commit21
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type CoordinationFilters = {
|
||||
record_search: string;
|
||||
record_state: string;
|
||||
};
|
||||
|
||||
export default function CoordinationRecordFilters({
|
||||
view,
|
||||
filters,
|
||||
states,
|
||||
}: {
|
||||
view: string;
|
||||
filters: CoordinationFilters;
|
||||
states: Record<string, string>;
|
||||
}) {
|
||||
const form = useForm({ view, ...filters });
|
||||
return (
|
||||
<details
|
||||
className="rounded border p-4"
|
||||
open={Boolean(filters.record_search || filters.record_state)}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Localizar registos por artigo, local, referência ou estado
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get('/compras/fornecimento', {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo, local ou referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={form.data.record_search}
|
||||
onChange={(event) =>
|
||||
form.setData('record_search', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.record_state}
|
||||
onChange={(event) =>
|
||||
form.setData('record_state', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(states).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Filtrar</Button>
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={`/compras/fornecimento?view=${view}`}
|
||||
>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function CorrectionSourceSearch({
|
||||
initial = '',
|
||||
search,
|
||||
title = 'Procurar origem pela referência',
|
||||
label = 'Número da recepção ou referência da operação',
|
||||
disabled = false,
|
||||
}: {
|
||||
initial?: string;
|
||||
search: (value: string) => void;
|
||||
title?: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<details className="rounded border p-3 md:col-span-2">
|
||||
<summary className="cursor-pointer text-sm">{title}</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
{label}
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!disabled) search(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
onClick={() => search(value)}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
As origens elegíveis já seleccionadas e os dados introduzidos
|
||||
são conservados.
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type CorrectionFilters = { search: string; state: string };
|
||||
|
||||
export default function InventoryCorrectionFilters({
|
||||
path,
|
||||
filters,
|
||||
}: {
|
||||
path: string;
|
||||
filters: CorrectionFilters;
|
||||
}) {
|
||||
const form = useForm({ search: filters.search, state: filters.state });
|
||||
return (
|
||||
<details
|
||||
className="rounded border p-4"
|
||||
open={Boolean(filters.search || filters.state)}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Filtrar por referência ou estado
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get(path, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={form.data.search}
|
||||
onChange={(event) =>
|
||||
form.setData('search', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.state}
|
||||
onChange={(event) =>
|
||||
form.setData('state', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="draft">Em preparação</option>
|
||||
<option value="returned">
|
||||
Devolvido para correcção
|
||||
</option>
|
||||
<option value="approved">Autorizado</option>
|
||||
<option value="executed">Executado</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Filtrar</Button>
|
||||
<Link className="text-sm underline" href={path}>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,14 @@ export type ProcurementInsights = {
|
||||
|
||||
export function ProcurementOperationalInsights({
|
||||
data,
|
||||
neededPastHref,
|
||||
withoutQuotesHref,
|
||||
withoutOrderHref,
|
||||
}: {
|
||||
data: ProcurementInsights;
|
||||
neededPastHref?: string;
|
||||
withoutQuotesHref?: string;
|
||||
withoutOrderHref?: string;
|
||||
}) {
|
||||
return (
|
||||
<Panel title="Leitura operacional de Compras">
|
||||
@@ -51,6 +57,16 @@ export function ProcurementOperationalInsights({
|
||||
precisa do bem ou serviço; não comprova atraso de
|
||||
entrega do fornecedor.
|
||||
</p>
|
||||
{neededPastHref &&
|
||||
data.unapproved_past_needed_date > 0 && (
|
||||
<Link
|
||||
className="inline-block underline"
|
||||
href={neededPastHref}
|
||||
>
|
||||
Consultar pedidos por aprovar com data de
|
||||
necessidade ultrapassada
|
||||
</Link>
|
||||
)}
|
||||
{data.pending.oldest.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
@@ -91,6 +107,27 @@ export function ProcurementOperationalInsights({
|
||||
comparáveis; ausência de ordem visível não comprova
|
||||
incumprimento.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{withoutQuotesHref &&
|
||||
data.approved.without_registered_quotes > 0 && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={withoutQuotesHref}
|
||||
>
|
||||
Consultar aprovadas sem propostas
|
||||
registadas
|
||||
</Link>
|
||||
)}
|
||||
{withoutOrderHref &&
|
||||
data.approved.without_visible_order > 0 && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={withoutOrderHref}
|
||||
>
|
||||
Consultar aprovadas sem ordem visível
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import { Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { date } from '@/types/procurement';
|
||||
|
||||
export type ReceiptDocument = {
|
||||
id: number;
|
||||
document_type: string;
|
||||
original_name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
uploaded_by: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function ReceiptDocuments({
|
||||
receiptId,
|
||||
ready,
|
||||
canAdd,
|
||||
documents,
|
||||
}: {
|
||||
receiptId: number;
|
||||
ready: boolean;
|
||||
canAdd: boolean;
|
||||
documents: ReceiptDocument[];
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const form = useForm<{
|
||||
operation_key: string;
|
||||
document_type: string;
|
||||
file: File | null;
|
||||
}>({
|
||||
operation_key: crypto.randomUUID(),
|
||||
document_type: 'delivery_note',
|
||||
file: null,
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
delivery_note: 'Guia de remessa',
|
||||
quality: 'Evidência de qualidade',
|
||||
other: 'Outro documento',
|
||||
};
|
||||
return (
|
||||
<Panel
|
||||
title="Anexos da recepção"
|
||||
description="Evidências ligadas à confirmação preservada. Anexar não altera quantidades, qualidade ou entradas de stock."
|
||||
>
|
||||
<div id="receipt-documents" />
|
||||
{!ready ? (
|
||||
<p className="text-sm">
|
||||
Anexos ainda indisponíveis nesta instalação.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sem anexos nesta recepção.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{documents.map((document) => (
|
||||
<li
|
||||
key={document.id}
|
||||
className="rounded border p-3 text-sm"
|
||||
>
|
||||
<a
|
||||
className="font-medium underline"
|
||||
href={`/recepcoes/${receiptId}/documentos/${document.id}`}
|
||||
>
|
||||
{document.original_name}
|
||||
</a>
|
||||
<p>
|
||||
{labels[document.document_type]} ·{' '}
|
||||
{Math.ceil(document.size / 1024)} KB ·
|
||||
Registado por #{document.uploaded_by} ·{' '}
|
||||
{date(document.created_at)}
|
||||
</p>
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer">
|
||||
Identificação do ficheiro preservado
|
||||
</summary>
|
||||
<code className="break-all">
|
||||
SHA-256 {document.sha256}
|
||||
</code>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canAdd && (
|
||||
<details className="mt-4 rounded border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Acrescentar evidência
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 grid gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.post(
|
||||
`/recepcoes/${receiptId}/documentos`,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.setData({
|
||||
operation_key:
|
||||
crypto.randomUUID(),
|
||||
document_type:
|
||||
'delivery_note',
|
||||
file: null,
|
||||
});
|
||||
if (input.current)
|
||||
input.current.value = '';
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Tipo de documento
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.document_type}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'document_type',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(labels).map(
|
||||
([value, label]) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Ficheiro PDF, JPG ou PNG — até 2 MB
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
required
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'file',
|
||||
event.target.files?.[0] ?? null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ficheiros idênticos do mesmo tipo são
|
||||
reutilizados. Os documentos anteriores
|
||||
permanecem preservados.
|
||||
</p>
|
||||
{Object.entries(form.errors).map(
|
||||
([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
<Button disabled={form.processing}>
|
||||
Anexar à recepção
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export type SupplyPageInfo = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
|
||||
export default function SupplyPagination({
|
||||
page,
|
||||
label = 'Paginação dos registos de fornecimento',
|
||||
}: {
|
||||
page?: SupplyPageInfo | null;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!page) return null;
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="my-4 flex flex-wrap items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{page.total} registos · Página {page.current_page} de{' '}
|
||||
{page.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{page.prev_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{page.next_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
/** Secrets stay in this mounted component, never in page props or remembered state. */
|
||||
export default function TwoFactorManagement({
|
||||
enabled: initialEnabled,
|
||||
pending: initialPending,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [pending, setPending] = useState(initialPending);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirm, setConfirm] = useState<'disable' | 'regenerate' | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
async function request(
|
||||
path: string,
|
||||
method = 'GET',
|
||||
data?: Record<string, string>,
|
||||
) {
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
|
||||
?.slice(11);
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(method !== 'GET'
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(data ? { body: JSON.stringify(data) } : {}),
|
||||
});
|
||||
if (response.status === 423)
|
||||
throw new Error(
|
||||
'Confirme novamente a palavra-passe, abrindo esta página outra vez.',
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
response.status === 422
|
||||
? (body?.errors?.code?.[0] ??
|
||||
'Confirme os dados introduzidos.')
|
||||
: 'Não foi possível concluir. Actualize a página e tente novamente.',
|
||||
);
|
||||
}
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
async function run(action: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await action();
|
||||
} catch (failure) {
|
||||
setError(
|
||||
failure instanceof Error
|
||||
? failure.message
|
||||
: 'Não foi possível concluir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const loadSecret = async () => {
|
||||
const data = await request('/user/two-factor-secret-key');
|
||||
setSecret(data.secretKey);
|
||||
};
|
||||
const loadCodes = async () => {
|
||||
setCodes(await request('/user/two-factor-recovery-codes'));
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 rounded border p-4"
|
||||
aria-labelledby="mfa-heading"
|
||||
>
|
||||
<h2 id="mfa-heading" className="font-semibold">
|
||||
Autenticação de dois factores
|
||||
</h2>
|
||||
<p className="text-sm">
|
||||
{enabled
|
||||
? 'Activa. Nos próximos acessos será pedido o código da aplicação ou um código de recuperação.'
|
||||
: pending
|
||||
? 'Adesão por confirmar. O segundo factor só será activado depois de confirmar um código válido.'
|
||||
: 'Acrescente um código da aplicação autenticadora à palavra-passe desta conta.'}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm">
|
||||
{error}{' '}
|
||||
<a className="underline" href="/settings/security">
|
||||
Reabrir segurança da conta
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{!enabled && !pending && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await request(
|
||||
'/user/two-factor-authentication',
|
||||
'POST',
|
||||
);
|
||||
setPending(true);
|
||||
await loadSecret();
|
||||
})
|
||||
}
|
||||
>
|
||||
Iniciar adesão
|
||||
</Button>
|
||||
)}
|
||||
{pending && !secret && (
|
||||
<Button disabled={busy} onClick={() => void run(loadSecret)}>
|
||||
Continuar adesão
|
||||
</Button>
|
||||
)}
|
||||
{pending && secret && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Na aplicação autenticadora, adicione uma conta com esta
|
||||
chave manual. Não partilhe esta chave.
|
||||
</p>
|
||||
<code className="block rounded bg-muted p-3 break-all">
|
||||
{secret}
|
||||
</code>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void run(async () => {
|
||||
await request(
|
||||
'/user/confirmed-two-factor-authentication',
|
||||
'POST',
|
||||
{ code },
|
||||
);
|
||||
setEnabled(true);
|
||||
setPending(false);
|
||||
setSecret('');
|
||||
setCode('');
|
||||
await loadCodes();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Código da aplicação
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(event) =>
|
||||
setCode(event.target.value)
|
||||
}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={busy}>
|
||||
Confirmar e mostrar códigos de recuperação
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{enabled && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (codes.length) setCodes([]);
|
||||
else void run(loadCodes);
|
||||
}}
|
||||
>
|
||||
{codes.length
|
||||
? 'Ocultar códigos'
|
||||
: 'Mostrar códigos de recuperação'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm('regenerate')}
|
||||
>
|
||||
Renovar códigos
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{codes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
Guarde estes códigos num local privado. Cada um permite
|
||||
recuperar um acesso e só pode ser usado uma vez.
|
||||
</p>
|
||||
<ul className="grid gap-2 font-mono sm:grid-cols-2">
|
||||
{codes.map((value) => (
|
||||
<li key={value}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(enabled || pending) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm('disable')}
|
||||
>
|
||||
{pending ? 'Cancelar adesão' : 'Desactivar segundo factor'}
|
||||
</Button>
|
||||
)}
|
||||
{confirm && (
|
||||
<div
|
||||
className="space-y-3 rounded border p-3"
|
||||
role="group"
|
||||
aria-label="Confirmar alteração de segurança"
|
||||
>
|
||||
<p className="text-sm">
|
||||
{confirm === 'disable'
|
||||
? 'Esta conta deixará de exigir o segundo factor. A palavra-passe permanece necessária.'
|
||||
: 'Os códigos anteriores deixarão de funcionar. Guarde os novos códigos depois de confirmar.'}
|
||||
</p>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
if (confirm === 'disable') {
|
||||
await request(
|
||||
'/user/two-factor-authentication',
|
||||
'DELETE',
|
||||
);
|
||||
setEnabled(false);
|
||||
setPending(false);
|
||||
setSecret('');
|
||||
setCode('');
|
||||
setCodes([]);
|
||||
} else {
|
||||
setCodes([]);
|
||||
await request(
|
||||
'/user/two-factor-recovery-codes',
|
||||
'POST',
|
||||
);
|
||||
await loadCodes();
|
||||
}
|
||||
setConfirm(null);
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirmar{' '}
|
||||
{confirm === 'disable' ? 'desactivação' : 'renovação'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm(null)}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import InputError from '@/components/input-error';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Confirmar palavra-passe" />
|
||||
<h1 className="text-xl font-semibold">Confirmar palavra-passe</h1>
|
||||
<p className="my-4 text-sm">
|
||||
Confirme a palavra-passe da sua conta para continuar esta
|
||||
operação.
|
||||
</p>
|
||||
<Form
|
||||
action="/user/confirm-password"
|
||||
method="post"
|
||||
resetOnError
|
||||
resetOnSuccess
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="space-y-4">
|
||||
<label className="grid gap-2">
|
||||
Palavra-passe actual
|
||||
<PasswordInput
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<InputError message={errors.password} />
|
||||
<Button disabled={processing}>
|
||||
Confirmar palavra-passe
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import InputError from '@/components/input-error';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const [recovery, setRecovery] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Head title="Confirmar acesso" />
|
||||
<h1 className="text-xl font-semibold">Confirmar acesso à conta</h1>
|
||||
<p className="my-4 text-sm">
|
||||
{recovery
|
||||
? 'Introduza um dos códigos de recuperação guardados. Cada código só pode ser utilizado uma vez.'
|
||||
: 'Introduza o código da aplicação de autenticação.'}
|
||||
</p>
|
||||
<Form
|
||||
key={String(recovery)}
|
||||
action="/two-factor-challenge"
|
||||
method="post"
|
||||
resetOnError
|
||||
resetOnSuccess
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="space-y-4">
|
||||
<label className="grid gap-2">
|
||||
{recovery
|
||||
? 'Código de recuperação'
|
||||
: 'Código de autenticação'}
|
||||
<Input
|
||||
autoFocus
|
||||
name={recovery ? 'recovery_code' : 'code'}
|
||||
autoComplete={
|
||||
recovery ? 'off' : 'one-time-code'
|
||||
}
|
||||
inputMode={recovery ? 'text' : 'numeric'}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<InputError
|
||||
message={errors.code ?? errors.recovery_code}
|
||||
/>
|
||||
<Button disabled={processing}>Confirmar acesso</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => setRecovery(!recovery)}
|
||||
>
|
||||
{recovery
|
||||
? 'Usar aplicação de autenticação'
|
||||
: 'Usar código de recuperação'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,26 @@ import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { date } from '@/types/procurement';
|
||||
import { quantity, qualityStates, receiptStates } from '@/types/inventory';
|
||||
import type { Receipt, StockMovement } from '@/types/inventory';
|
||||
import ReceiptDocuments, {
|
||||
type ReceiptDocument,
|
||||
} from '@/components/receipt-documents';
|
||||
|
||||
export default function ReceiptShow({
|
||||
receipt,
|
||||
movements,
|
||||
correctedReceiptItems = {},
|
||||
documentsReady = false,
|
||||
canAddDocuments = false,
|
||||
documents = [],
|
||||
purchaseProcessHref = null,
|
||||
}: {
|
||||
receipt: Receipt;
|
||||
movements: StockMovement[];
|
||||
correctedReceiptItems?: Record<number, string>;
|
||||
documentsReady?: boolean;
|
||||
canAddDocuments?: boolean;
|
||||
documents?: ReceiptDocument[];
|
||||
purchaseProcessHref?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -24,6 +35,20 @@ export default function ReceiptShow({
|
||||
description={`${receipt.snapshot.supplier.name} · ${receiptStates[receipt.status]}`}
|
||||
/>
|
||||
<InventoryNotice />
|
||||
<a
|
||||
href={`/recepcoes/${receipt.id}/imprimir`}
|
||||
className="text-sm underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Imprimir ou guardar a recepção como PDF
|
||||
</a>
|
||||
<ReceiptDocuments
|
||||
receiptId={receipt.id}
|
||||
ready={documentsReady}
|
||||
canAdd={canAddDocuments}
|
||||
documents={documents}
|
||||
/>
|
||||
<Panel title="Confirmação preservada">
|
||||
<dl className="grid gap-5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
@@ -122,6 +147,14 @@ export default function ReceiptShow({
|
||||
>
|
||||
Consultar a ordem e as quantidades pendentes
|
||||
</Link>
|
||||
{purchaseProcessHref && (
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={purchaseProcessHref}
|
||||
>
|
||||
Acompanhar o processo de compra de origem
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ type Row = {
|
||||
number: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
needed_by: string | null;
|
||||
department: string | null;
|
||||
requester: string | null;
|
||||
total_minor: string;
|
||||
@@ -23,6 +24,7 @@ type Row = {
|
||||
order_number: string | null;
|
||||
};
|
||||
type Filters = {
|
||||
attention?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
@@ -61,11 +63,18 @@ export default function ProcurementReports({
|
||||
from: filters.from ?? '',
|
||||
to: filters.to ?? '',
|
||||
department_id: String(filters.department_id ?? ''),
|
||||
attention: filters.attention ?? '',
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters))
|
||||
if (value !== null && value !== undefined && value !== '')
|
||||
params.set(key, String(value));
|
||||
const attentionParams = new URLSearchParams(params);
|
||||
attentionParams.set('attention', 'unapproved_needed');
|
||||
const quoteParams = new URLSearchParams(params);
|
||||
quoteParams.set('attention', 'approved_without_quotes');
|
||||
const orderParams = new URLSearchParams(params);
|
||||
orderParams.set('attention', 'approved_without_order');
|
||||
return (
|
||||
<>
|
||||
<Head title="Relatórios de compras" />
|
||||
@@ -83,7 +92,14 @@ export default function ProcurementReports({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{insights && <ProcurementOperationalInsights data={insights} />}
|
||||
{insights && (
|
||||
<ProcurementOperationalInsights
|
||||
data={insights}
|
||||
neededPastHref={`/relatorios?${attentionParams}`}
|
||||
withoutQuotesHref={`/relatorios?${quoteParams}`}
|
||||
withoutOrderHref={`/relatorios?${orderParams}`}
|
||||
/>
|
||||
)}
|
||||
<Panel
|
||||
title="Filtrar requisições"
|
||||
description="O período refere-se à criação da requisição. Os resultados respeitam o seu acesso à empresa e ao departamento."
|
||||
@@ -166,6 +182,31 @@ export default function ProcurementReports({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Acompanhamento
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.attention}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'attention',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="unapproved_needed">
|
||||
Data de necessidade ultrapassada, por
|
||||
aprovar
|
||||
</option>
|
||||
<option value="approved_without_quotes">
|
||||
Aprovadas sem propostas registadas
|
||||
</option>
|
||||
<option value="approved_without_order">
|
||||
Aprovadas sem ordem visível
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end gap-3">
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
Aplicar filtros
|
||||
@@ -240,6 +281,12 @@ export default function ProcurementReports({
|
||||
<span className="block text-muted-foreground">
|
||||
{date(row.created_at)}
|
||||
</span>
|
||||
{row.needed_by && (
|
||||
<span className="block text-sm">
|
||||
Necessário em{' '}
|
||||
{row.needed_by}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{row.department ??
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function Show({
|
||||
canDecide,
|
||||
conflictBlocked,
|
||||
audit,
|
||||
auditPagination,
|
||||
technical,
|
||||
comparisonPolicy,
|
||||
replenishmentSource,
|
||||
@@ -79,6 +80,13 @@ export default function Show({
|
||||
canDecide: boolean;
|
||||
conflictBlocked: boolean;
|
||||
audit: Audit[];
|
||||
auditPagination?: {
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
technical?: {
|
||||
canReview: boolean;
|
||||
basis_hash: string;
|
||||
@@ -685,6 +693,13 @@ export default function Show({
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Histórico">
|
||||
<div id="requisition-history" />
|
||||
<a
|
||||
className="mb-4 inline-block text-sm underline"
|
||||
href={`/requisicoes/${r.id}/historico.csv`}
|
||||
>
|
||||
Exportar metadados do histórico (CSV)
|
||||
</a>
|
||||
<ol className="space-y-4">
|
||||
{audit.map((a) => (
|
||||
<li
|
||||
@@ -705,6 +720,48 @@ export default function Show({
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{auditPagination && (
|
||||
<nav
|
||||
aria-label="Paginação do histórico da requisição"
|
||||
className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{auditPagination.total} eventos · Página{' '}
|
||||
{auditPagination.current_page} de{' '}
|
||||
{auditPagination.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{auditPagination.prev_page_url && (
|
||||
<Link
|
||||
preserveState
|
||||
preserveScroll
|
||||
only={[
|
||||
'audit',
|
||||
'auditPagination',
|
||||
]}
|
||||
className="underline"
|
||||
href={`${auditPagination.prev_page_url}#requisition-history`}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{auditPagination.next_page_url && (
|
||||
<Link
|
||||
preserveState
|
||||
preserveScroll
|
||||
only={[
|
||||
'audit',
|
||||
'auditPagination',
|
||||
]}
|
||||
className="underline"
|
||||
href={`${auditPagination.next_page_url}#requisition-history`}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SecurityController from '@/actions/App/Http/Controllers/Settings/Security
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import TwoFactorManagement from '@/components/two-factor-management';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { edit } from '@/routes/security';
|
||||
@@ -11,6 +12,9 @@ import { edit } from '@/routes/security';
|
||||
// oxfmt-ignore
|
||||
type Props = {
|
||||
passwordRules: string;
|
||||
canManageTwoFactor?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorPending?: boolean;
|
||||
};
|
||||
|
||||
export default function Security(props: Props) {
|
||||
@@ -24,6 +28,12 @@ export default function Security(props: Props) {
|
||||
<h1 className="sr-only">Segurança da conta</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{props.canManageTwoFactor && (
|
||||
<TwoFactorManagement
|
||||
enabled={Boolean(props.twoFactorEnabled)}
|
||||
pending={Boolean(props.twoFactorPending)}
|
||||
/>
|
||||
)}
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Alterar palavra-passe"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CorrectionSourceSearch from '@/components/correction-source-search';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -7,6 +9,12 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplyPagination, {
|
||||
type SupplyPageInfo,
|
||||
} from '@/components/supply-pagination';
|
||||
import InventoryCorrectionFilters, {
|
||||
type CorrectionFilters,
|
||||
} from '@/components/inventory-correction-filters';
|
||||
|
||||
type Source = {
|
||||
item: { code: string; name: string; unit: string };
|
||||
@@ -19,6 +27,10 @@ type Source = {
|
||||
} | null;
|
||||
};
|
||||
type RecordEntry = {
|
||||
executionSourceCurrent?: boolean | null;
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canExecute?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -44,6 +56,12 @@ type RecordEntry = {
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
sourceSearch?: string;
|
||||
sourceFormKey?: string | null;
|
||||
recordPagination?: SupplyPageInfo | null;
|
||||
filters: CorrectionFilters;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
canViewReversals: boolean;
|
||||
correctedOperations?: Record<number, string>;
|
||||
canViewOperations: boolean;
|
||||
@@ -83,6 +101,12 @@ const states: Record<string, string> = {
|
||||
};
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
const [receipts, setReceipts] = useState(props.receipts);
|
||||
const formKey = `disposition:${old?.id ?? 'new'}`;
|
||||
useEffect(() => {
|
||||
if (!props.sourceFormKey || props.sourceFormKey === formKey)
|
||||
setReceipts(props.receipts);
|
||||
}, [props.sourceFormKey, props.receipts, formKey]);
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
@@ -101,6 +125,27 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
reference: old?.data.reference ?? '',
|
||||
},
|
||||
});
|
||||
const searchSources = (value: string) =>
|
||||
router.get(
|
||||
'/stock/ocorrencias',
|
||||
{
|
||||
records_page: props.recordPagination?.current_page,
|
||||
search: props.filters.search || undefined,
|
||||
state: props.filters.state || undefined,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
source_search: value,
|
||||
source_form_key: formKey,
|
||||
selected_receipt_id:
|
||||
form.data.data.receipt_item_id || undefined,
|
||||
},
|
||||
{
|
||||
only: ['receipts', 'sourceSearch', 'sourceFormKey'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
const change = (update: Partial<typeof form.data.data>) =>
|
||||
form.setData('data', { ...form.data.data, ...update });
|
||||
const lots = props.lots.filter(
|
||||
@@ -119,6 +164,10 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<CorrectionSourceSearch
|
||||
initial={props.sourceSearch}
|
||||
search={searchSources}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Operação
|
||||
<select
|
||||
@@ -148,7 +197,7 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
className={controlClass}
|
||||
value={form.data.data.receipt_item_id}
|
||||
onChange={(e) => {
|
||||
const source = props.receipts.find(
|
||||
const source = receipts.find(
|
||||
(r) => String(r.id) === e.target.value,
|
||||
);
|
||||
change({
|
||||
@@ -164,7 +213,7 @@ function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccione a recepção aceite</option>
|
||||
{props.receipts.map((r) => (
|
||||
{receipts.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label} · Devolvível da origem:{' '}
|
||||
{quantity(r.accepted_milli - r.returned_milli)}
|
||||
@@ -274,23 +323,11 @@ function RecordActions({
|
||||
record: RecordEntry;
|
||||
}) {
|
||||
const choices: { value: string; label: string }[] = [];
|
||||
if (
|
||||
props.capabilities.review &&
|
||||
!record.snapshot.contributors.includes(props.actorId)
|
||||
) {
|
||||
if (['draft', 'returned'].includes(record.state))
|
||||
choices.push({ value: 'approve', label: 'Conferir e aprovar' });
|
||||
if (['draft', 'returned', 'approved'].includes(record.state))
|
||||
choices.push({
|
||||
value: 'return',
|
||||
label: 'Devolver para actualização',
|
||||
});
|
||||
}
|
||||
if (
|
||||
props.capabilities.execute &&
|
||||
record.state === 'approved' &&
|
||||
record.snapshot.reviewed_by !== props.actorId
|
||||
)
|
||||
if (record.canApprove)
|
||||
choices.push({ value: 'approve', label: 'Conferir e aprovar' });
|
||||
if (record.canReturn)
|
||||
choices.push({ value: 'return', label: 'Devolver para actualização' });
|
||||
if (record.canExecute && record.executionSourceCurrent !== false)
|
||||
choices.push({ value: 'execute', label: 'Confirmar saída física' });
|
||||
return (
|
||||
<InventoryEventActions
|
||||
@@ -342,11 +379,34 @@ export default function Dispositions(props: Props) {
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
href="/stock/ocorrencias"
|
||||
className="underline"
|
||||
>
|
||||
Ver todas as ocorrências
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
Esta ocorrência foi actualizada. Está a
|
||||
consultar a versão actual; as anteriores
|
||||
permanecem no histórico.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.capabilities.prepare && !props.focusRecordId && (
|
||||
<Panel title="Preparar saída">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
)}
|
||||
{!props.focusRecordId && (
|
||||
<InventoryCorrectionFilters
|
||||
path="/stock/ocorrencias"
|
||||
filters={props.filters}
|
||||
/>
|
||||
)}
|
||||
{props.records.map((record) => (
|
||||
<Panel
|
||||
key={record.id}
|
||||
@@ -360,6 +420,14 @@ export default function Dispositions(props: Props) {
|
||||
: states[record.state]
|
||||
}
|
||||
>
|
||||
<div id={`disposition-${record.id}`} />
|
||||
{record.executionSourceCurrent === false && (
|
||||
<p role="status" className="mb-3 text-sm">
|
||||
A origem ou o saldo mudou. A execução
|
||||
aguarda reposição válida ou devolução
|
||||
pelo conferente para nova preparação.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{record.snapshot.source.item.code} ·{' '}
|
||||
{record.snapshot.source.item.name} ·{' '}
|
||||
@@ -447,6 +515,10 @@ export default function Dispositions(props: Props) {
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<SupplyPagination
|
||||
page={props.recordPagination}
|
||||
label="Paginação das ocorrências de stock"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -15,6 +15,7 @@ type Metadata = {
|
||||
quantity_milli: number;
|
||||
};
|
||||
type RecordEntry = {
|
||||
canActivate?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -41,6 +42,10 @@ type RecordEntry = {
|
||||
};
|
||||
};
|
||||
type Lot = {
|
||||
canIdentify: boolean;
|
||||
canRelease: boolean;
|
||||
canHold: boolean;
|
||||
quality_released: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -57,6 +62,11 @@ type Lot = {
|
||||
release_setup_id?: number | null;
|
||||
};
|
||||
type Props = {
|
||||
controlPagination?: PageInfo | null;
|
||||
historyPagination?: PageInfo | null;
|
||||
focusLotId?: number | null;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
capabilities: Record<string, boolean>;
|
||||
@@ -75,6 +85,47 @@ type Props = {
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
history: RecordEntry[];
|
||||
};
|
||||
type PageInfo = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
|
||||
function Pages({ page, label }: { page?: PageInfo | null; label: string }) {
|
||||
if (!page) return null;
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="my-4 flex flex-wrap items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{page.total} · Página {page.current_page} de {page.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{page.prev_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{page.next_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
const qty = (n: number) =>
|
||||
(n / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
const states: Record<string, string> = {
|
||||
@@ -518,7 +569,22 @@ export default function Lots(props: Props) {
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link href="/stock/lotes" className="underline">
|
||||
Ver todas as configurações
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
A proposta foi actualizada. É
|
||||
apresentada a versão actual; a
|
||||
configuração em vigor permanece visível
|
||||
quando aplicável.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.capabilities.prepare && !props.focusRecordId && (
|
||||
<Panel title="Preparar novo controlo">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
@@ -533,6 +599,7 @@ export default function Lots(props: Props) {
|
||||
states[record.state] ?? record.state
|
||||
}
|
||||
>
|
||||
<div id={`lot-setup-${record.id}`} />
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Validade{' '}
|
||||
{record.data.requires_expiry
|
||||
@@ -608,15 +675,12 @@ export default function Lots(props: Props) {
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
{props.capabilities.review &&
|
||||
!record.snapshot.contributors.includes(
|
||||
props.actorId,
|
||||
) && (
|
||||
<Decision
|
||||
record={record}
|
||||
action="activate"
|
||||
/>
|
||||
)}
|
||||
{record.canActivate && (
|
||||
<Decision
|
||||
record={record}
|
||||
action="activate"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
context && (
|
||||
@@ -665,6 +729,7 @@ export default function Lots(props: Props) {
|
||||
)}
|
||||
{context.lots.map((lot) => (
|
||||
<article
|
||||
id={`lot-${record.data.warehouse_id}-${lot.id}`}
|
||||
key={lot.id}
|
||||
className="mt-4 rounded-lg border p-4"
|
||||
>
|
||||
@@ -713,8 +778,7 @@ export default function Lots(props: Props) {
|
||||
{lot.last_usable_on &&
|
||||
` · Último dia utilizável: ${lot.last_usable_on}`}
|
||||
</p>
|
||||
{props.capabilities
|
||||
.prepare && (
|
||||
{lot.canIdentify && (
|
||||
<Identify
|
||||
key={`identify-${lot.version}`}
|
||||
lot={lot}
|
||||
@@ -724,15 +788,9 @@ export default function Lots(props: Props) {
|
||||
lot.quantity_milli >
|
||||
0 && (
|
||||
<>
|
||||
{props
|
||||
.capabilities
|
||||
.release &&
|
||||
lot.eligible &&
|
||||
{lot.canRelease &&
|
||||
lot.state !==
|
||||
'released' &&
|
||||
!lot.contributors.includes(
|
||||
props.actorId,
|
||||
) && (
|
||||
'released' && (
|
||||
<Decision
|
||||
key={`release-${lot.version}-${lot.state_version}`}
|
||||
record={
|
||||
@@ -748,11 +806,8 @@ export default function Lots(props: Props) {
|
||||
action="release"
|
||||
/>
|
||||
)}
|
||||
{props
|
||||
.capabilities
|
||||
.hold &&
|
||||
lot.state ===
|
||||
'released' && (
|
||||
{lot.canHold &&
|
||||
lot.quality_released && (
|
||||
<Decision
|
||||
key={`hold-${lot.version}-${lot.state_version}`}
|
||||
record={
|
||||
@@ -778,10 +833,22 @@ export default function Lots(props: Props) {
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
<Pages
|
||||
page={props.controlPagination}
|
||||
label="Paginação das configurações de lotes"
|
||||
/>
|
||||
<Panel title="Histórico de conferências">
|
||||
<details>
|
||||
<details
|
||||
open={
|
||||
(props.historyPagination?.current_page ??
|
||||
1) > 1
|
||||
}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Consultar os últimos 200 registos
|
||||
Consultar histórico (
|
||||
{props.historyPagination?.total ??
|
||||
props.history.length}{' '}
|
||||
registos)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{props.history.map((record) => (
|
||||
@@ -795,6 +862,10 @@ export default function Lots(props: Props) {
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Pages
|
||||
page={props.historyPagination}
|
||||
label="Paginação do histórico de lotes"
|
||||
/>
|
||||
</details>
|
||||
</Panel>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
type RecordRow = {
|
||||
canApprove?: boolean;
|
||||
canSuspend?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -72,6 +74,8 @@ function Pagination<T>({
|
||||
) : null;
|
||||
}
|
||||
type Props = {
|
||||
focusRuleId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
ready: boolean;
|
||||
canPrepare: boolean;
|
||||
canReview: boolean;
|
||||
@@ -376,7 +380,19 @@ export default function Replenishment(props: Props) {
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.canPrepare && (
|
||||
{props.focusRuleId != null && (
|
||||
<Link className="underline" href="/stock/reposicao">
|
||||
Ver todas as parametrizações e necessidades
|
||||
</Link>
|
||||
)}
|
||||
{props.focusChanged && (
|
||||
<p className="rounded border p-3 text-sm">
|
||||
A parametrização mudou desde o encaminhamento.
|
||||
Confira a versão actual e o histórico antes de
|
||||
intervir.
|
||||
</p>
|
||||
)}
|
||||
{props.canPrepare && props.focusRuleId == null && (
|
||||
<Panel title="Parametrizar artigo e armazém">
|
||||
<RuleForm props={props} />
|
||||
</Panel>
|
||||
@@ -458,6 +474,7 @@ export default function Replenishment(props: Props) {
|
||||
) : (
|
||||
props.rules?.data.map((r) => (
|
||||
<div
|
||||
id={`supply-rule-${r.id}`}
|
||||
className="mb-4 rounded border p-4"
|
||||
key={r.id}
|
||||
>
|
||||
@@ -477,22 +494,20 @@ export default function Replenishment(props: Props) {
|
||||
MZN · Imposto {r.data.tax_rate}% ·
|
||||
Validade {r.data.valid_until}
|
||||
</p>
|
||||
{props.canReview &&
|
||||
r.state === 'draft' && (
|
||||
<Decision
|
||||
key={`approve:${r.id}`}
|
||||
rule={r}
|
||||
action="approve"
|
||||
/>
|
||||
)}
|
||||
{props.canReview &&
|
||||
r.state === 'approved' && (
|
||||
<Decision
|
||||
key={`suspend:${r.id}`}
|
||||
rule={r}
|
||||
action="suspend"
|
||||
/>
|
||||
)}
|
||||
{r.canApprove && (
|
||||
<Decision
|
||||
key={`approve:${r.id}`}
|
||||
rule={r}
|
||||
action="approve"
|
||||
/>
|
||||
)}
|
||||
{r.canSuspend && (
|
||||
<Decision
|
||||
key={`suspend:${r.id}`}
|
||||
rule={r}
|
||||
action="suspend"
|
||||
/>
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
<details className="mt-3">
|
||||
<summary>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CorrectionSourceSearch from '@/components/correction-source-search';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -7,6 +9,12 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplyPagination, {
|
||||
type SupplyPageInfo,
|
||||
} from '@/components/supply-pagination';
|
||||
import InventoryCorrectionFilters, {
|
||||
type CorrectionFilters,
|
||||
} from '@/components/inventory-correction-filters';
|
||||
|
||||
type Row = {
|
||||
source_movement_id: number;
|
||||
@@ -18,6 +26,10 @@ type Row = {
|
||||
lots: { lot_id: number | null; code: string | null; quantity: number }[];
|
||||
};
|
||||
type Entry = {
|
||||
executionSourceCurrent?: boolean | null;
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canExecute?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -48,6 +60,12 @@ type Entry = {
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
sourceSearch?: string;
|
||||
sourceFormKey?: string | null;
|
||||
recordPagination?: SupplyPageInfo | null;
|
||||
filters: CorrectionFilters;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
today: string;
|
||||
@@ -68,6 +86,15 @@ const states: Record<string, string> = {
|
||||
};
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
const [operations, setOperations] = useState(props.operations);
|
||||
const [receipts, setReceipts] = useState(props.receipts);
|
||||
const formKey = `reversal:${old?.id ?? 'new'}`;
|
||||
useEffect(() => {
|
||||
if (!props.sourceFormKey || props.sourceFormKey === formKey) {
|
||||
setOperations(props.operations);
|
||||
setReceipts(props.receipts);
|
||||
}
|
||||
}, [props.sourceFormKey, props.operations, props.receipts, formKey]);
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
@@ -85,6 +112,34 @@ function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
: '',
|
||||
},
|
||||
});
|
||||
const searchSources = (value: string) =>
|
||||
router.get(
|
||||
'/stock/estornos',
|
||||
{
|
||||
records_page: props.recordPagination?.current_page,
|
||||
search: props.filters.search || undefined,
|
||||
state: props.filters.state || undefined,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
source_search: value,
|
||||
source_form_key: formKey,
|
||||
selected_operation_id:
|
||||
form.data.data.source_operation_id || undefined,
|
||||
selected_receipt_id:
|
||||
form.data.data.receipt_item_id || undefined,
|
||||
},
|
||||
{
|
||||
only: [
|
||||
'operations',
|
||||
'receipts',
|
||||
'sourceSearch',
|
||||
'sourceFormKey',
|
||||
],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
@@ -94,6 +149,10 @@ function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<CorrectionSourceSearch
|
||||
initial={props.sourceSearch}
|
||||
search={searchSources}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Origem
|
||||
<select
|
||||
@@ -131,7 +190,7 @@ function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
<option value="">
|
||||
Seleccione a operação a corrigir
|
||||
</option>
|
||||
{props.operations.map((op) => (
|
||||
{operations.map((op) => (
|
||||
<option key={op.id} value={op.id}>
|
||||
{op.label}
|
||||
</option>
|
||||
@@ -153,7 +212,7 @@ function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione a linha a anular</option>
|
||||
{props.receipts.map((receipt) => (
|
||||
{receipts.map((receipt) => (
|
||||
<option key={receipt.id} value={receipt.id}>
|
||||
{receipt.label}
|
||||
</option>
|
||||
@@ -200,23 +259,11 @@ function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
}
|
||||
function RecordActions({ props, record }: { props: Props; record: Entry }) {
|
||||
const choices: { value: string; label: string }[] = [];
|
||||
if (
|
||||
props.capabilities.review &&
|
||||
!record.snapshot.contributors.includes(props.actorId)
|
||||
) {
|
||||
if (['draft', 'returned'].includes(record.state))
|
||||
choices.push({ value: 'approve', label: 'Conferir e aprovar' });
|
||||
if (['draft', 'returned', 'approved'].includes(record.state))
|
||||
choices.push({
|
||||
value: 'return',
|
||||
label: 'Devolver para actualização',
|
||||
});
|
||||
}
|
||||
if (
|
||||
props.capabilities.execute &&
|
||||
record.state === 'approved' &&
|
||||
record.snapshot.reviewed_by !== props.actorId
|
||||
)
|
||||
if (record.canApprove)
|
||||
choices.push({ value: 'approve', label: 'Conferir e aprovar' });
|
||||
if (record.canReturn)
|
||||
choices.push({ value: 'return', label: 'Devolver para actualização' });
|
||||
if (record.canExecute && record.executionSourceCurrent !== false)
|
||||
choices.push({ value: 'execute', label: 'Confirmar estorno integral' });
|
||||
return (
|
||||
<InventoryEventActions
|
||||
@@ -274,17 +321,49 @@ export default function Reversals(props: Props) {
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
href="/stock/estornos"
|
||||
className="underline"
|
||||
>
|
||||
Ver todos os estornos
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
Este estorno foi actualizado. Está a
|
||||
consultar a versão actual; as anteriores
|
||||
permanecem no histórico.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.capabilities.prepare && !props.focusRecordId && (
|
||||
<Panel title="Preparar correcção">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
)}
|
||||
{!props.focusRecordId && (
|
||||
<InventoryCorrectionFilters
|
||||
path="/stock/estornos"
|
||||
filters={props.filters}
|
||||
/>
|
||||
)}
|
||||
{props.records.map((record) => (
|
||||
<Panel
|
||||
key={record.id}
|
||||
title={`${record.data.reference} · ${states[record.state]}`}
|
||||
description={`Origem: ${record.snapshot.source.document.reference}`}
|
||||
>
|
||||
<div id={`reversal-${record.id}`} />
|
||||
{record.executionSourceCurrent === false && (
|
||||
<p role="status" className="mb-3 text-sm">
|
||||
A origem ou o saldo mudou. A execução
|
||||
aguarda disponibilidade válida ou
|
||||
devolução pelo conferente para nova
|
||||
preparação.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{record.snapshot.source.item.code} ·{' '}
|
||||
{record.snapshot.source.item.name}
|
||||
@@ -402,6 +481,10 @@ export default function Reversals(props: Props) {
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<SupplyPagination
|
||||
page={props.recordPagination}
|
||||
label="Paginação dos estornos de stock"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ type Row = {
|
||||
};
|
||||
export default function SupplierEvaluations({
|
||||
ready,
|
||||
canCreate,
|
||||
records,
|
||||
filters,
|
||||
suppliers,
|
||||
@@ -27,6 +28,7 @@ export default function SupplierEvaluations({
|
||||
notice,
|
||||
}: {
|
||||
ready: boolean;
|
||||
canCreate: boolean;
|
||||
records: {
|
||||
data: Row[];
|
||||
total: number;
|
||||
@@ -249,86 +251,89 @@ export default function SupplierEvaluations({
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Preparar nova avaliação"
|
||||
description="Bens e serviços são avaliados separadamente. O relatório regista o âmbito escolhido e não calcula médias entre componentes."
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.post('/fornecedores/avaliacoes', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
{canCreate && (
|
||||
<Panel
|
||||
title="Preparar nova avaliação"
|
||||
description="Bens e serviços são avaliados separadamente. O relatório regista o âmbito escolhido e não calcula médias entre componentes."
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fornecedor
|
||||
<select
|
||||
className={controlClass}
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.post('/fornecedores/avaliacoes', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fornecedor
|
||||
<select
|
||||
className={controlClass}
|
||||
disabled={!ready || form.processing}
|
||||
value={form.data.supplier_id}
|
||||
onChange={(event) =>
|
||||
form.setData({
|
||||
...form.data,
|
||||
supplier_id: event.target.value,
|
||||
payload: {
|
||||
...form.data.payload,
|
||||
order_ids: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Escolher fornecedor
|
||||
</option>
|
||||
{suppliers.map((supplier) => (
|
||||
<option
|
||||
key={supplier.id}
|
||||
value={supplier.id}
|
||||
>
|
||||
{supplier.name} · {supplier.nuit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SupplierEvaluationFields
|
||||
payload={form.data.payload}
|
||||
orders={supplierOrders}
|
||||
disabled={!ready || form.processing}
|
||||
value={form.data.supplier_id}
|
||||
onChange={(event) =>
|
||||
form.setData({
|
||||
...form.data,
|
||||
supplier_id: event.target.value,
|
||||
payload: {
|
||||
...form.data.payload,
|
||||
order_ids: [],
|
||||
},
|
||||
})
|
||||
onChange={(payload) =>
|
||||
form.setData('payload', payload)
|
||||
}
|
||||
/>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p
|
||||
key={key}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<Button
|
||||
disabled={
|
||||
!ready ||
|
||||
form.processing ||
|
||||
!form.data.supplier_id
|
||||
}
|
||||
>
|
||||
<option value="">Escolher fornecedor</option>
|
||||
{suppliers.map((supplier) => (
|
||||
<option
|
||||
key={supplier.id}
|
||||
value={supplier.id}
|
||||
>
|
||||
{supplier.name} · {supplier.nuit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SupplierEvaluationFields
|
||||
payload={form.data.payload}
|
||||
orders={supplierOrders}
|
||||
disabled={!ready || form.processing}
|
||||
onChange={(payload) =>
|
||||
form.setData('payload', payload)
|
||||
}
|
||||
/>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p
|
||||
key={key}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<Button
|
||||
disabled={
|
||||
!ready ||
|
||||
form.processing ||
|
||||
!form.data.supplier_id
|
||||
}
|
||||
>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
<details className="rounded-lg border p-4 text-sm">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Proposta de grelhas quantitativas e evoluções previstas
|
||||
</summary>
|
||||
<p className="my-3">
|
||||
O ciclo disponível é qualitativo. Pesos, limiares,
|
||||
pontuações, planos de melhoria acompanhados e
|
||||
contestação mantêm-se distintos desta entrega; os
|
||||
valores ilustrativos abaixo não foram activados como
|
||||
política.
|
||||
A avaliação é qualitativa. Os esclarecimentos e as
|
||||
medidas de melhoria têm acompanhamento e decisões
|
||||
próprios. Os pesos, limiares e valores ilustrativos
|
||||
abaixo não foram activados como política.
|
||||
</p>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||
{proposal}
|
||||
|
||||
@@ -248,6 +248,7 @@ export default function SupplierShow({
|
||||
Procurement e Finanças.
|
||||
</p>
|
||||
)}
|
||||
<div id="supplier-review" />
|
||||
{canReview && (
|
||||
<Panel
|
||||
title="Validação independente por Finanças"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import CorrectionSourceSearch from '@/components/correction-source-search';
|
||||
import CoordinationRecordFilters, {
|
||||
type CoordinationFilters,
|
||||
} from '@/components/coordination-record-filters';
|
||||
|
||||
type Fields = Record<string, string | number | boolean | null>;
|
||||
type Application = {
|
||||
@@ -15,6 +19,12 @@ type Application = {
|
||||
quantity_milli: number;
|
||||
};
|
||||
type RecordVersion = {
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canSuspend?: boolean;
|
||||
canEdit?: boolean;
|
||||
canFulfil?: boolean;
|
||||
canCancel?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -40,6 +50,13 @@ type Source = {
|
||||
needed_on?: string;
|
||||
};
|
||||
type Props = {
|
||||
recordFilters?: CoordinationFilters;
|
||||
lineSearch?: string;
|
||||
lineFormKey?: string | null;
|
||||
issueSearch?: string;
|
||||
issueRecordId?: number | null;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
kind: 'reservation' | 'delivery' | 'alternative';
|
||||
ready: boolean;
|
||||
canPrepare: boolean;
|
||||
@@ -48,6 +65,13 @@ type Props = {
|
||||
pending: Record<string, number>;
|
||||
reversedOperations?: number[];
|
||||
records: RecordVersion[];
|
||||
recordPagination?: {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
} | null;
|
||||
history: Record<string, RecordVersion[]>;
|
||||
items: { id: number; name: string; code: string; unit: string }[];
|
||||
warehouses: { id: number; name: string; code: string }[];
|
||||
@@ -85,6 +109,15 @@ const quantity = (milli: number) =>
|
||||
(milli / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||||
const [orderLines, setOrderLines] = useState(props.orderLines);
|
||||
const [requestLines, setRequestLines] = useState(props.requestLines);
|
||||
const lineFormKey = `${props.kind}:${old?.id ?? 'new'}`;
|
||||
useEffect(() => {
|
||||
if (!props.lineFormKey || props.lineFormKey === lineFormKey) {
|
||||
setOrderLines(props.orderLines);
|
||||
setRequestLines(props.requestLines);
|
||||
}
|
||||
}, [props.lineFormKey, props.orderLines, props.requestLines, lineFormKey]);
|
||||
const defaults: Fields = {
|
||||
stock_item_id: '',
|
||||
warehouse_id: '',
|
||||
@@ -174,8 +207,35 @@ function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
const sources =
|
||||
props.kind === 'delivery' ? props.orderLines : props.requestLines;
|
||||
const sources = props.kind === 'delivery' ? orderLines : requestLines;
|
||||
const searchLines = (value: string) =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: props.kind === 'delivery' ? 'deliveries' : 'reservations',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
...props.recordFilters,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
line_search: value,
|
||||
line_form_key: lineFormKey,
|
||||
selected_order_line_id:
|
||||
form.data.data.purchase_order_item_id || undefined,
|
||||
selected_request_line_id:
|
||||
form.data.data.requisition_item_id || undefined,
|
||||
},
|
||||
{
|
||||
only: [
|
||||
'orderLines',
|
||||
'requestLines',
|
||||
'lineSearch',
|
||||
'lineFormKey',
|
||||
],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
@@ -199,6 +259,18 @@ function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{props.kind !== 'alternative' && (
|
||||
<CorrectionSourceSearch
|
||||
initial={props.lineSearch}
|
||||
search={searchLines}
|
||||
title={
|
||||
props.kind === 'delivery'
|
||||
? 'Procurar encomenda emitida'
|
||||
: 'Procurar requisição aprovada'
|
||||
}
|
||||
label="Número ou parte do número"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{props.kind !== 'alternative' && (
|
||||
<label className="grid gap-1 text-sm">
|
||||
@@ -424,11 +496,13 @@ function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||||
}
|
||||
|
||||
function Actions({ props, record }: { props: Props; record: RecordVersion }) {
|
||||
const reopened =
|
||||
props.kind === 'reservation' &&
|
||||
record.state === 'fulfilled' &&
|
||||
(props.pending[record.record_key] ?? 0) > 0;
|
||||
const [action, setAction] = useState('');
|
||||
const [issueSearch, setIssueSearch] = useState(props.issueSearch ?? '');
|
||||
const [operationOptions, setOperationOptions] = useState(props.operations);
|
||||
useEffect(() => {
|
||||
if (props.issueRecordId === record.id)
|
||||
setOperationOptions(props.operations);
|
||||
}, [props.issueRecordId, props.operations, record.id]);
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: record.record_key,
|
||||
@@ -440,30 +514,52 @@ function Actions({ props, record }: { props: Props; record: RecordVersion }) {
|
||||
});
|
||||
const choices =
|
||||
props.kind === 'alternative'
|
||||
? props.canReview
|
||||
? record.state === 'approved'
|
||||
? [['suspend', 'Suspender aprovação']]
|
||||
: ['draft', 'returned'].includes(record.state)
|
||||
? [
|
||||
['approve', 'Validar análise'],
|
||||
['return', 'Devolver para correcção'],
|
||||
]
|
||||
: []
|
||||
: []
|
||||
: (record.state === 'active' || reopened) && props.canReview
|
||||
? [
|
||||
...(props.kind === 'reservation'
|
||||
? [['fulfil', 'Associar saída efectiva']]
|
||||
: []),
|
||||
['cancel', 'Cancelar pendência'],
|
||||
]
|
||||
: [];
|
||||
? [
|
||||
...(record.canApprove
|
||||
? [['approve', 'Validar análise']]
|
||||
: []),
|
||||
...(record.canReturn
|
||||
? [['return', 'Devolver para correcção']]
|
||||
: []),
|
||||
...(record.canSuspend
|
||||
? [['suspend', 'Suspender aprovação']]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
...(record.canFulfil
|
||||
? [['fulfil', 'Associar saída efectiva']]
|
||||
: []),
|
||||
...(record.canCancel
|
||||
? [['cancel', 'Cancelar pendência']]
|
||||
: []),
|
||||
];
|
||||
const searchIssues = () =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: 'reservations',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
...props.recordFilters,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
issue_record_id: record.id,
|
||||
issue_search: issueSearch,
|
||||
selected_issue_id:
|
||||
form.data.data.stock_operation_id || undefined,
|
||||
},
|
||||
{
|
||||
only: ['operations', 'issueSearch', 'issueRecordId'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{props.canPrepare &&
|
||||
(props.kind === 'alternative'
|
||||
? ['draft', 'returned'].includes(record.state)
|
||||
: record.state === 'active' || reopened) && (
|
||||
: record.canEdit) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Rever os dados desta preparação
|
||||
@@ -513,6 +609,44 @@ function Actions({ props, record }: { props: Props; record: RecordVersion }) {
|
||||
<FormErrors errors={form.errors} />
|
||||
{action === 'fulfil' && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<details className="rounded border p-3 md:col-span-2">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Procurar saída pela referência
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência ou parte da referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={issueSearch}
|
||||
onChange={(event) =>
|
||||
setIssueSearch(
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
searchIssues();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={searchIssues}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A pesquisa usa o artigo e o armazém desta
|
||||
necessidade e conserva os dados já
|
||||
introduzidos.
|
||||
</p>
|
||||
</details>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Saída já registada
|
||||
<select
|
||||
@@ -529,7 +663,7 @@ function Actions({ props, record }: { props: Props; record: RecordVersion }) {
|
||||
<option value="">
|
||||
Seleccionar saída existente
|
||||
</option>
|
||||
{props.operations
|
||||
{operationOptions
|
||||
.filter(
|
||||
(o) =>
|
||||
o.stock_item_id ===
|
||||
@@ -668,6 +802,18 @@ function Facts({
|
||||
}
|
||||
|
||||
export default function Coordination(props: Props) {
|
||||
const view =
|
||||
props.kind === 'reservation'
|
||||
? 'reservations'
|
||||
: props.kind === 'delivery'
|
||||
? 'deliveries'
|
||||
: 'alternatives';
|
||||
const allowedStates =
|
||||
props.kind === 'alternative'
|
||||
? ['draft', 'returned', 'approved', 'suspended']
|
||||
: props.kind === 'reservation'
|
||||
? ['active', 'cancelled', 'fulfilled']
|
||||
: ['active', 'cancelled'];
|
||||
return (
|
||||
<>
|
||||
<Head title={titles[props.kind]} />
|
||||
@@ -686,16 +832,54 @@ export default function Coordination(props: Props) {
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
<CoordinationRecordFilters
|
||||
key={view}
|
||||
view={view}
|
||||
filters={
|
||||
props.recordFilters ?? {
|
||||
record_search: '',
|
||||
record_state: '',
|
||||
}
|
||||
}
|
||||
states={Object.fromEntries(
|
||||
allowedStates.map((state) => [state, states[state]]),
|
||||
)}
|
||||
/>
|
||||
<Panel title="Responsabilidade desta etapa">
|
||||
<a
|
||||
className="mb-3 inline-block text-sm underline"
|
||||
href={`/compras/fornecimento.csv?${new URLSearchParams({ view, ...props.recordFilters, ...(props.focusRecordId ? { record_id: String(props.focusRecordId) } : {}) }).toString()}`}
|
||||
>
|
||||
{props.focusRecordId
|
||||
? 'Exportar este processo (CSV)'
|
||||
: 'Exportar resultados filtrados (CSV)'}
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{props.kind === 'alternative'
|
||||
? 'A equipa técnica prepara a análise; um validador independente decide. Relações pendentes, incompatíveis, expiradas ou suspensas ficam excluídas das alternativas elegíveis. Uma substituição condicionada exige confirmação das condições na utilização.'
|
||||
: props.kind === 'reservation'
|
||||
? 'Armazém reserva e associa saídas efectivas. A afectação não repete a saída nem altera o stock físico. A reserva pendente entra na sua data e não duplica a procura já prevista.'
|
||||
? 'O responsável acompanha a necessidade planeada e associa saídas efectivas já registadas. Esta reserva de planeamento não bloqueia stock físico nem repete saídas. A reserva física das requisições internas tem acompanhamento próprio no armazém.'
|
||||
: 'Compras regista a confirmação e as alterações de prazo. Armazém regista a recepção no circuito existente. Mercadoria importada retida fica fora das entradas utilizáveis do calendário.'}
|
||||
</p>
|
||||
</Panel>
|
||||
{props.canPrepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
href={`/compras/fornecimento?view=${props.kind === 'alternative' ? 'alternatives' : props.kind === 'reservation' ? 'reservations' : 'deliveries'}`}
|
||||
className="underline"
|
||||
>
|
||||
Ver todos os registos
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
Este registo foi actualizado. Está a consultar a
|
||||
versão actual; as versões anteriores permanecem
|
||||
no histórico.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.canPrepare && !props.focusRecordId && (
|
||||
<Panel
|
||||
title={
|
||||
props.kind === 'alternative'
|
||||
@@ -727,6 +911,7 @@ export default function Coordination(props: Props) {
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<div id={`supply-record-${r.id}`} />
|
||||
<Facts
|
||||
record={r}
|
||||
pending={
|
||||
@@ -787,6 +972,36 @@ export default function Coordination(props: Props) {
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
{props.recordPagination && (
|
||||
<nav
|
||||
aria-label="Paginação do acompanhamento de fornecimentos"
|
||||
className="flex flex-wrap items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{props.recordPagination.total} registos · Página{' '}
|
||||
{props.recordPagination.current_page} de{' '}
|
||||
{props.recordPagination.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{props.recordPagination.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={props.recordPagination.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{props.recordPagination.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={props.recordPagination.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
<p className="flex flex-wrap gap-5 text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplyPagination, {
|
||||
type SupplyPageInfo,
|
||||
} from '@/components/supply-pagination';
|
||||
|
||||
type Analysis = {
|
||||
days: number;
|
||||
@@ -26,6 +29,9 @@ type Analysis = {
|
||||
}[];
|
||||
};
|
||||
type HistoryRecord = {
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canSuspend?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -50,6 +56,9 @@ type HistoryRecord = {
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
recordPagination?: SupplyPageInfo | null;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
canPrepare: boolean;
|
||||
@@ -399,7 +408,6 @@ function Decision({
|
||||
reason: '',
|
||||
data: { confirm_scope: false },
|
||||
});
|
||||
const independent = !r.snapshot.contributors.includes(props.actorId);
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{props.canPrepare && ['draft', 'returned'].includes(r.state) && (
|
||||
@@ -410,7 +418,7 @@ function Decision({
|
||||
<Prepare props={props} old={r} />
|
||||
</details>
|
||||
)}
|
||||
{props.canReview && independent && r.state !== 'suspended' && (
|
||||
{(r.canApprove || r.canReturn || r.canSuspend) && (
|
||||
<>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Decisão da minha responsabilidade
|
||||
@@ -422,19 +430,20 @@ function Decision({
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar acção</option>
|
||||
{r.state === 'approved' ? (
|
||||
{r.canSuspend && (
|
||||
<option value="suspend">
|
||||
Suspender reutilização
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="approve">
|
||||
Conferir e aprovar análise
|
||||
</option>
|
||||
<option value="return">
|
||||
Devolver para correcção
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
{r.canApprove && (
|
||||
<option value="approve">
|
||||
Conferir e aprovar análise
|
||||
</option>
|
||||
)}
|
||||
{r.canReturn && (
|
||||
<option value="return">
|
||||
Devolver para correcção
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -515,7 +524,24 @@ export default function History(props: Props) {
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
href="/compras/fornecimento?view=history"
|
||||
className="underline"
|
||||
>
|
||||
Ver todas as análises
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
Esta análise foi actualizada. Está a consultar a
|
||||
versão actual; as versões anteriores permanecem
|
||||
no histórico.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.canPrepare && !props.focusRecordId && (
|
||||
<Panel title="Analisar período existente">
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
@@ -540,6 +566,7 @@ export default function History(props: Props) {
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<div id={`supply-record-${r.id}`} />
|
||||
<Facts record={r} series />
|
||||
<p className="mt-3 text-sm">{r.reason}</p>
|
||||
<Decision props={props} record={r} />
|
||||
@@ -569,6 +596,7 @@ export default function History(props: Props) {
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<SupplyPagination page={props.recordPagination} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Uma análise conferida pode ser seleccionada pelo responsável
|
||||
pelas políticas. A consulta do razão e a configuração de
|
||||
|
||||
+327
-103
@@ -1,5 +1,5 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -7,9 +7,16 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import CorrectionSourceSearch from '@/components/correction-source-search';
|
||||
import SupplyPagination, {
|
||||
type SupplyPageInfo,
|
||||
} from '@/components/supply-pagination';
|
||||
|
||||
type Data = Record<string, string | number | boolean | null>;
|
||||
type SupplyRecord = {
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canSuspend?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -60,6 +67,13 @@ type Projection = {
|
||||
series?: { date: string; balance_milli: number }[];
|
||||
};
|
||||
type Props = {
|
||||
agreementSearch?: string;
|
||||
agreementFormKey?: string | null;
|
||||
orderSearch?: string;
|
||||
historyFormKey?: string | null;
|
||||
recordPagination?: SupplyPageInfo | null;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
asOf: string;
|
||||
view: string;
|
||||
kind: string;
|
||||
@@ -265,6 +279,87 @@ function Preparation({
|
||||
reason: '',
|
||||
data: defaults,
|
||||
});
|
||||
const [orderSearch, setOrderSearch] = useState(props.orderSearch ?? '');
|
||||
const [historyOptions, setHistoryOptions] = useState(props.histories);
|
||||
const [periodFrom, setPeriodFrom] = useState('');
|
||||
const [periodUntil, setPeriodUntil] = useState('');
|
||||
const historyFormKey = `${copy ? 'copy' : 'prepare'}:${record?.id ?? 'new'}`;
|
||||
const [agreementOptions, setAgreementOptions] = useState(props.agreements);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!props.agreementFormKey ||
|
||||
props.agreementFormKey === historyFormKey
|
||||
)
|
||||
setAgreementOptions(props.agreements);
|
||||
}, [props.agreementFormKey, props.agreements, historyFormKey]);
|
||||
const searchAgreements = (search: string) =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: 'calendar',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
agreement_search: search,
|
||||
agreement_stock_item_id: form.data.data.stock_item_id,
|
||||
agreement_warehouse_id: form.data.data.warehouse_id,
|
||||
selected_agreement_key:
|
||||
form.data.data.agreement_key || undefined,
|
||||
agreement_form_key: historyFormKey,
|
||||
},
|
||||
{
|
||||
only: ['agreements', 'agreementSearch', 'agreementFormKey'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!props.historyFormKey || props.historyFormKey === historyFormKey)
|
||||
setHistoryOptions(props.histories);
|
||||
}, [props.historyFormKey, props.histories, historyFormKey]);
|
||||
const searchHistories = () =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: 'calendar',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
history_stock_item_id: form.data.data.stock_item_id,
|
||||
history_warehouse_id: form.data.data.warehouse_id,
|
||||
history_purpose: form.data.data.purpose,
|
||||
history_period_from: periodFrom || undefined,
|
||||
history_period_until: periodUntil || undefined,
|
||||
selected_history_key: form.data.data.history_key || undefined,
|
||||
history_form_key: historyFormKey,
|
||||
},
|
||||
{
|
||||
only: ['histories', 'historyFormKey'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
const searchOrders = () =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: 'imports',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
order_search: orderSearch,
|
||||
selected_order_id:
|
||||
form.data.data.purchase_order_id || undefined,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
only: ['orders', 'orderSearch'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
const field = (
|
||||
key: string,
|
||||
label: string,
|
||||
@@ -387,6 +482,44 @@ function Preparation({
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{props.kind === 'import' && (
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Procurar ordem pelo número
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Número ou parte do número
|
||||
<input
|
||||
className={controlClass}
|
||||
value={orderSearch}
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setOrderSearch(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
searchOrders();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={searchOrders}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Seleccione a ordem nos resultados abaixo. A ordem já
|
||||
escolhida e os dados do formulário são conservados
|
||||
durante a pesquisa.
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{props.kind === 'import' ? (
|
||||
select('purchase_order_id', 'Ordem de compra', props.orders)
|
||||
@@ -466,6 +599,65 @@ function Preparation({
|
||||
)}
|
||||
{form.data.data.forecast_method === 'historical' && (
|
||||
<div className="space-y-3 md:col-span-2">
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Procurar análises aprovadas por período
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Período a partir de
|
||||
<input
|
||||
className={controlClass}
|
||||
type="date"
|
||||
value={periodFrom}
|
||||
onChange={(event) =>
|
||||
setPeriodFrom(
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter')
|
||||
event.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Período até
|
||||
<input
|
||||
className={controlClass}
|
||||
type="date"
|
||||
value={periodUntil}
|
||||
onChange={(event) =>
|
||||
setPeriodUntil(
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter')
|
||||
event.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
!form.data.data.stock_item_id ||
|
||||
!form.data.data.warehouse_id ||
|
||||
!form.data.data.purpose
|
||||
}
|
||||
onClick={searchHistories}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A pesquisa usa o artigo, armazém e
|
||||
finalidade escolhidos. A selecção
|
||||
elegível e os dados introduzidos são
|
||||
conservados.
|
||||
</p>
|
||||
</details>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Período já aprovado para este artigo, local
|
||||
e finalidade
|
||||
@@ -485,7 +677,7 @@ function Preparation({
|
||||
<option value="">
|
||||
Seleccionar análise existente
|
||||
</option>
|
||||
{props.histories
|
||||
{historyOptions
|
||||
.filter(
|
||||
(h) =>
|
||||
h.stock_item_id ===
|
||||
@@ -546,6 +738,16 @@ function Preparation({
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
<CorrectionSourceSearch
|
||||
initial={props.agreementSearch}
|
||||
search={searchAgreements}
|
||||
disabled={
|
||||
!form.data.data.stock_item_id ||
|
||||
!form.data.data.warehouse_id
|
||||
}
|
||||
title="Procurar acordos aprovados deste artigo e local"
|
||||
label="Referência do acordo ou nome do fornecedor"
|
||||
/>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Prazo e origem a partir de acordo existente
|
||||
<select
|
||||
@@ -554,7 +756,7 @@ function Preparation({
|
||||
form.data.data.agreement_key ?? '',
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const agreement = props.agreements.find(
|
||||
const agreement = agreementOptions.find(
|
||||
(a) => a.record_key === e.target.value,
|
||||
);
|
||||
form.setData('data', {
|
||||
@@ -574,7 +776,7 @@ function Preparation({
|
||||
<option value="">
|
||||
Definir prazo manual com fonte documentada
|
||||
</option>
|
||||
{props.agreements
|
||||
{agreementOptions
|
||||
.filter(
|
||||
(a) =>
|
||||
a.stock_item_id ===
|
||||
@@ -711,16 +913,17 @@ function RecordActions({
|
||||
] as [string, string][])
|
||||
: []),
|
||||
]
|
||||
: props.canReview
|
||||
? record.state === 'approved'
|
||||
? [['suspend', 'Suspender']]
|
||||
: ['draft', 'returned'].includes(record.state)
|
||||
? [
|
||||
['approve', 'Aprovar'],
|
||||
['return', 'Devolver'],
|
||||
]
|
||||
: []
|
||||
: [];
|
||||
: [
|
||||
...(record.canApprove
|
||||
? [['approve', 'Aprovar'] as [string, string]]
|
||||
: []),
|
||||
...(record.canReturn
|
||||
? [['return', 'Devolver'] as [string, string]]
|
||||
: []),
|
||||
...(record.canSuspend
|
||||
? [['suspend', 'Suspender'] as [string, string]]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{props.kind === 'policy' &&
|
||||
@@ -941,7 +1144,21 @@ export default function SupplyIndex(props: Props) {
|
||||
{props.view === 'calendar' && props.ready && (
|
||||
<CalendarAlerts props={props} />
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
{props.focusRecordId != null && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/compras/fornecimento?view=${props.view}`}
|
||||
>
|
||||
Ver todos os registos desta área
|
||||
</Link>
|
||||
)}
|
||||
{props.focusChanged && (
|
||||
<p className="rounded border p-3 text-sm">
|
||||
O registo mudou desde o encaminhamento. Confira a versão
|
||||
actual; as versões anteriores permanecem no histórico.
|
||||
</p>
|
||||
)}
|
||||
{props.canPrepare && props.focusRecordId == null && (
|
||||
<Panel
|
||||
title={
|
||||
props.kind === 'policy'
|
||||
@@ -988,97 +1205,104 @@ export default function SupplyIndex(props: Props) {
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((record) => (
|
||||
<Panel
|
||||
<div
|
||||
key={`${record.record_key}-${record.version}`}
|
||||
title={
|
||||
record.snapshot.item
|
||||
? `${record.snapshot.item.code} · ${record.snapshot.item.name}`
|
||||
: (record.snapshot.order?.number ??
|
||||
'Processo de fornecimento')
|
||||
}
|
||||
description={`${states[record.state]} · Versão ${record.version} · ${record.snapshot.warehouse?.name ?? record.data.jurisdiction ?? ''}`}
|
||||
id={`supply-record-${record.id}`}
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
{record.snapshot.supplier?.name ??
|
||||
(record.data.purpose === 'resale'
|
||||
? 'Revenda'
|
||||
: 'Uso interno')}
|
||||
{record.data.origin &&
|
||||
` · ${record.data.origin === 'import' ? 'Importação directa' : 'Compra local'}`}
|
||||
</p>
|
||||
<p>{record.reason}</p>
|
||||
</div>
|
||||
{props.projections[record.record_key] && (
|
||||
<ProjectionView
|
||||
projection={
|
||||
props.projections[record.record_key]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RecordActions record={record} props={props} />
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Dados preservados desta versão
|
||||
</summary>
|
||||
<PreservedData record={record} />
|
||||
</details>
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Histórico de versões e decisões
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-4">
|
||||
{props.history[record.record_key]?.map(
|
||||
(version) => (
|
||||
<li
|
||||
className="border-l-2 pl-4 text-sm"
|
||||
key={version.id}
|
||||
>
|
||||
<p className="font-medium">
|
||||
Versão {version.version} ·{' '}
|
||||
{states[version.state]} ·{' '}
|
||||
{new Date(
|
||||
version.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
<p>{version.reason}</p>
|
||||
{version.data.event && (
|
||||
<p>
|
||||
Marco:{' '}
|
||||
{String(
|
||||
(
|
||||
version.data
|
||||
.event as unknown as {
|
||||
event_date: string;
|
||||
}
|
||||
).event_date,
|
||||
)}{' '}
|
||||
· Evidência:{' '}
|
||||
{String(
|
||||
(
|
||||
version.data
|
||||
.event as unknown as {
|
||||
evidence: string;
|
||||
}
|
||||
).evidence,
|
||||
)}
|
||||
<Panel
|
||||
key={`${record.record_key}-${record.version}`}
|
||||
title={
|
||||
record.snapshot.item
|
||||
? `${record.snapshot.item.code} · ${record.snapshot.item.name}`
|
||||
: (record.snapshot.order?.number ??
|
||||
'Processo de fornecimento')
|
||||
}
|
||||
description={`${states[record.state]} · Versão ${record.version} · ${record.snapshot.warehouse?.name ?? record.data.jurisdiction ?? ''}`}
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
{record.snapshot.supplier?.name ??
|
||||
(record.data.purpose === 'resale'
|
||||
? 'Revenda'
|
||||
: 'Uso interno')}
|
||||
{record.data.origin &&
|
||||
` · ${record.data.origin === 'import' ? 'Importação directa' : 'Compra local'}`}
|
||||
</p>
|
||||
<p>{record.reason}</p>
|
||||
</div>
|
||||
{props.projections[record.record_key] && (
|
||||
<ProjectionView
|
||||
projection={
|
||||
props.projections[record.record_key]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RecordActions record={record} props={props} />
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Dados preservados desta versão
|
||||
</summary>
|
||||
<PreservedData record={record} />
|
||||
</details>
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Histórico de versões e decisões
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-4">
|
||||
{props.history[record.record_key]?.map(
|
||||
(version) => (
|
||||
<li
|
||||
className="border-l-2 pl-4 text-sm"
|
||||
key={version.id}
|
||||
>
|
||||
<p className="font-medium">
|
||||
Versão {version.version} ·{' '}
|
||||
{states[version.state]} ·{' '}
|
||||
{new Date(
|
||||
version.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
Consultar dados desta versão
|
||||
</summary>
|
||||
<PreservedData
|
||||
record={version}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
</details>
|
||||
</Panel>
|
||||
<p>{version.reason}</p>
|
||||
{version.data.event && (
|
||||
<p>
|
||||
Marco:{' '}
|
||||
{String(
|
||||
(
|
||||
version.data
|
||||
.event as unknown as {
|
||||
event_date: string;
|
||||
}
|
||||
).event_date,
|
||||
)}{' '}
|
||||
· Evidência:{' '}
|
||||
{String(
|
||||
(
|
||||
version.data
|
||||
.event as unknown as {
|
||||
evidence: string;
|
||||
}
|
||||
).evidence,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
Consultar dados desta
|
||||
versão
|
||||
</summary>
|
||||
<PreservedData
|
||||
record={version}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
</details>
|
||||
</Panel>
|
||||
</div>
|
||||
))}
|
||||
<SupplyPagination page={props.recordPagination} />
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplyPagination, {
|
||||
type SupplyPageInfo,
|
||||
} from '@/components/supply-pagination';
|
||||
|
||||
type Policy = {
|
||||
key: string;
|
||||
@@ -20,6 +23,11 @@ type Policy = {
|
||||
unit?: string;
|
||||
};
|
||||
type Suggestion = {
|
||||
canApprove?: boolean;
|
||||
canReturn?: boolean;
|
||||
canConvert?: boolean;
|
||||
canCancel?: boolean;
|
||||
canClose?: boolean;
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
@@ -73,6 +81,11 @@ type Suggestion = {
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
policySearch?: string;
|
||||
policyFormKey?: string | null;
|
||||
recordPagination?: SupplyPageInfo | null;
|
||||
focusRecordId?: number | null;
|
||||
focusChanged?: boolean;
|
||||
actorId: number;
|
||||
ready: boolean;
|
||||
records: Suggestion[];
|
||||
@@ -98,7 +111,14 @@ const quantity = (value: number) =>
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
|
||||
const [selected, setSelected] = useState(old?.data.policy_key ?? '');
|
||||
const option = props.policies.find((p) => p.key === selected);
|
||||
const [policies, setPolicies] = useState(props.policies);
|
||||
const [policySearch, setPolicySearch] = useState(props.policySearch ?? '');
|
||||
const formKey = `suggestion:${old?.id ?? 'new'}`;
|
||||
useEffect(() => {
|
||||
if (!props.policyFormKey || props.policyFormKey === formKey)
|
||||
setPolicies(props.policies);
|
||||
}, [props.policyFormKey, props.policies, formKey]);
|
||||
const option = policies.find((p) => p.key === selected);
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
@@ -115,6 +135,25 @@ function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
|
||||
delivery_on: old?.data.manual_plan ? old.data.delivery_on : '',
|
||||
},
|
||||
});
|
||||
const searchPolicies = () =>
|
||||
router.get(
|
||||
'/compras/fornecimento',
|
||||
{
|
||||
view: 'suggestions',
|
||||
records_page: props.recordPagination?.current_page,
|
||||
...(props.focusRecordId
|
||||
? { record_id: props.focusRecordId }
|
||||
: {}),
|
||||
policy_search: policySearch,
|
||||
selected_policy_key: selected || undefined,
|
||||
policy_form_key: formKey,
|
||||
},
|
||||
{
|
||||
only: ['policies', 'policySearch', 'policyFormKey'],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
@@ -141,6 +180,42 @@ function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<details className="rounded border p-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Procurar política aprovada
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Código ou descrição do artigo ou armazém
|
||||
<input
|
||||
className={controlClass}
|
||||
value={policySearch}
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setPolicySearch(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
searchPolicies();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={searchPolicies}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A selecção elegível e os dados introduzidos são conservados.
|
||||
As condições da política continuam a ser verificadas antes
|
||||
de preparar a compra.
|
||||
</p>
|
||||
</details>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo e local com política aprovada
|
||||
<select
|
||||
@@ -149,7 +224,7 @@ function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
|
||||
className={controlClass}
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
const p = props.policies.find(
|
||||
const p = policies.find(
|
||||
(o) => o.key === e.target.value,
|
||||
);
|
||||
setSelected(e.target.value);
|
||||
@@ -163,7 +238,7 @@ function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar política existente</option>
|
||||
{props.policies.map((p) => (
|
||||
{policies.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
@@ -455,26 +530,14 @@ function Decisions({ props, record: r }: { props: Props; record: Suggestion }) {
|
||||
reason: '',
|
||||
data: { cost_center_id: '', confirm_exception: false },
|
||||
});
|
||||
const independent = !r.snapshot.contributors.includes(props.actorId);
|
||||
const choices: [string, string][] = [];
|
||||
if (props.canReview && independent) {
|
||||
if (
|
||||
['draft', 'returned'].includes(r.state) &&
|
||||
!props.excess[r.record_key]
|
||||
)
|
||||
choices.push(['approve', 'Aprovar para requisitar']);
|
||||
if (['draft', 'approved'].includes(r.state))
|
||||
choices.push(['return', 'Devolver para actualização']);
|
||||
if (r.state === 'converted')
|
||||
choices.push(['close', 'Conferir e encerrar acompanhamento']);
|
||||
}
|
||||
if (props.canPrepare && ['draft', 'returned'].includes(r.state))
|
||||
choices.push(['cancel', 'Cancelar sugestão pendente']);
|
||||
if (
|
||||
props.canConvert &&
|
||||
r.state === 'approved' &&
|
||||
r.snapshot.reviewed_by !== props.actorId
|
||||
)
|
||||
if (r.canApprove && !props.excess[r.record_key])
|
||||
choices.push(['approve', 'Aprovar para requisitar']);
|
||||
if (r.canReturn) choices.push(['return', 'Devolver para actualização']);
|
||||
if (r.canClose)
|
||||
choices.push(['close', 'Conferir e encerrar acompanhamento']);
|
||||
if (r.canCancel) choices.push(['cancel', 'Cancelar sugestão pendente']);
|
||||
if (r.canConvert)
|
||||
choices.push(['convert', 'Criar requisição a partir desta aprovação']);
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
@@ -620,7 +683,24 @@ export default function Suggestions(props: Props) {
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
{props.focusRecordId && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Link
|
||||
href="/compras/fornecimento?view=suggestions"
|
||||
className="underline"
|
||||
>
|
||||
Ver todas as sugestões
|
||||
</Link>
|
||||
{props.focusChanged && (
|
||||
<p role="status">
|
||||
Esta sugestão foi actualizada. Está a consultar
|
||||
a versão actual; as versões anteriores
|
||||
permanecem no histórico.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.canPrepare && !props.focusRecordId && (
|
||||
<Panel title="Preparar sugestão">
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
@@ -645,6 +725,7 @@ export default function Suggestions(props: Props) {
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<div id={`supply-suggestion-${r.id}`} />
|
||||
<Facts record={r} detailed />
|
||||
<p className="mt-3 text-sm">{r.reason}</p>
|
||||
{r.data.requisition_id && (
|
||||
@@ -690,6 +771,7 @@ export default function Suggestions(props: Props) {
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<SupplyPagination page={props.recordPagination} />
|
||||
<p className="flex flex-wrap gap-5 text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { roleLabels, statusLabels } from '@/types/procurement';
|
||||
|
||||
@@ -7,7 +7,9 @@ type Props = {
|
||||
ready: boolean;
|
||||
unread: number;
|
||||
view: string;
|
||||
search: string;
|
||||
notices: {
|
||||
current_page: number;
|
||||
total: number;
|
||||
data: {
|
||||
id: number;
|
||||
@@ -24,7 +26,14 @@ type Props = {
|
||||
next_page_url: string | null;
|
||||
};
|
||||
};
|
||||
export default function Notices({ ready, unread, view, notices }: Props) {
|
||||
export default function Notices({
|
||||
ready,
|
||||
unread,
|
||||
view,
|
||||
search,
|
||||
notices,
|
||||
}: Props) {
|
||||
const form = useForm({ view, search });
|
||||
return (
|
||||
<>
|
||||
<Head title="Avisos de aprovação" />
|
||||
@@ -61,6 +70,56 @@ export default function Notices({ ready, unread, view, notices }: Props) {
|
||||
}
|
||||
description="Intervenções concluídas deixam a fila pendente. Resultados registados mantêm-se visíveis ao requisitante com acesso actual. Não são criados avisos retroactivos para decisões antigas."
|
||||
>
|
||||
<details
|
||||
className="mb-4 rounded border p-3"
|
||||
open={Boolean(search)}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Localizar por referência da requisição
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
view,
|
||||
}));
|
||||
form.get('/requisicoes/avisos', {
|
||||
preserveState: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="text-sm">
|
||||
Referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={form.data.search}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'search',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Procurar</Button>
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={`/requisicoes/avisos?view=${view}`}
|
||||
>
|
||||
Limpar pesquisa
|
||||
</Link>
|
||||
{form.errors.search && (
|
||||
<p role="alert">{form.errors.search}</p>
|
||||
)}
|
||||
</form>
|
||||
</details>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{notices.total} aviso(s) nesta consulta · Página{' '}
|
||||
{notices.current_page}
|
||||
</p>
|
||||
{!ready ? (
|
||||
<p>Instalação dos avisos internos pendente.</p>
|
||||
) : notices.data.length === 0 ? (
|
||||
@@ -102,6 +161,11 @@ export default function Notices({ ready, unread, view, notices }: Props) {
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/requisicoes/avisos/${notice.id}/ler`,
|
||||
{
|
||||
view,
|
||||
search,
|
||||
page: notices.current_page,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,9 @@ type Props = {
|
||||
modules: Record<string, string>;
|
||||
counts: Record<string, number>;
|
||||
checkedAt: string;
|
||||
filters: { module: string; search: string };
|
||||
asOf: string;
|
||||
deadlineCounts: Record<string, number>;
|
||||
filters: { module: string; search: string; due: string };
|
||||
tasks: {
|
||||
total: number;
|
||||
data: {
|
||||
@@ -19,6 +21,7 @@ type Props = {
|
||||
blocked: boolean;
|
||||
created_at: string | null;
|
||||
due_on: string | null;
|
||||
due_status: string;
|
||||
href: string;
|
||||
}[];
|
||||
prev_page_url: string | null;
|
||||
@@ -31,11 +34,20 @@ export default function Tasks({
|
||||
modules,
|
||||
counts,
|
||||
checkedAt,
|
||||
asOf,
|
||||
deadlineCounts,
|
||||
filters,
|
||||
tasks,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState(filters.search);
|
||||
const [module, setModule] = useState(filters.module);
|
||||
const [due, setDue] = useState(filters.due);
|
||||
const deadlines: Record<string, string> = {
|
||||
overdue: 'Prazo ultrapassado',
|
||||
today: 'Vence hoje',
|
||||
upcoming: 'Prazo futuro',
|
||||
undated: 'Sem prazo registado',
|
||||
};
|
||||
const path =
|
||||
area === 'people' ? '/pessoas/tarefas' : '/requisicoes/tarefas';
|
||||
return (
|
||||
@@ -81,7 +93,7 @@ export default function Tasks({
|
||||
event.preventDefault();
|
||||
router.get(
|
||||
path,
|
||||
{ module, search },
|
||||
{ module, search, due },
|
||||
{ preserveState: true },
|
||||
);
|
||||
}}
|
||||
@@ -114,6 +126,23 @@ export default function Tasks({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Prazo
|
||||
<select
|
||||
className={controlClass}
|
||||
value={due}
|
||||
onChange={(event) => setDue(event.target.value)}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{Object.entries(deadlines).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" variant="outline">
|
||||
Filtrar
|
||||
</Button>
|
||||
@@ -121,6 +150,16 @@ export default function Tasks({
|
||||
Limpar filtros
|
||||
</Link>
|
||||
</form>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
No módulo e pesquisa escolhidos, em {asOf}:{' '}
|
||||
{Object.entries(deadlines)
|
||||
.map(
|
||||
([value, label]) =>
|
||||
`${label}: ${deadlineCounts[value] ?? 0}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{tasks.total} tarefa(s) no filtro. Documentos mais
|
||||
antigos aparecem primeiro; esta ordem não define
|
||||
@@ -162,6 +201,8 @@ export default function Tasks({
|
||||
<p className="mt-2 text-sm">
|
||||
Prazo registado no processo:{' '}
|
||||
{task.due_on}
|
||||
{' · '}
|
||||
{deadlines[task.due_status]}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $receipt->number }} · SGC</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 17mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 12px/1.5 Arial, sans-serif; color: #111; max-width: 950px; margin: 24px auto; }
|
||||
h1 { font-size: 24px; } h2 { font-size: 16px; margin-top: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||||
th, td { border: 1px solid #ccc; padding: 7px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||
thead { display: table-header-group; } tr { break-inside: avoid; }
|
||||
.notice { border: 1px solid #aaa; padding: 12px; } .pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.fingerprint { font: 10px monospace; overflow-wrap: anywhere; } button { padding: 10px; }
|
||||
@media print { .no-print { display: none; } body { margin: 0; max-width: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@php($quantity = fn ($milli) => number_format($milli / 1000, 3, ',', '.'))
|
||||
@php($quality = ['accepted' => 'Aceite', 'rejected' => 'Rejeitado', 'partial' => 'Parcial', 'pending' => 'Pendente'])
|
||||
@php($types = ['delivery_note' => 'Guia de remessa', 'quality' => 'Qualidade', 'other' => 'Outro'])
|
||||
<button type="button" class="no-print" onclick="window.print()">Imprimir ou guardar como PDF</button>
|
||||
<h1>Recepção {{ $receipt->number }}</h1>
|
||||
<p>Ordem {{ $receipt->snapshot['order']['number'] }} · Fornecedor: {{ $receipt->snapshot['supplier']['name'] }}</p>
|
||||
@if($receipt->snapshot['development_only'] ?? false)
|
||||
<p class="notice">Documento de desenvolvimento. Não autoriza compras reais.</p>
|
||||
@endif
|
||||
<p>Guia de remessa: {{ $receipt->delivery_note }}<br>
|
||||
Recebido em: {{ $receipt->received_on->format('Y-m-d') }} · Por: {{ $receipt->snapshot['receiver']['name'] }}<br>
|
||||
Armazém: {{ $receipt->snapshot['warehouse']['name'] ?? 'Sem entrada física' }}</p>
|
||||
<h2>Confirmação original de quantidade e qualidade</h2>
|
||||
<p>Esta reprodução conserva a confirmação original. Os estornos identificados abaixo devem ser considerados na leitura das quantidades; a situação efectiva consta da ordem.</p>
|
||||
<table>
|
||||
<thead><tr><th>Artigo / unidade</th><th>Recebido</th><th>Aceite</th><th>Rejeitado</th><th>Qualidade / observações</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach($receipt->items as $item)
|
||||
<tr>
|
||||
<td>{{ $item->snapshot['order_item']['description'] }} · {{ $item->snapshot['order_item']['unit'] }}
|
||||
@if($item->snapshot['stock_item'] ?? null)<br>{{ $item->snapshot['stock_item']['code'] }}@endif
|
||||
@if(isset($corrections[$item->id]))<p><strong>Estornado integralmente: {{ $corrections[$item->id]->data['reference'] }}</strong></p>@endif
|
||||
</td>
|
||||
<td>{{ $quantity($item->received_milli) }}</td><td>{{ $quantity($item->accepted_milli) }}</td><td>{{ $quantity($item->rejected_milli) }}</td>
|
||||
<td>{{ $quality[$item->quality_status] ?? $item->quality_status }}<p class="pre">{{ $item->notes }}</p></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Referências documentais</h2>
|
||||
<p>Os anexos permanecem no SGC e não são incorporados nesta impressão. A lista corresponde à consulta actual.</p>
|
||||
@forelse($documents as $document)
|
||||
<p>{{ $types[$document->document_type] ?? $document->document_type }} · {{ $document->original_name }}<br>
|
||||
<span class="fingerprint">SHA-256: {{ $document->sha256 }}</span></p>
|
||||
@empty
|
||||
<p>Sem referências documentais disponíveis para esta consulta.</p>
|
||||
@endforelse
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user