This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Choice = { value: string; label: string };
type Props = {
route: string;
record: { record_key: string; version: number };
choices: Choice[];
today?: string;
data?: Record<string, string | number | boolean | null>;
};
function Confirm({ props, choice }: { props: Props; choice: Choice }) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: props.record.record_key,
version: props.record.version,
action: choice.value,
reason: '',
data: {
...props.data,
...(choice.value === 'execute'
? { event_on: props.today ?? '', evidence: '' }
: {}),
},
});
return (
<form
className="mt-3 grid gap-3 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
form.post(props.route);
}}
>
<FormErrors errors={form.errors} />
<label className="grid gap-1 text-sm">
Motivo e evidência da conferência
<textarea
required
minLength={10}
maxLength={4000}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
{choice.value === 'execute' && (
<>
<label className="grid gap-1 text-sm">
Data efectiva do evento
<input
type="date"
required
max={props.today}
className={controlClass}
value={String(form.data.data.event_on ?? '')}
onChange={(e) =>
form.setData('data', {
...form.data.data,
event_on: e.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm">
Comprovativo da execução física
<input
required
minLength={10}
maxLength={3000}
className={controlClass}
value={String(form.data.data.evidence ?? '')}
onChange={(e) =>
form.setData('data', {
...form.data.data,
evidence: e.target.value,
})
}
/>
</label>
</>
)}
<Button disabled={form.processing}>{choice.label}</Button>
</form>
);
}
export function InventoryEventActions(props: Props) {
const [selected, setSelected] = useState(
props.choices.length === 1 ? props.choices[0].value : '',
);
if (props.choices.length === 0) return null;
const choice = props.choices.find((option) => option.value === selected);
return (
<details className="mt-4">
<summary className="cursor-pointer text-sm font-medium">
{props.choices.length === 1
? props.choices[0].label
: 'Acções da minha responsabilidade'}
</summary>
{props.choices.length > 1 && (
<label className="mt-3 grid gap-1 text-sm">
Acção
<select
className={controlClass}
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
<option value="">Seleccione a decisão</option>
{props.choices.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)}
{choice && (
<Confirm key={choice.value} props={props} choice={choice} />
)}
</details>
);
}
@@ -0,0 +1,211 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { quantity } from '@/types/inventory';
import { controlClass } from '@/components/procurement';
export type CountInput = {
lot_id: number | null;
new_key: string | null;
quantity: string;
code: string;
expires_on: string;
no_expiry: boolean;
};
export type LotCountSource = {
stock_item_id: number;
warehouse_id: number;
hash: string;
lots: {
lot_id: number;
metadata_id: number;
quantity_milli: number;
code: string | null;
expires_on: string | null;
no_expiry: boolean;
}[];
};
export function countTotal(rows: CountInput[]): string | null {
let total = 0;
for (const row of rows) {
if (!/^\d{1,9}(\.\d{1,3})?$/.test(row.quantity)) return null;
const [whole, fraction = ''] = row.quantity.split('.');
total += Number(whole) * 1000 + Number(fraction.padEnd(3, '0'));
}
return `${Math.floor(total / 1000)}.${String(total % 1000).padStart(3, '0')}`;
}
export function LotCountFields({
source,
rows,
setRows,
}: {
source: LotCountSource;
rows: CountInput[];
setRows: (rows: CountInput[]) => void;
}) {
const change = (index: number, update: Partial<CountInput>) =>
setRows(
rows.map((row, i) => (i === index ? { ...row, ...update } : row)),
);
const exhausted = source.lots.filter(
(lot) =>
lot.quantity_milli === 0 &&
!rows.some((row) => row.lot_id === lot.lot_id),
);
return (
<fieldset className="space-y-3 rounded-lg border p-4 sm:col-span-2">
<legend className="px-2 text-sm font-semibold">
Conferência física por lote
</legend>
<p className="text-sm text-muted-foreground">
Registe apenas o que contou. O total é calculado; identificação,
validade e saldo de referência vêm do registo existente. A
contagem não altera existências nem liberta qualidade.
</p>
{rows.map((row, index) => {
const known = source.lots.find(
(lot) => lot.lot_id === row.lot_id,
);
return (
<div
key={row.lot_id ?? row.new_key}
className="grid gap-3 rounded border p-3 md:grid-cols-2"
>
<p className="text-sm">
{known
? `${known.code ?? `Lote #${known.lot_id}`} · Saldo: ${quantity(known.quantity_milli)} · Validade: ${known.expires_on ?? (known.no_expiry ? 'Sem prazo confirmado' : 'Por confirmar')}`
: 'Lote encontrado sem identidade anterior neste local'}
</p>
<label className="grid gap-1 text-sm">
Quantidade contada
<Input
required
inputMode="decimal"
pattern="\d{1,9}(\.\d{1,3})?"
value={row.quantity}
onChange={(e) =>
change(index, { quantity: e.target.value })
}
/>
</label>
{!known && (
<>
<label className="grid gap-1 text-sm">
Código, se conhecido
<Input
maxLength={100}
value={row.code}
onChange={(e) =>
change(index, {
code: e.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm">
Validade, se conhecida
<Input
type="date"
value={row.expires_on}
onChange={(e) =>
change(index, {
expires_on: e.target.value,
})
}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={row.no_expiry}
onChange={(e) =>
change(index, {
no_expiry: e.target.checked,
})
}
/>
Confirmo artigo sem prazo de validade
</label>
<Button
type="button"
variant="outline"
onClick={() =>
setRows(
rows.filter((_, i) => i !== index),
)
}
>
Remover linha encontrada
</Button>
</>
)}
</div>
);
})}
<Button
type="button"
variant="outline"
onClick={() =>
setRows([
...rows,
{
lot_id: null,
new_key: crypto.randomUUID(),
quantity: '',
code: '',
expires_on: '',
no_expiry: false,
},
])
}
>
Acrescentar lote encontrado
</Button>
{exhausted.length > 0 && (
<label className="grid gap-1 text-sm">
Reutilizar identidade de lote sem saldo que voltou a
encontrar
<select
className={controlClass}
value=""
onChange={(e) => {
const lot = exhausted.find(
(l) => String(l.lot_id) === e.target.value,
);
if (lot)
setRows([
...rows,
{
lot_id: lot.lot_id,
new_key: null,
quantity: '',
code: lot.code ?? '',
expires_on: lot.expires_on ?? '',
no_expiry: lot.no_expiry,
},
]);
}}
>
<option value="">
Seleccione apenas se encontrou quantidade deste lote
</option>
{exhausted.map((lot) => (
<option key={lot.lot_id} value={lot.lot_id}>
{lot.code ?? `Lote #${lot.lot_id}`}
</option>
))}
</select>
</label>
)}
<p className="text-sm font-medium">
Total contado: {countTotal(rows) ?? 'Complete as quantidades'}
</p>
<p className="text-sm text-muted-foreground">
Quantidades encontradas a mais ficam em quarentena após o
ajuste, mesmo num lote anteriormente libertado. A aplicação
exige conferência independente da contagem.
</p>
</fieldset>
);
}
+17
View File
@@ -47,6 +47,23 @@ export function ReceiptProgressTable({
<p className="mt-1 text-xs text-muted-foreground">
{line.unit}
</p>
{(line.supplier_returned_milli ?? 0) >
0 && (
<p className="mt-1 text-xs text-muted-foreground">
Devolvido ao fornecedor:{' '}
{quantity(
line.supplier_returned_milli ??
0,
)}{' '}
· Aceitação efectiva:{' '}
{quantity(
line.effective_accepted_milli ??
0,
)}
. Reposição exige autorização
própria.
</p>
)}
</td>
{[
line.ordered_milli,
@@ -30,6 +30,8 @@ import {
procurementNavigation,
} from '@/lib/procurement-navigation';
import { roleLabels } from '@/types/procurement';
import type { WorkContextState } from '@/types/work-context';
import { WorkContextChange } from '@/components/work-context-change';
export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
const page = usePage();
@@ -44,11 +46,31 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
active: boolean;
permissions: string[];
} | null;
const context = page.props.workContext as WorkContextState | null;
const scopedEntries = new Set(
procurementNavigation(
context?.permissions ?? [],
page.props.payablesAvailable === true,
).flatMap((group) => group.entries.map((entry) => entry.id)),
);
const groups = procurementNavigation(
access?.active ? access.permissions : (user?.purchasing_role ?? ''),
page.props.payablesAvailable === true,
publicNavigation?.paths,
);
)
.map((group) => ({
...group,
entries: group.entries.filter(
(entry) =>
!context?.enforced ||
(context.paths.includes(entry.path.split('?')[0]) &&
(context.sharedPaths.includes(
entry.path.split('?')[0],
) ||
scopedEntries.has(entry.id))),
),
}))
.filter((group) => group.entries.length);
const activeId = activeProcurementEntry(groups, page.url);
const activeGroup = groups.find((group) =>
group.entries.some((entry) => entry.id === activeId),
@@ -278,6 +300,20 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
);
return (
<div className="min-h-screen bg-slate-50 text-foreground dark:bg-background print:block">
{context?.enforced && <WorkContextChange />}
{context?.enforced && (
<div
className="border-b bg-background px-5 py-2 text-sm"
role="status"
>
{context.active
? `${context.options[context.active].areaLabel} · ${context.options[context.active].label}`
: 'Sem contexto seleccionado'}{' '}
<Link className="ml-3 underline" href="/dashboard">
Mudar contexto
</Link>
</div>
)}
{publicNavigation && (
<p className="border-b bg-background p-3 text-center text-sm">
Consulta pública de desenvolvimento · Sem organização
@@ -0,0 +1,170 @@
import { router, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
export function WorkContextChange() {
const page = usePage();
const dirty = useRef(new Set<HTMLFormElement>());
const pending = useRef(false);
const savingForm = useRef<HTMLFormElement | null>(null);
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
dirty.current.clear();
}, [page.url]);
useEffect(() => {
const formChanged = (event: Event) => {
if (
!(event instanceof CustomEvent) ||
!(event.detail.form instanceof HTMLFormElement)
)
return;
if (event.detail.dirty) dirty.current.add(event.detail.form);
else dirty.current.delete(event.detail.form);
};
const changed = (event: Event) => {
const input = event.target;
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement ||
input instanceof HTMLSelectElement
) {
if (input.form) dirty.current.add(input.form);
}
};
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const link = event.target.closest('a');
if (
!link ||
new URL(link.href).pathname !== '/dashboard' ||
!dirty.current.size
)
return;
event.preventDefault();
event.stopPropagation();
setMessage('');
setOpen(true);
};
const unload = (event: BeforeUnloadEvent) => {
if (dirty.current.size) {
event.preventDefault();
event.returnValue = '';
}
};
const saved = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
if (event.detail.success) dirty.current.delete(event.detail.form);
if (!pending.current || event.detail.form !== savingForm.current)
return;
if (event.detail.success) {
pending.current = false;
dirty.current.clear();
setOpen(false);
router.visit('/dashboard');
} else {
pending.current = false;
setOpen(true);
setMessage(
'A gravação falhou. O contexto actual foi mantido. Corrija os campos antes de mudar.',
);
}
};
const failure = () => {
if (pending.current) {
pending.current = false;
setOpen(true);
setMessage(
'Não foi possível concluir a gravação. O contexto actual foi mantido.',
);
}
};
const offException = router.on('httpException', failure);
const offNetwork = router.on('networkError', failure);
document.addEventListener('input', changed);
document.addEventListener('change', changed);
document.addEventListener('click', click, true);
window.addEventListener('beforeunload', unload);
window.addEventListener('sgc:context-save-result', saved);
window.addEventListener('sgc:context-dirty', formChanged);
return () => {
document.removeEventListener('input', changed);
document.removeEventListener('change', changed);
document.removeEventListener('click', click, true);
window.removeEventListener('beforeunload', unload);
window.removeEventListener('sgc:context-save-result', saved);
window.removeEventListener('sgc:context-dirty', formChanged);
offException();
offNetwork();
};
}, []);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Alterações por guardar</DialogTitle>
<DialogDescription>
Guarde as alterações antes de escolher outro contexto, ou
descarte-as.
</DialogDescription>
{message && <p role="alert">{message}</p>}
<div className="flex flex-wrap gap-3">
<Button
onClick={() => {
const forms = [...dirty.current].filter(
(form) => form.isConnected,
);
if (forms.length !== 1) {
setMessage(
'Existem alterações em vários formulários. Cancele a mudança e guarde cada formulário antes de continuar.',
);
return;
}
if (!forms[0].hasAttribute('data-context-save')) {
setMessage(
'Cancele a mudança e conclua este formulário pela sua acção própria. A mudança de contexto não executa aprovações ou outras decisões.',
);
return;
}
if (!forms[0].reportValidity()) {
setOpen(false);
return;
}
pending.current = true;
savingForm.current = forms[0];
setOpen(false);
forms[0].requestSubmit();
}}
>
Guardar
</Button>
<Button
variant="outline"
onClick={() => {
dirty.current.clear();
pending.current = false;
setOpen(false);
router.visit('/dashboard');
}}
>
Descartar
</Button>
<Button
variant="outline"
onClick={() => {
pending.current = false;
setOpen(false);
}}
>
Cancelar
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,87 @@
import { useForm } from '@inertiajs/react';
import { FormErrors, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import type { WorkContextState } from '@/types/work-context';
export function WorkContextSettings({
context,
enabled,
version,
}: {
context: WorkContextState;
enabled: string[];
version: number;
}) {
const form = useForm({ version, enabled, reason: '', confirm: false });
return (
<Panel title="Áreas e opções do produto">
<p className="mb-4 text-sm">
A configuração limita as opções contratadas; não atribui
permissões. A alteração de acesso é imediata, sem modificar
processos existentes. Os perfis precisam de associação explícita
às opções. Percursos ainda pendentes ficam identificados no
Painel Inicial.
</p>
{context.purchased.length === 0 && (
<p>
Não existem opções contratadas registadas para esta empresa
na instalação.
</p>
)}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/administracao/parametrizacao/work_contexts', {
preserveState: 'errors',
});
}}
>
<FormErrors errors={form.errors} />
{context.purchased.map((key) => (
<label className="flex items-center gap-2" key={key}>
<input
type="checkbox"
checked={form.data.enabled.includes(key)}
onChange={(e) =>
form.setData(
'enabled',
e.target.checked
? [...form.data.enabled, key]
: form.data.enabled.filter(
(value) => value !== key,
),
)
}
/>
{context.options[key].areaLabel} ·{' '}
{context.options[key].label}
</label>
))}
<label className="block">
Motivo
<textarea
required
minLength={10}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<label className="flex items-center gap-2">
<input
required
type="checkbox"
checked={form.data.confirm}
onChange={(e) =>
form.setData('confirm', e.target.checked)
}
/>
Confirmo o efeito imediato sobre o acesso, preservando os
processos.
</label>
<Button disabled={form.processing}>Guardar opções</Button>
</form>
</Panel>
);
}