This commit is contained in:
2026-11-03 11:51:44 +02:00
parent badaada043
commit e753f7a0a4
309 changed files with 14537 additions and 529 deletions
@@ -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>
</>
);
}
+33
View File
@@ -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>
</>
);
+48 -1
View File
@@ -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 ??
+57
View File
@@ -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>
+10
View File
@@ -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"
+93 -21
View File
@@ -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>
+99 -28
View File
@@ -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>
</>
+32 -17
View File
@@ -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>
+104 -21
View File
@@ -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>
+76 -71
View File
@@ -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}
+1
View File
@@ -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"
+245 -30
View File
@@ -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"
+42 -14
View File
@@ -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
View File
@@ -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"
+107 -25
View File
@@ -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"
+67 -3
View File
@@ -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,
},
)
}
>
+43 -2
View File
@@ -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>
+59
View File
@@ -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>