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>
);
}