commit13
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,11 @@ export function procurementNavigation(
|
||||
path: '/requisicoes?view=approvals',
|
||||
roles: readers,
|
||||
},
|
||||
{
|
||||
id: 'workflow_notices',
|
||||
label: 'Avisos de aprovação',
|
||||
path: '/requisicoes/avisos',
|
||||
},
|
||||
{
|
||||
id: 'decisions',
|
||||
label: 'Minhas decisões',
|
||||
@@ -103,6 +108,68 @@ export function procurementNavigation(
|
||||
label: 'Revisão e cancelamento',
|
||||
path: '/revisao-compras',
|
||||
},
|
||||
{
|
||||
id: 'supply_agreements',
|
||||
label: 'Fornecimento habitual',
|
||||
path: '/compras/fornecimento?view=agreements',
|
||||
roles: [
|
||||
'admin',
|
||||
'supply_read',
|
||||
'supply_prepare',
|
||||
'supply_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supply_calendar',
|
||||
label: 'Reposição e calendário',
|
||||
path: '/compras/fornecimento?view=calendar',
|
||||
roles: [
|
||||
'admin',
|
||||
'supply_read',
|
||||
'supply_prepare',
|
||||
'supply_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supply_suggestions',
|
||||
label: 'Sugestões de reposição',
|
||||
path: '/compras/fornecimento?view=suggestions',
|
||||
roles: [
|
||||
'admin',
|
||||
'supply_read',
|
||||
'supply_suggest_prepare',
|
||||
'supply_suggest_review',
|
||||
'supply_suggest_convert',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supply_imports',
|
||||
label: 'Importações',
|
||||
path: '/compras/fornecimento?view=imports',
|
||||
roles: [
|
||||
'admin',
|
||||
'import_read',
|
||||
'import_prepare',
|
||||
'import_customs',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supply_alternatives',
|
||||
label: 'Alternativas técnicas',
|
||||
path: '/compras/fornecimento?view=alternatives',
|
||||
roles: [
|
||||
'admin',
|
||||
'supply_read',
|
||||
'supply_technical_prepare',
|
||||
'supply_technical_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supply_deliveries',
|
||||
label: 'Entregas confirmadas',
|
||||
path: '/compras/fornecimento?view=deliveries',
|
||||
roles: ['admin', 'supply_read', 'supply_delivery'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -173,6 +240,18 @@ export function procurementNavigation(
|
||||
'people_staff_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'people_reports',
|
||||
label: 'Relatórios de pessoas e contratações',
|
||||
path: '/pessoas/relatorios',
|
||||
roles: [
|
||||
'admin',
|
||||
'people_service_prepare',
|
||||
'people_service_review',
|
||||
'people_staff_prepare',
|
||||
'people_staff_review',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -186,17 +265,98 @@ export function procurementNavigation(
|
||||
path: '/recepcoes',
|
||||
roles: inventory,
|
||||
},
|
||||
{
|
||||
id: 'supply_reservations',
|
||||
label: 'Reservas de necessidades',
|
||||
path: '/compras/fornecimento?view=reservations',
|
||||
roles: ['admin', 'supply_read', 'supply_reserve'],
|
||||
},
|
||||
{
|
||||
id: 'supply_history',
|
||||
label: 'Histórico de consumo',
|
||||
path: '/compras/fornecimento?view=history',
|
||||
roles: [
|
||||
'admin',
|
||||
'supply_read',
|
||||
'supply_history_prepare',
|
||||
'supply_history_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'warehouse_availability',
|
||||
label: 'Disponibilidade para requisições',
|
||||
path: '/stock/disponibilidade',
|
||||
roles: [
|
||||
'admin',
|
||||
'warehouse',
|
||||
'procurement',
|
||||
'finance',
|
||||
'warehouse_availability_read',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'warehouse_replenishment',
|
||||
label: 'Reposição automática',
|
||||
path: '/stock/reposicao',
|
||||
roles: ['admin', 'supply_read', 'supply_prepare', 'supply_review'],
|
||||
},
|
||||
{
|
||||
id: 'warehouse_requests',
|
||||
label: 'Requisições ao armazém',
|
||||
path: '/stock/requisicoes',
|
||||
roles: [
|
||||
'admin',
|
||||
'warehouse_request_read',
|
||||
'warehouse_request_prepare',
|
||||
'warehouse_request_review',
|
||||
'warehouse_request_deliver',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'inventory_lots',
|
||||
label: 'Lotes, validade e quarentena',
|
||||
path: '/stock/lotes',
|
||||
roles: [
|
||||
'admin',
|
||||
'inventory_lot_read',
|
||||
'inventory_lot_prepare',
|
||||
'inventory_lot_review',
|
||||
'inventory_lot_hold',
|
||||
'inventory_lot_release',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Artigos e stock',
|
||||
path: '/stock',
|
||||
roles: ['admin', 'procurement', 'finance', 'warehouse'],
|
||||
},
|
||||
{
|
||||
id: 'inventory_dispositions',
|
||||
label: 'Devoluções e correcções',
|
||||
path: '/stock/ocorrencias',
|
||||
roles: [
|
||||
'admin',
|
||||
'warehouse',
|
||||
'inventory_disposition_read',
|
||||
'inventory_disposition_prepare',
|
||||
'inventory_disposition_review',
|
||||
'inventory_reversal_read',
|
||||
'inventory_reversal_prepare',
|
||||
'inventory_reversal_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stock_operations',
|
||||
label: 'Saídas e inventários',
|
||||
path: '/stock/operacoes',
|
||||
roles: ['admin', 'procurement', 'finance', 'warehouse'],
|
||||
roles: [
|
||||
'admin',
|
||||
'procurement',
|
||||
'finance',
|
||||
'warehouse',
|
||||
'inventory_adjust_review',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
|
||||
@@ -12,8 +12,15 @@ import {
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { WorkContextSettings } from '@/components/work-context-settings';
|
||||
import type { WorkContextState } from '@/types/work-context';
|
||||
|
||||
type Profile = { id: number; name: string; permissions: string[] };
|
||||
type Profile = {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
work_contexts: string[];
|
||||
};
|
||||
type Member = {
|
||||
user_id: number;
|
||||
profile_id: number;
|
||||
@@ -160,6 +167,7 @@ export default function Parameters({
|
||||
active,
|
||||
version,
|
||||
permissions,
|
||||
workflowTasks = {},
|
||||
profiles,
|
||||
memberships,
|
||||
users,
|
||||
@@ -173,6 +181,8 @@ export default function Parameters({
|
||||
aiPlan,
|
||||
aiDomains = {},
|
||||
aiDataScopes = {},
|
||||
workContextConfiguration,
|
||||
enabledWorkContexts = [],
|
||||
}: {
|
||||
ready: boolean;
|
||||
comparisonPolicy: ComparisonPolicy;
|
||||
@@ -181,9 +191,12 @@ export default function Parameters({
|
||||
aiPlan?: AiPlan;
|
||||
aiDomains?: Record<string, string>;
|
||||
aiDataScopes?: Record<string, string>;
|
||||
workContextConfiguration?: WorkContextState;
|
||||
enabledWorkContexts?: string[];
|
||||
active: boolean;
|
||||
version: number;
|
||||
permissions: { [key: string]: string };
|
||||
workflowTasks?: Record<string, { key: string; label: string }[]>;
|
||||
profiles: Profile[];
|
||||
memberships: Member[];
|
||||
users: Person[];
|
||||
@@ -203,6 +216,7 @@ export default function Parameters({
|
||||
id: '',
|
||||
name: '',
|
||||
permissions: [] as string[],
|
||||
work_contexts: [] as string[],
|
||||
});
|
||||
const user = useForm({
|
||||
version,
|
||||
@@ -353,6 +367,13 @@ export default function Parameters({
|
||||
ready={intelligenceReady}
|
||||
/>
|
||||
)}
|
||||
{workContextConfiguration && (
|
||||
<WorkContextSettings
|
||||
context={workContextConfiguration}
|
||||
enabled={enabledWorkContexts}
|
||||
version={version}
|
||||
/>
|
||||
)}
|
||||
<Panel title="Regras de comparação de propostas">
|
||||
<p className="mb-4 text-sm">
|
||||
{comparisonPolicy.configured
|
||||
@@ -656,6 +677,8 @@ export default function Parameters({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
permissions: p.permissions,
|
||||
work_contexts:
|
||||
p.work_contexts ?? [],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -693,9 +716,55 @@ export default function Parameters({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{workContextConfiguration && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="font-medium">
|
||||
Contextos autorizados deste perfil
|
||||
</legend>
|
||||
<p className="text-sm">
|
||||
A associação não acrescenta
|
||||
permissões funcionais nem opções
|
||||
contratadas.
|
||||
</p>
|
||||
{Object.entries(
|
||||
workContextConfiguration.options,
|
||||
).map(([key, option]) => (
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
key={key}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile.data.work_contexts.includes(
|
||||
key,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
profile.setData(
|
||||
'work_contexts',
|
||||
e.target.checked
|
||||
? [
|
||||
...profile
|
||||
.data
|
||||
.work_contexts,
|
||||
key,
|
||||
]
|
||||
: profile.data.work_contexts.filter(
|
||||
(value) =>
|
||||
value !==
|
||||
key,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.areaLabel} ·{' '}
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset className="grid gap-3 sm:grid-cols-2">
|
||||
<legend className="mb-2 font-medium">
|
||||
Permissões funcionais
|
||||
Tarefas e permissões do perfil
|
||||
</legend>
|
||||
{Object.entries(permissions).map(
|
||||
([key, label]) => (
|
||||
@@ -726,11 +795,64 @@ export default function Parameters({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
<span>
|
||||
{label}
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{(
|
||||
workflowTasks[
|
||||
key
|
||||
] ?? []
|
||||
).map((task) => (
|
||||
<span
|
||||
className="block"
|
||||
key={task.key}
|
||||
>
|
||||
{task.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="rounded-md border p-4 text-sm">
|
||||
<p className="font-medium">
|
||||
Tarefas associadas a este perfil no
|
||||
workflow
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Seleccione as tarefas nas permissões
|
||||
acima e atribua o perfil às pessoas. O
|
||||
contexto, departamento, equipa do
|
||||
processo, validade do acesso e
|
||||
independência continuam a limitar cada
|
||||
intervenção. Administração, por si só,
|
||||
não autoriza decisões de compras. Estas
|
||||
associações não alteram o percurso
|
||||
aprovado.
|
||||
</p>
|
||||
{profile.data.permissions.length === 0 ? (
|
||||
<p className="mt-3">
|
||||
Sem tarefas seleccionadas.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5">
|
||||
{profile.data.permissions
|
||||
.flatMap(
|
||||
(permission) =>
|
||||
workflowTasks[
|
||||
permission
|
||||
] ?? [],
|
||||
)
|
||||
.map((task) => (
|
||||
<li key={task.key}>
|
||||
{task.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button disabled={profile.processing}>
|
||||
{profile.data.id
|
||||
@@ -746,6 +868,7 @@ export default function Parameters({
|
||||
id: '',
|
||||
name: '',
|
||||
permissions: [],
|
||||
work_contexts: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -48,6 +48,12 @@ type History = {
|
||||
equipment?: { code: string; description: string };
|
||||
stock_item: { name: string; unit: string };
|
||||
reason?: string;
|
||||
reversal?: {
|
||||
source: {
|
||||
document: { reference: string };
|
||||
rows: { warehouse_name: string; quantity_milli: number }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
@@ -766,6 +772,24 @@ export default function EquipmentPage({
|
||||
{h.snapshot.stock_item.unit}
|
||||
</p>
|
||||
<p>{h.snapshot.reason}</p>
|
||||
{h.snapshot.reversal && (
|
||||
<p className="mt-2 font-medium">
|
||||
Estorno de{' '}
|
||||
{
|
||||
h.snapshot.reversal.source.document
|
||||
.reference
|
||||
}
|
||||
:{' '}
|
||||
{h.snapshot.reversal.source.rows
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.warehouse_name}: ${row.quantity_milli > 0 ? '+' : ''}${quantity(row.quantity_milli)}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
. As quantidades repostas exigem a
|
||||
conferência de qualidade aplicável.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{history.data.length === 0 && (
|
||||
|
||||
@@ -49,11 +49,28 @@ export default function Invoices({
|
||||
orders,
|
||||
canManage,
|
||||
payablesAvailable = false,
|
||||
stockReturns = [],
|
||||
stockReversals = [],
|
||||
}: {
|
||||
invoices: Page<Invoice>;
|
||||
orders: { id: number; number: string; supplier: string; items: Source[] }[];
|
||||
canManage: boolean;
|
||||
payablesAvailable?: boolean;
|
||||
stockReturns?: {
|
||||
reference: string;
|
||||
quantity_milli: number;
|
||||
unit: string;
|
||||
order_number: string;
|
||||
supplier_name: string;
|
||||
event_on: string;
|
||||
reversed?: boolean;
|
||||
}[];
|
||||
stockReversals?: {
|
||||
reference: string;
|
||||
source_reference: string;
|
||||
item_name: string;
|
||||
event_on: string;
|
||||
}[];
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const [orderId, setOrderId] = useState('');
|
||||
@@ -90,6 +107,47 @@ export default function Invoices({
|
||||
compromisso orçamental aprovado. Diferenças, notas de
|
||||
crédito e operações entre exercícios ficam bloqueadas.
|
||||
</p>
|
||||
{canManage && stockReturns.length > 0 && (
|
||||
<Panel title="Devoluções físicas a conferir em Finanças">
|
||||
<p className="mb-3 text-sm">
|
||||
Novas conferências de facturas descontam quantidades
|
||||
devolvidas. Os documentos e obrigações anteriores
|
||||
mantêm os valores registados; confronte os créditos
|
||||
ou restituições correspondentes. Uma saída física
|
||||
não comprova regularização financeira.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{stockReturns.map((r) => (
|
||||
<li key={r.reference}>
|
||||
{r.reference} · {r.order_number} ·{' '}
|
||||
{r.supplier_name} ·{' '}
|
||||
{r.quantity_milli / 1000} {r.unit} ·{' '}
|
||||
{r.event_on}
|
||||
{r.reversed &&
|
||||
' · Saída física posteriormente estornada'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
{canManage && stockReversals.length > 0 && (
|
||||
<Panel title="Estornos físicos com documentos a conferir">
|
||||
<p className="mb-3 text-sm">
|
||||
Estes estornos não alteraram facturas, créditos ou
|
||||
pagamentos já registados. Confronte a documentação
|
||||
financeira e contabilística antes de concluir a
|
||||
regularização.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{stockReversals.map((r) => (
|
||||
<li key={r.reference}>
|
||||
{r.reference} · Corrige {r.source_reference}{' '}
|
||||
· {r.item_name} · {r.event_on}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
{canManage && (
|
||||
<Panel
|
||||
title="Registar factura"
|
||||
|
||||
@@ -185,12 +185,19 @@ export default function PeopleIndex({
|
||||
description="Contratação de prestadores individuais e recrutamento de trabalhadores, com critérios próprios e decisão independente."
|
||||
action={
|
||||
ready ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.reload()}
|
||||
>
|
||||
Actualizar
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/pessoas/relatorios">
|
||||
Relatórios
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.reload()}
|
||||
>
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { peopleStates } from '@/lib/people-hiring';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
service: 'Serviços individuais',
|
||||
staff: 'Recrutamento e selecção',
|
||||
};
|
||||
type Filters = {
|
||||
deadline_window?: string;
|
||||
view?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
type Props = {
|
||||
deadlineReport: null | {
|
||||
as_of: string;
|
||||
window: string;
|
||||
total_items: number;
|
||||
matching_processes: number;
|
||||
processes_considered: number;
|
||||
pagination: {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
tasks: number;
|
||||
approvals: number;
|
||||
alerts: number;
|
||||
without_deadline: number;
|
||||
overdue_items: number;
|
||||
items: {
|
||||
id: string;
|
||||
reference: string;
|
||||
title: string;
|
||||
category: string;
|
||||
label: string;
|
||||
responsibility: string;
|
||||
due_on: string | null;
|
||||
days_overdue: number | null;
|
||||
href: string;
|
||||
}[];
|
||||
};
|
||||
selectionMetrics: Record<
|
||||
string,
|
||||
{
|
||||
selection_state: string;
|
||||
proposed_at: string | null;
|
||||
decided_at: string | null;
|
||||
pending: string;
|
||||
contract_visibility: string;
|
||||
contract_reference: string | null;
|
||||
contract_prepared_at: string | null;
|
||||
contract_starts_on: string | null;
|
||||
}
|
||||
>;
|
||||
applicationMetrics: Record<
|
||||
string,
|
||||
{
|
||||
registered: number;
|
||||
evaluation_complete: number;
|
||||
evaluation_pending: number;
|
||||
evaluation_unstarted: number;
|
||||
evaluation_in_progress: number;
|
||||
basis: string;
|
||||
portal_received: number | null;
|
||||
portal_accepted: number | null;
|
||||
portal_rejected: number | null;
|
||||
portal_pending: number | null;
|
||||
}
|
||||
>;
|
||||
filters: Filters;
|
||||
kinds: string[];
|
||||
generatedAt: string;
|
||||
summary: { kind: string; status: string; total: number | string }[];
|
||||
processes: {
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
closes_on: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function Reports(props: Props) {
|
||||
const form = useForm({
|
||||
deadline_window: props.filters.deadline_window ?? 'all',
|
||||
view: props.filters.view ?? 'processes',
|
||||
kind: props.filters.kind ?? '',
|
||||
status: props.filters.status ?? '',
|
||||
search: props.filters.search ?? '',
|
||||
from: props.filters.from ?? '',
|
||||
to: props.filters.to ?? '',
|
||||
});
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(props.filters).filter(([, value]) => !!value) as [
|
||||
string,
|
||||
string,
|
||||
][],
|
||||
).toString();
|
||||
return (
|
||||
<>
|
||||
<Head title="Relatórios de pessoas e contratações" />
|
||||
<style>
|
||||
{
|
||||
'@media print { .people-report-tools { display: none !important; } }'
|
||||
}
|
||||
</style>
|
||||
<div className="space-y-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="Pessoas e contratações"
|
||||
title="Relatórios"
|
||||
description="Dados reutilizados dos processos que está autorizado a consultar, no contexto seleccionado."
|
||||
/>
|
||||
<div className="people-report-tools flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/pessoas/relatorios?${new URLSearchParams({ ...props.filters, view: 'deadlines' }).toString()}`}
|
||||
>
|
||||
Prazos e responsabilidades
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pessoas/relatorios?${new URLSearchParams({ ...props.filters, view: 'selections' }).toString()}`}
|
||||
>
|
||||
Selecções e contratações
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pessoas/relatorios?${new URLSearchParams({ ...props.filters, view: 'processes' }).toString()}`}
|
||||
>
|
||||
Processos e estados
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pessoas/relatorios?${new URLSearchParams({ ...props.filters, view: 'applications' }).toString()}`}
|
||||
>
|
||||
Candidaturas e avaliações
|
||||
</Link>
|
||||
<Link href="/pessoas">Voltar aos processos</Link>
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
Imprimir esta página
|
||||
</Button>
|
||||
<a
|
||||
className="rounded-md border px-4 py-2 text-sm"
|
||||
href={`/pessoas/relatorios.csv?${query}`}
|
||||
>
|
||||
Exportar resultados em CSV
|
||||
</a>
|
||||
</div>
|
||||
<div className="people-report-tools">
|
||||
<Panel
|
||||
title="Filtrar relatório"
|
||||
description="O período refere-se à criação dos processos. A exportação usa os filtros aplicados."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4 md:grid-cols-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get('/pessoas/relatorios');
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Relatório
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.view}
|
||||
onChange={(e) =>
|
||||
form.setData('view', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="processes">
|
||||
Processos e estados
|
||||
</option>
|
||||
<option value="applications">
|
||||
Candidaturas e avaliações
|
||||
</option>
|
||||
<option value="deadlines">
|
||||
Prazos e responsabilidades
|
||||
</option>
|
||||
<option value="selections">
|
||||
Selecções e contratações
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.data.view === 'deadlines' && (
|
||||
<label>
|
||||
Prazo da intervenção
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.deadline_window}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'deadline_window',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="overdue">
|
||||
Vencidos
|
||||
</option>
|
||||
<option value="today">Hoje</option>
|
||||
<option value="future">Futuros</option>
|
||||
<option value="undated">
|
||||
Sem prazo definido
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Percurso
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.kind}
|
||||
onChange={(e) =>
|
||||
form.setData('kind', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Todos os autorizados
|
||||
</option>
|
||||
{props.kinds.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{labels[kind]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.status}
|
||||
onChange={(e) =>
|
||||
form.setData('status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(peopleStates).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Título ou referência
|
||||
<input
|
||||
className={controlClass}
|
||||
value={form.data.search}
|
||||
onChange={(e) =>
|
||||
form.setData('search', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Criado desde
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.from}
|
||||
onChange={(e) =>
|
||||
form.setData('from', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Criado até
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.to}
|
||||
onChange={(e) =>
|
||||
form.setData('to', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end gap-3">
|
||||
<Button disabled={form.processing}>
|
||||
Aplicar filtros
|
||||
</Button>
|
||||
<Link href="/pessoas/relatorios">Limpar</Link>
|
||||
</div>
|
||||
<FormErrors errors={form.errors} />
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
{props.deadlineReport && (
|
||||
<Panel
|
||||
title="Prazos e responsabilidades"
|
||||
description={`Consulta em ${props.deadlineReport.as_of}. Totais de todos os resultados filtrados; a impressão inclui esta página e o CSV inclui todos.`}
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
{props.deadlineReport.total_items} ocorrências em{' '}
|
||||
{props.deadlineReport.matching_processes} processos,
|
||||
entre {props.deadlineReport.processes_considered}{' '}
|
||||
processos autorizados considerados. Filtro de prazo:{' '}
|
||||
{
|
||||
(
|
||||
{
|
||||
all: 'todos',
|
||||
overdue: 'vencidos',
|
||||
today: 'hoje',
|
||||
future: 'futuros',
|
||||
undated: 'sem prazo definido',
|
||||
} as Record<string, string>
|
||||
)[props.deadlineReport.window]
|
||||
}
|
||||
.
|
||||
</p>
|
||||
<p className="mb-4 text-sm">
|
||||
Tarefas: {props.deadlineReport.tasks} ·
|
||||
Conferências/decisões:{' '}
|
||||
{props.deadlineReport.approvals} · Alertas:{' '}
|
||||
{props.deadlineReport.alerts} · Ocorrências com
|
||||
atraso: {props.deadlineReport.overdue_items} · Sem
|
||||
prazo definido:{' '}
|
||||
{props.deadlineReport.without_deadline}.
|
||||
</p>
|
||||
<p className="mb-4 text-sm">
|
||||
Um alerta do processo não atribui responsabilidade
|
||||
pessoal. Sem prazo definido não equivale a tarefa em
|
||||
dia. Esta consulta não representa toda a carga de
|
||||
trabalho da equipa; o mesmo processo pode ter várias
|
||||
ocorrências.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Processo',
|
||||
'Categoria e intervenção',
|
||||
'Responsabilidade',
|
||||
'Prazo',
|
||||
'Dias de atraso',
|
||||
].map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="border-b p-3"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.deadlineReport.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b p-3">
|
||||
<Link href={item.href}>
|
||||
{item.reference} ·{' '}
|
||||
{item.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{item.category} · {item.label}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{item.responsibility}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{item.due_on ??
|
||||
'Sem prazo definido'}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{item.days_overdue ??
|
||||
'Não determinável'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{props.deadlineReport.items.length === 0 && (
|
||||
<p className="mt-3">
|
||||
Sem intervenções ou alertas visíveis neste
|
||||
âmbito.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm">
|
||||
Página{' '}
|
||||
{props.deadlineReport.pagination.current_page} de{' '}
|
||||
{props.deadlineReport.pagination.last_page}.
|
||||
</p>
|
||||
<div className="people-report-tools mt-3 flex gap-4">
|
||||
{props.deadlineReport.pagination.prev_page_url && (
|
||||
<Link
|
||||
href={
|
||||
props.deadlineReport.pagination
|
||||
.prev_page_url
|
||||
}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{props.deadlineReport.pagination.next_page_url && (
|
||||
<Link
|
||||
href={
|
||||
props.deadlineReport.pagination
|
||||
.next_page_url
|
||||
}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{props.filters.view === 'selections' && (
|
||||
<Panel
|
||||
title="Selecções e contratações"
|
||||
description="Situação e pendências dos processos desta página, a partir dos registos existentes. O período filtra a criação dos processos."
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
Aprovar uma selecção e preparar uma minuta são
|
||||
etapas distintas. O início previsto na minuta não
|
||||
comprova assinatura nem admissão efectiva. Dados da
|
||||
minuta exigem acesso contratual próprio.
|
||||
</p>
|
||||
{props.processes.data.map((process) => {
|
||||
const m = props.selectionMetrics[process.id];
|
||||
if (!m) return null;
|
||||
return (
|
||||
<div
|
||||
key={process.id}
|
||||
className="mb-4 rounded-md border p-4"
|
||||
>
|
||||
<Link
|
||||
className="font-medium"
|
||||
href={`/pessoas/${process.id}`}
|
||||
>
|
||||
PES-{process.id} · {process.title}
|
||||
</Link>
|
||||
<dl className="mt-3 grid gap-3 text-sm md:grid-cols-2">
|
||||
{Object.entries({
|
||||
Selecção: m.selection_state,
|
||||
'Proposta em': m.proposed_at,
|
||||
'Decisão em': m.decided_at,
|
||||
Pendência: m.pending,
|
||||
'Acesso à minuta':
|
||||
m.contract_visibility,
|
||||
'Referência da minuta':
|
||||
m.contract_reference,
|
||||
'Preparada em':
|
||||
m.contract_prepared_at,
|
||||
'Início previsto':
|
||||
m.contract_starts_on,
|
||||
}).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="font-medium">
|
||||
{label}
|
||||
</dt>
|
||||
<dd>
|
||||
{value ?? 'Não registado'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{props.processes.data.length === 0 && (
|
||||
<p>Sem processos nos filtros seleccionados.</p>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{props.filters.view === 'applications' && (
|
||||
<Panel
|
||||
title="Candidaturas e avaliações"
|
||||
description="Contagens dos processos desta página. O período filtra a criação dos processos. Registadas inclui entradas manuais e candidaturas do portal admitidas, sem duplicação."
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
Avaliação completa exige o mínimo de pareceres
|
||||
elegíveis. Após aprovação da selecção usa-se a
|
||||
fotografia preservada. Admissão para análise não
|
||||
significa contratação. Sem acesso à triagem não
|
||||
equivale a zero.
|
||||
</p>
|
||||
{props.processes.data.map((process) => {
|
||||
const m = props.applicationMetrics[process.id];
|
||||
if (!m) return null;
|
||||
const count = (n: number | null) =>
|
||||
n === null ? 'Sem acesso' : n;
|
||||
return (
|
||||
<div
|
||||
key={process.id}
|
||||
className="mb-4 rounded-md border p-4"
|
||||
>
|
||||
<Link
|
||||
className="font-medium"
|
||||
href={`/pessoas/${process.id}`}
|
||||
>
|
||||
PES-{process.id} · {process.title}
|
||||
</Link>
|
||||
<p className="mt-2 text-sm">
|
||||
Registadas para avaliação:{' '}
|
||||
{m.registered} · Completas:{' '}
|
||||
{m.evaluation_complete} · Por concluir:{' '}
|
||||
{m.evaluation_pending} (sem avaliação:{' '}
|
||||
{m.evaluation_unstarted}; em curso:{' '}
|
||||
{m.evaluation_in_progress}).
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Portal — recebidas:{' '}
|
||||
{count(m.portal_received)} · admitidas
|
||||
para análise: {count(m.portal_accepted)}{' '}
|
||||
· não admitidas:{' '}
|
||||
{count(m.portal_rejected)} · triagem
|
||||
pendente: {count(m.portal_pending)}.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Base:{' '}
|
||||
{m.basis === 'selection_snapshot'
|
||||
? 'selecção preservada'
|
||||
: 'elegibilidade actual das avaliações'}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{props.processes.data.length === 0 && (
|
||||
<p>Sem processos nos filtros seleccionados.</p>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!props.deadlineReport && (
|
||||
<>
|
||||
<Panel
|
||||
title="Resumo dos resultados"
|
||||
description="Uma minuta preparada não comprova contrato assinado nem admissão concluída."
|
||||
>
|
||||
<p className="mb-3 text-sm">
|
||||
{props.processes.total} processos · Consulta em{' '}
|
||||
{new Date(props.generatedAt).toLocaleString(
|
||||
'pt-PT',
|
||||
)}
|
||||
</p>
|
||||
{props.summary.length === 0 ? (
|
||||
<p>
|
||||
Sem processos no âmbito e filtros
|
||||
seleccionados.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-2 md:grid-cols-2">
|
||||
{props.summary.map((row) => (
|
||||
<li
|
||||
key={`${row.kind}:${row.status}`}
|
||||
className="rounded-md border p-3"
|
||||
>
|
||||
{labels[row.kind]} ·{' '}
|
||||
{peopleStates[row.status] ??
|
||||
row.status}
|
||||
: <strong>{row.total}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Processos"
|
||||
description={`Página ${props.processes.current_page} de ${props.processes.last_page}. A impressão inclui esta página; o CSV inclui todos os resultados filtrados.`}
|
||||
>
|
||||
<p className="mb-3 text-sm">
|
||||
Filtros aplicados:{' '}
|
||||
{labels[props.filters.kind ?? ''] ??
|
||||
'todos os percursos autorizados'}{' '}
|
||||
·{' '}
|
||||
{peopleStates[props.filters.status ?? ''] ??
|
||||
'todos os estados'}{' '}
|
||||
· {props.filters.from ?? 'sem início'} a{' '}
|
||||
{props.filters.to ?? 'sem fim'}
|
||||
{props.filters.search
|
||||
? ` · Pesquisa: ${props.filters.search}`
|
||||
: ''}
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Referência e título',
|
||||
'Percurso',
|
||||
'Estado',
|
||||
'Fecho de candidaturas',
|
||||
].map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="border-b p-3"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.processes.data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="border-b p-3">
|
||||
<Link
|
||||
href={`/pessoas/${row.id}`}
|
||||
>
|
||||
PES-{row.id} ·{' '}
|
||||
{row.title}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs">
|
||||
O processo permite
|
||||
imprimir o relatório
|
||||
individual.
|
||||
</p>
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{labels[row.kind]}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{peopleStates[row.status] ??
|
||||
row.status}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{row.closes_on}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="people-report-tools mt-4 flex gap-4">
|
||||
{props.processes.prev_page_url && (
|
||||
<Link href={props.processes.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{props.processes.next_page_url && (
|
||||
<Link href={props.processes.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,12 @@ type Line = {
|
||||
quality_status: string;
|
||||
notes: string;
|
||||
stock_item_id: string;
|
||||
lots: {
|
||||
code: string;
|
||||
expires_on: string;
|
||||
no_expiry: boolean;
|
||||
quantity: string;
|
||||
}[];
|
||||
};
|
||||
type Props = {
|
||||
order: { id: number; number: string };
|
||||
@@ -34,6 +40,11 @@ type Props = {
|
||||
warehouses: Warehouse[];
|
||||
stockItems: StockItem[];
|
||||
canConfirm: boolean;
|
||||
lotScopes: {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
quarantine_on_receipt: boolean;
|
||||
}[];
|
||||
};
|
||||
export default function ReceiveOrder({
|
||||
order,
|
||||
@@ -43,6 +54,7 @@ export default function ReceiveOrder({
|
||||
warehouses,
|
||||
stockItems,
|
||||
canConfirm,
|
||||
lotScopes,
|
||||
}: Props) {
|
||||
const form = useForm({
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
@@ -57,6 +69,7 @@ export default function ReceiveOrder({
|
||||
quality_status: 'compliant',
|
||||
notes: '',
|
||||
stock_item_id: line.stock_item_id ? String(line.stock_item_id) : '',
|
||||
lots: [] as Line['lots'],
|
||||
})),
|
||||
});
|
||||
const change = (index: number, updates: Partial<Line>) =>
|
||||
@@ -467,6 +480,248 @@ export default function ReceiveOrder({
|
||||
físicas.
|
||||
</p>
|
||||
)}
|
||||
{lotScopes.some(
|
||||
(scope) =>
|
||||
String(
|
||||
scope.stock_item_id,
|
||||
) ===
|
||||
form.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.stock_item_id &&
|
||||
String(
|
||||
scope.warehouse_id,
|
||||
) ===
|
||||
form.data
|
||||
.warehouse_id,
|
||||
) && (
|
||||
<details className="rounded border p-3 md:col-span-2">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Identificação
|
||||
dos lotes
|
||||
recebidos
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
A regra
|
||||
aprovada
|
||||
deste
|
||||
artigo/local
|
||||
determina a
|
||||
quarentena.
|
||||
Só uma
|
||||
recepção
|
||||
conforme,
|
||||
identificada
|
||||
e com
|
||||
validade
|
||||
suficiente
|
||||
pode
|
||||
beneficiar
|
||||
de dispensa
|
||||
previamente
|
||||
aprovada. Um
|
||||
único lote
|
||||
reutiliza a
|
||||
quantidade
|
||||
aceite. Se
|
||||
não tiver a
|
||||
identificação,
|
||||
poderá
|
||||
registá-la
|
||||
posteriormente
|
||||
em Lotes,
|
||||
validade e
|
||||
quarentena.
|
||||
</p>
|
||||
{form.data.items[
|
||||
index
|
||||
].lots.map(
|
||||
(
|
||||
lot,
|
||||
lotIndex,
|
||||
) => (
|
||||
<fieldset
|
||||
key={
|
||||
lotIndex
|
||||
}
|
||||
className="mb-3 grid gap-3 md:grid-cols-2"
|
||||
>
|
||||
<legend>
|
||||
Lote{' '}
|
||||
{lotIndex +
|
||||
1}
|
||||
</legend>
|
||||
{(
|
||||
[
|
||||
'code',
|
||||
'expires_on',
|
||||
'quantity',
|
||||
] as const
|
||||
).map(
|
||||
(
|
||||
key,
|
||||
) => (
|
||||
<label
|
||||
key={
|
||||
key
|
||||
}
|
||||
className="grid gap-1 text-sm"
|
||||
>
|
||||
{
|
||||
{
|
||||
code: 'Código do lote',
|
||||
expires_on:
|
||||
'Validade',
|
||||
quantity:
|
||||
'Quantidade (apenas para vários lotes)',
|
||||
}[
|
||||
key
|
||||
]
|
||||
}
|
||||
<Input
|
||||
type={
|
||||
key ===
|
||||
'expires_on'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={
|
||||
lot[
|
||||
key
|
||||
]
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
lots: form.data.items[
|
||||
index
|
||||
].lots.map(
|
||||
(
|
||||
value,
|
||||
li,
|
||||
) =>
|
||||
li ===
|
||||
lotIndex
|
||||
? {
|
||||
...value,
|
||||
[key]: e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: value,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
lot.no_expiry
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
lots: form.data.items[
|
||||
index
|
||||
].lots.map(
|
||||
(
|
||||
value,
|
||||
li,
|
||||
) =>
|
||||
li ===
|
||||
lotIndex
|
||||
? {
|
||||
...value,
|
||||
no_expiry:
|
||||
e
|
||||
.target
|
||||
.checked,
|
||||
}
|
||||
: value,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
Confirmo
|
||||
artigo
|
||||
sem
|
||||
prazo
|
||||
de
|
||||
validade
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
lots: form.data.items[
|
||||
index
|
||||
].lots.filter(
|
||||
(
|
||||
_,
|
||||
li,
|
||||
) =>
|
||||
li !==
|
||||
lotIndex,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Remover
|
||||
repartição
|
||||
</Button>
|
||||
</fieldset>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
change(
|
||||
index,
|
||||
{
|
||||
lots: [
|
||||
...form
|
||||
.data
|
||||
.items[
|
||||
index
|
||||
]
|
||||
.lots,
|
||||
{
|
||||
code: '',
|
||||
expires_on:
|
||||
'',
|
||||
no_expiry: false,
|
||||
quantity:
|
||||
'',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Acrescentar
|
||||
identificação
|
||||
de lote
|
||||
</Button>
|
||||
</details>
|
||||
)}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label
|
||||
htmlFor={`notes-${source.id}`}
|
||||
|
||||
@@ -8,9 +8,11 @@ import type { Receipt, StockMovement } from '@/types/inventory';
|
||||
export default function ReceiptShow({
|
||||
receipt,
|
||||
movements,
|
||||
correctedReceiptItems = {},
|
||||
}: {
|
||||
receipt: Receipt;
|
||||
movements: StockMovement[];
|
||||
correctedReceiptItems?: Record<number, string>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -59,6 +61,15 @@ export default function ReceiptShow({
|
||||
{line.snapshot.order_item.description} ·{' '}
|
||||
{qualityStates[line.quality_status]}
|
||||
</h3>
|
||||
{correctedReceiptItems[line.id] && (
|
||||
<p className="mt-2 font-medium">
|
||||
Linha estornada integralmente por{' '}
|
||||
{correctedReceiptItems[line.id]}. As
|
||||
quantidades abaixo preservam o documento
|
||||
original; consulte a situação efectiva
|
||||
da ordem.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">
|
||||
Recebido: {quantity(line.received_milli)} ·
|
||||
Aceite: {quantity(line.accepted_milli)} ·
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { Plus, Save, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
@@ -97,7 +98,10 @@ export default function RequisitionForm({
|
||||
defaultTaxRate?: string;
|
||||
catalogue?: CatalogueOptions;
|
||||
}) {
|
||||
const ready = usePage().props.internalRequisitionReady === true;
|
||||
const form = useForm({
|
||||
operation_key: ready ? crypto.randomUUID() : null,
|
||||
process_version: requisition?.process_version ?? null,
|
||||
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
|
||||
priority: requisition?.priority ?? 'normal',
|
||||
needed_by: requisition?.needed_by ?? '',
|
||||
@@ -125,6 +129,14 @@ export default function RequisitionForm({
|
||||
downtime_started_at: i.downtime_started_at?.slice(0, 16) ?? '',
|
||||
})) ?? [emptyLine(defaultTaxRate)],
|
||||
});
|
||||
const formElement = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('sgc:context-dirty', {
|
||||
detail: { form: formElement.current, dirty: form.isDirty },
|
||||
}),
|
||||
);
|
||||
}, [form.isDirty]);
|
||||
const changeLine = (index: number, field: keyof Line, value: string) =>
|
||||
form.setData(
|
||||
'items',
|
||||
@@ -157,12 +169,25 @@ export default function RequisitionForm({
|
||||
description="Descreva a necessidade e os artigos. Guarde o rascunho e confira os valores antes de submeter."
|
||||
/>
|
||||
<form
|
||||
ref={formElement}
|
||||
data-context-save
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const element = e.currentTarget;
|
||||
const result = (success: boolean) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('sgc:context-save-result', {
|
||||
detail: { form: element, success },
|
||||
}),
|
||||
);
|
||||
const options = {
|
||||
onSuccess: () => result(true),
|
||||
onError: () => result(false),
|
||||
};
|
||||
if (requisition)
|
||||
form.put(`/requisicoes/${requisition.id}`);
|
||||
else form.post('/requisicoes');
|
||||
form.put(`/requisicoes/${requisition.id}`, options);
|
||||
else form.post('/requisicoes', options);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { Check, Circle, Pencil, Send } from 'lucide-react';
|
||||
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
|
||||
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
|
||||
@@ -36,9 +36,29 @@ export default function Show({
|
||||
audit,
|
||||
technical,
|
||||
comparisonPolicy,
|
||||
replenishmentSource,
|
||||
approvalRouting,
|
||||
canRefreshRouting = false,
|
||||
}: {
|
||||
requisition: Requisition;
|
||||
canRefreshRouting?: boolean;
|
||||
approvalRouting?: {
|
||||
status: string;
|
||||
step_id: number | null;
|
||||
role: string | null;
|
||||
people: { id: number; name: string }[];
|
||||
checked_at: string;
|
||||
};
|
||||
comparisonPolicy: ComparisonPolicy;
|
||||
replenishmentSource?: {
|
||||
approval_version: number;
|
||||
prepared_on: string;
|
||||
quantity_milli: number;
|
||||
unit: string;
|
||||
delivery_on: string;
|
||||
purpose: string;
|
||||
origin: string;
|
||||
} | null;
|
||||
canEdit: boolean;
|
||||
canDecide: boolean;
|
||||
conflictBlocked: boolean;
|
||||
@@ -55,13 +75,25 @@ export default function Show({
|
||||
}[];
|
||||
};
|
||||
}) {
|
||||
const submission = useForm({});
|
||||
const ready = usePage().props.internalRequisitionReady === true;
|
||||
const submission = useForm({
|
||||
operation_key: ready ? crypto.randomUUID() : null,
|
||||
process_version: r.process_version ?? null,
|
||||
});
|
||||
const routing = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
process_version: r.process_version ?? 1,
|
||||
step_id: approvalRouting?.step_id,
|
||||
reason: '',
|
||||
});
|
||||
const review = useForm({
|
||||
basis_hash: technical?.basis_hash ?? '',
|
||||
decision: 'approved',
|
||||
reason: '',
|
||||
});
|
||||
const decision = useForm<{ decision: string; comment: string }>({
|
||||
const decision = useForm({
|
||||
operation_key: ready ? crypto.randomUUID() : null,
|
||||
process_version: r.process_version ?? null,
|
||||
decision: 'approved',
|
||||
comment: '',
|
||||
});
|
||||
@@ -69,6 +101,11 @@ export default function Show({
|
||||
<>
|
||||
<Head title={r.number} />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
||||
{r.process_version && (
|
||||
<p className="text-sm">
|
||||
Compras internas · Versão {r.process_version}
|
||||
</p>
|
||||
)}
|
||||
<PageTitle
|
||||
eyebrow="Requisição de compra"
|
||||
title={r.number}
|
||||
@@ -85,6 +122,35 @@ export default function Show({
|
||||
}
|
||||
/>
|
||||
<FormErrors errors={submission.errors} />
|
||||
{replenishmentSource && (
|
||||
<Panel title="Origem na reposição">
|
||||
<p className="text-sm">
|
||||
Criada a partir de uma sugestão de reposição, versão
|
||||
aprovada {replenishmentSource.approval_version},
|
||||
preparada em {replenishmentSource.prepared_on}.
|
||||
Proposta de{' '}
|
||||
{(
|
||||
replenishmentSource.quantity_milli / 1000
|
||||
).toLocaleString('pt-MZ', {
|
||||
maximumFractionDigits: 3,
|
||||
})}{' '}
|
||||
{replenishmentSource.unit}, para{' '}
|
||||
{replenishmentSource.delivery_on}.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{replenishmentSource.purpose === 'resale'
|
||||
? 'Revenda'
|
||||
: 'Uso interno'}{' '}
|
||||
·{' '}
|
||||
{replenishmentSource.origin === 'import'
|
||||
? 'Importação directa'
|
||||
: 'Compra local'}
|
||||
. A preparação e as decisões originais permanecem
|
||||
preservadas; esta requisição segue o circuito
|
||||
habitual.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel title="Regra de comparação deste processo">
|
||||
<ComparisonPolicySummary policy={comparisonPolicy} />
|
||||
</Panel>
|
||||
@@ -408,6 +474,111 @@ export default function Show({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{approvalRouting &&
|
||||
approvalRouting.status !== 'inactive' && (
|
||||
<Panel
|
||||
title="Quem pode tratar a etapa actual"
|
||||
description="Consulta das permissões e impedimentos actuais, respeitando o percurso já aprovado."
|
||||
>
|
||||
<p className="text-sm">
|
||||
Etapa:{' '}
|
||||
{roleLabels[
|
||||
approvalRouting.role ?? ''
|
||||
] ?? approvalRouting.role}
|
||||
.
|
||||
</p>
|
||||
{canRefreshRouting && (
|
||||
<details className="my-3 rounded-md border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Actualizar encaminhamento
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
routing.post(
|
||||
`/requisicoes/${r.id}/encaminhamento`,
|
||||
{
|
||||
preserveState:
|
||||
'errors',
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Avisa novas pessoas
|
||||
actualmente elegíveis para
|
||||
esta etapa. Não muda perfis,
|
||||
aprovações ou avisos já
|
||||
lidos.
|
||||
</p>
|
||||
<FormErrors
|
||||
errors={routing.errors}
|
||||
/>
|
||||
<label className="block text-sm">
|
||||
Motivo
|
||||
<input
|
||||
className={controlClass}
|
||||
required
|
||||
minLength={10}
|
||||
value={
|
||||
routing.data.reason
|
||||
}
|
||||
onChange={(event) =>
|
||||
routing.setData(
|
||||
'reason',
|
||||
event.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={
|
||||
routing.processing
|
||||
}
|
||||
>
|
||||
Actualizar avisos da etapa
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
{approvalRouting.people.length === 0 ? (
|
||||
<p
|
||||
className="mt-3 text-sm"
|
||||
role="status"
|
||||
>
|
||||
Sem pessoa actualmente elegível para
|
||||
decidir esta etapa. A administração
|
||||
deve conferir perfis, departamento,
|
||||
validade dos acessos e impedimentos.
|
||||
O pedido continua pendente.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
{approvalRouting.people.map(
|
||||
(person) => (
|
||||
<li key={person.id}>
|
||||
{person.name}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Estas pessoas podem consultar a
|
||||
tarefa em As minhas aprovações.
|
||||
A lista é uma fila de elegíveis,
|
||||
sem atribuição exclusiva ou
|
||||
aviso enviado; o servidor volta
|
||||
a validar a autorização ao
|
||||
decidir.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
<Panel title="Percurso de aprovação">
|
||||
{r.steps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
type Row = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
physical_milli: number;
|
||||
usable_milli: number | null;
|
||||
blocked_milli: number | null;
|
||||
lot_control: boolean;
|
||||
reconciled: boolean;
|
||||
planned_milli: number | null;
|
||||
minimum_milli: number | null;
|
||||
at_minimum: boolean;
|
||||
physical_commitment_milli: number | null;
|
||||
free_milli: number | null;
|
||||
};
|
||||
type Props = {
|
||||
filters: { warehouse_id?: number; search?: string };
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
warehouse: { name: string } | null;
|
||||
checkedAt: string;
|
||||
planningVisible: boolean;
|
||||
rows: null | {
|
||||
data: Row[];
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
};
|
||||
const quantity = (n: number | null) =>
|
||||
n === null
|
||||
? 'Por determinar'
|
||||
: (n / 1000).toLocaleString('pt-PT', { maximumFractionDigits: 3 });
|
||||
export default function Availability(props: Props) {
|
||||
const form = useForm({
|
||||
warehouse_id: props.filters.warehouse_id?.toString() ?? '',
|
||||
search: props.filters.search ?? '',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Disponibilidade para requisições" />
|
||||
<div className="space-y-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="Armazém e activos"
|
||||
title="Disponibilidade para requisições"
|
||||
description="Consulta dos registos existentes. Consultar não reserva nem autoriza uma saída."
|
||||
/>
|
||||
<Panel title="Escolher armazém">
|
||||
<form
|
||||
className="grid gap-4 md:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get('/stock/disponibilidade');
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Armazém
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
form.setData('warehouse_id', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.warehouses.map((warehouse) => (
|
||||
<option
|
||||
key={warehouse.id}
|
||||
value={warehouse.id}
|
||||
>
|
||||
{warehouse.code} · {warehouse.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Artigo ou código
|
||||
<input
|
||||
className={controlClass}
|
||||
value={form.data.search}
|
||||
onChange={(e) =>
|
||||
form.setData('search', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Consultar</Button>
|
||||
<FormErrors errors={form.errors} />
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel
|
||||
title={props.warehouse?.name ?? 'Disponibilidade'}
|
||||
description={`Consulta em ${props.checkedAt}. Cada quantidade mantém a unidade do artigo.`}
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
As necessidades planeadas são apresentadas
|
||||
separadamente. O compromisso físico e a reserva
|
||||
autorizada de requisições ao armazém ainda não estão
|
||||
implementados; não são considerados zero nem deduzidos
|
||||
como se já existissem.
|
||||
</p>
|
||||
{!props.rows ? (
|
||||
<p>Seleccione o armazém para consultar.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Artigo',
|
||||
'Físico',
|
||||
'Utilizável pelos controlos activos',
|
||||
'Retido/indisponível',
|
||||
'Necessidades planeadas',
|
||||
'Reservado / livre',
|
||||
'Mínimo aprovado',
|
||||
'Situação',
|
||||
].map((label) => (
|
||||
<th
|
||||
className="border-b p-3"
|
||||
key={label}
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.rows.data.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="border-b p-3">
|
||||
{row.code} · {row.name}
|
||||
<p>{row.unit}</p>
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{quantity(
|
||||
row.physical_milli,
|
||||
)}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{quantity(row.usable_milli)}
|
||||
{!row.lot_control && (
|
||||
<p className="text-xs">
|
||||
Sem controlo por
|
||||
lote neste âmbito
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{row.lot_control
|
||||
? quantity(
|
||||
row.blocked_milli,
|
||||
)
|
||||
: 'Sem controlo por lote'}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{props.planningVisible
|
||||
? quantity(
|
||||
row.planned_milli,
|
||||
)
|
||||
: 'Sem acesso ao planeamento'}
|
||||
</td>
|
||||
<td className="border-b p-3">{quantity(row.physical_commitment_milli)} / {quantity(row.free_milli)}</td>
|
||||
<td className="border-b p-3">{quantity(row.minimum_milli)}</td>
|
||||
<td className="border-b p-3">
|
||||
{!row.reconciled
|
||||
? 'Reconciliar repartição dos lotes'
|
||||
: row.usable_milli === 0
|
||||
? 'Sem material utilizável — necessidade de compra'
|
||||
: row.at_minimum
|
||||
? 'No mínimo ou abaixo — necessidade de compra'
|
||||
: row.minimum_milli ===
|
||||
null
|
||||
? 'Mínimo por determinar'
|
||||
: 'Acima do mínimo'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{props.rows.data.length === 0 && (
|
||||
<p>Sem artigos nos filtros seleccionados.</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm">
|
||||
A compra automática usa a parametrização do artigo e armazém. Esta consulta não cria documentos. <Link href="/stock/reposicao">Consultar reposição e necessidades pendentes</Link>.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-4">
|
||||
{props.rows.prev_page_url && (
|
||||
<Link href={props.rows.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{props.rows.next_page_url && (
|
||||
<Link href={props.rows.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Source = {
|
||||
item: { code: string; name: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
lot: { code: string | null } | null;
|
||||
receipt: {
|
||||
number: string;
|
||||
supplier_name: string;
|
||||
order_number: string;
|
||||
} | null;
|
||||
};
|
||||
type RecordEntry = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
kind: string;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
lot_id: number | null;
|
||||
receipt_item_id: number | null;
|
||||
quantity_milli: number;
|
||||
reference: string;
|
||||
stock_operation_id?: number;
|
||||
event?: { event_on: string; evidence: string };
|
||||
};
|
||||
snapshot: {
|
||||
source: Source;
|
||||
contributors: number[];
|
||||
reviewed_by?: number;
|
||||
actor: { name: string };
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
canViewReversals: boolean;
|
||||
correctedOperations?: Record<number, string>;
|
||||
canViewOperations: boolean;
|
||||
ready: boolean;
|
||||
today: string;
|
||||
actorId: number;
|
||||
capabilities: Record<string, boolean>;
|
||||
records: RecordEntry[];
|
||||
history: Record<string, RecordEntry[]>;
|
||||
items: { id: number; code: string; name: string; unit: string }[];
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
receipts: {
|
||||
id: number;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
accepted_milli: number;
|
||||
returned_milli: number;
|
||||
label: string;
|
||||
}[];
|
||||
lots: {
|
||||
id: number;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
code: string | null;
|
||||
quantity_milli: number;
|
||||
receipt_item_id: number | null;
|
||||
reconciled: boolean;
|
||||
}[];
|
||||
};
|
||||
const quantity = (n: number) =>
|
||||
(n / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
const states: Record<string, string> = {
|
||||
draft: 'Em preparação',
|
||||
returned: 'Devolvido para actualização',
|
||||
approved: 'Aprovado para saída física',
|
||||
executed: 'Saída física registada',
|
||||
};
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'save',
|
||||
reason: '',
|
||||
data: {
|
||||
kind: old?.data.kind ?? 'supplier_return',
|
||||
stock_item_id: old ? String(old.data.stock_item_id) : '',
|
||||
warehouse_id: old ? String(old.data.warehouse_id) : '',
|
||||
lot_id: old?.data.lot_id ? String(old.data.lot_id) : '',
|
||||
receipt_item_id: old?.data.receipt_item_id
|
||||
? String(old.data.receipt_item_id)
|
||||
: '',
|
||||
quantity: old ? String(old.data.quantity_milli / 1000) : '',
|
||||
reference: old?.data.reference ?? '',
|
||||
},
|
||||
});
|
||||
const change = (update: Partial<typeof form.data.data>) =>
|
||||
form.setData('data', { ...form.data.data, ...update });
|
||||
const lots = props.lots.filter(
|
||||
(l) =>
|
||||
String(l.stock_item_id) === form.data.data.stock_item_id &&
|
||||
String(l.warehouse_id) === form.data.data.warehouse_id &&
|
||||
(form.data.data.kind !== 'supplier_return' ||
|
||||
String(l.receipt_item_id) === form.data.data.receipt_item_id),
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/stock/ocorrencias');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label className="grid gap-1 text-sm">
|
||||
Operação
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.data.kind}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
kind: e.target.value,
|
||||
lot_id: '',
|
||||
receipt_item_id: '',
|
||||
stock_item_id: '',
|
||||
warehouse_id: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="supplier_return">
|
||||
Devolução ao fornecedor
|
||||
</option>
|
||||
<option value="write_off">Abate físico</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.data.data.kind === 'supplier_return' && (
|
||||
<label className="grid gap-1 text-sm">
|
||||
Recepção de origem
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.receipt_item_id}
|
||||
onChange={(e) => {
|
||||
const source = props.receipts.find(
|
||||
(r) => String(r.id) === e.target.value,
|
||||
);
|
||||
change({
|
||||
receipt_item_id: e.target.value,
|
||||
stock_item_id: source
|
||||
? String(source.stock_item_id)
|
||||
: '',
|
||||
warehouse_id: source
|
||||
? String(source.warehouse_id)
|
||||
: '',
|
||||
lot_id: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccione a recepção aceite</option>
|
||||
{props.receipts.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label} · Devolvível da origem:{' '}
|
||||
{quantity(r.accepted_milli - r.returned_milli)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo
|
||||
<select
|
||||
required
|
||||
disabled={form.data.data.kind === 'supplier_return'}
|
||||
className={controlClass}
|
||||
value={form.data.data.stock_item_id}
|
||||
onChange={(e) =>
|
||||
change({ stock_item_id: e.target.value, lot_id: '' })
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} · {i.name} ({i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Local da saída
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
change({ warehouse_id: e.target.value, lot_id: '' })
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Lote exacto, quando controlado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.data.lot_id}
|
||||
onChange={(e) => change({ lot_id: e.target.value })}
|
||||
>
|
||||
<option value="">Sem lote seleccionado</option>
|
||||
{lots.map((l) => (
|
||||
<option
|
||||
key={l.id}
|
||||
disabled={!l.reconciled}
|
||||
value={l.id}
|
||||
>
|
||||
{l.code ?? `Por identificar #${l.id}`} · Quantidade
|
||||
física: {quantity(l.quantity_milli)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Quantidade a retirar
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
inputMode="decimal"
|
||||
pattern="\d{1,9}(\.\d{1,3})?"
|
||||
value={form.data.data.quantity}
|
||||
onChange={(e) => change({ quantity: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência única do documento de saída
|
||||
<input
|
||||
required
|
||||
maxLength={100}
|
||||
className={controlClass}
|
||||
value={form.data.data.reference}
|
||||
onChange={(e) => change({ reference: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm md:col-span-2">
|
||||
Motivo e evidência da proposta
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Guardar preparação</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordActions({
|
||||
props,
|
||||
record,
|
||||
}: {
|
||||
props: Props;
|
||||
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
|
||||
)
|
||||
choices.push({ value: 'execute', label: 'Confirmar saída física' });
|
||||
return (
|
||||
<InventoryEventActions
|
||||
route="/stock/ocorrencias"
|
||||
record={record}
|
||||
choices={choices}
|
||||
today={props.today}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dispositions(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Devoluções e abates" />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém"
|
||||
title="Devoluções e abates"
|
||||
description="Preparar, conferir e comprovar a saída de material, preservando as suas origens."
|
||||
/>
|
||||
{props.canViewReversals && (
|
||||
<Link href="/stock/estornos" className="text-sm underline">
|
||||
Estornos de stock ligados ao original
|
||||
</Link>
|
||||
)}
|
||||
<Panel title="Responsabilidades separadas">
|
||||
<p className="text-sm">
|
||||
A preparação reutiliza a recepção ou o lote existente.
|
||||
Outra pessoa aprova; Armazém comprova a saída, por
|
||||
pessoa diferente de quem aprovou. Material em quarentena
|
||||
ou vencido pode sair por estes percursos sem ser
|
||||
considerado consumo.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A devolução ou abate físico não cria crédito,
|
||||
restituição, despesa ou pagamento. Facturas e obrigações
|
||||
já validadas conservam o histórico e precisam da
|
||||
regularização financeira correspondente. Novas facturas
|
||||
consideram as quantidades já devolvidas.
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>
|
||||
O diário de stock ainda não está instalado neste
|
||||
ambiente.
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
<Panel title="Preparar saída">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((record) => (
|
||||
<Panel
|
||||
key={record.id}
|
||||
title={`${record.data.reference} · ${record.data.kind === 'supplier_return' ? 'Devolução ao fornecedor' : 'Abate físico'}`}
|
||||
description={
|
||||
record.data.stock_operation_id &&
|
||||
props.correctedOperations?.[
|
||||
record.data.stock_operation_id
|
||||
]
|
||||
? `Saída estornada por ${props.correctedOperations[record.data.stock_operation_id]}`
|
||||
: states[record.state]
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{record.snapshot.source.item.code} ·{' '}
|
||||
{record.snapshot.source.item.name} ·{' '}
|
||||
{quantity(record.data.quantity_milli)}{' '}
|
||||
{record.snapshot.source.item.unit} ·{' '}
|
||||
{record.snapshot.source.warehouse.name}
|
||||
</p>
|
||||
{record.snapshot.source.receipt && (
|
||||
<p className="mt-2 text-sm">
|
||||
Origem:{' '}
|
||||
{record.snapshot.source.receipt.number}{' '}
|
||||
·{' '}
|
||||
{
|
||||
record.snapshot.source.receipt
|
||||
.order_number
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
record.snapshot.source.receipt
|
||||
.supplier_name
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{record.snapshot.source.lot && (
|
||||
<p className="mt-2 text-sm">
|
||||
Lote:{' '}
|
||||
{record.snapshot.source.lot.code ??
|
||||
`Por identificar #${record.data.lot_id}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{record.reason}
|
||||
</p>
|
||||
{props.capabilities.prepare &&
|
||||
['draft', 'returned'].includes(
|
||||
record.state,
|
||||
) && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Actualizar preparação
|
||||
</summary>
|
||||
<Prepare
|
||||
props={props}
|
||||
old={record}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
<RecordActions props={props} record={record} />
|
||||
{record.state === 'executed' && (
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
Saída em {record.data.event?.event_on} ·{' '}
|
||||
{record.data.event?.evidence}.
|
||||
Regularização financeira/contabilística
|
||||
não concluída por este registo.{' '}
|
||||
{props.canViewOperations && (
|
||||
<Link
|
||||
href="/stock/operacoes"
|
||||
className="underline"
|
||||
>
|
||||
Consultar movimentos
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<details className="mt-4 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Histórico preservado
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{props.history[record.record_key]?.map(
|
||||
(version) => (
|
||||
<li key={version.id}>
|
||||
Versão {version.version} ·{' '}
|
||||
{states[version.state]} ·{' '}
|
||||
{
|
||||
version.snapshot.actor
|
||||
.name
|
||||
}{' '}
|
||||
· {version.created_at} ·{' '}
|
||||
{version.reason}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ type Props = {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
quantity_milli: number;
|
||||
lot_control?: {
|
||||
usable_milli: number;
|
||||
blocked_milli: number;
|
||||
reconciled: boolean;
|
||||
} | null;
|
||||
}[];
|
||||
movements: Page<StockMovement>;
|
||||
filters: { search?: string; warehouse_id?: number };
|
||||
@@ -139,6 +144,14 @@ export default function Stock({
|
||||
balance.quantity_milli,
|
||||
)}{' '}
|
||||
{article?.unit}
|
||||
{balance.lot_control && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{balance.lot_control
|
||||
.reconciled
|
||||
? `Utilizável: ${quantity(balance.lot_control.usable_milli)} · Indisponível: ${quantity(balance.lot_control.blocked_milli)}`
|
||||
: 'Repartição por lotes por conferir'}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Metadata = {
|
||||
code: string | null;
|
||||
expires_on: string | null;
|
||||
no_expiry: boolean;
|
||||
quantity_milli: number;
|
||||
};
|
||||
type RecordEntry = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
kind: string;
|
||||
state: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
requires_expiry: boolean;
|
||||
minimum_remaining_days: number;
|
||||
quarantine_on_receipt?: boolean;
|
||||
lots: Metadata[];
|
||||
};
|
||||
snapshot: {
|
||||
contributors: number[];
|
||||
actor: { name: string };
|
||||
item: { code: string; name: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
balance: { quantity: number; version: number };
|
||||
previous_setup_id?: number | null;
|
||||
impact?: { before_milli: number; after_milli: number } | null;
|
||||
};
|
||||
};
|
||||
type Lot = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
metadata_id: number;
|
||||
data: Metadata;
|
||||
quantity_milli: number;
|
||||
usable_milli: number;
|
||||
eligible: boolean;
|
||||
state: string;
|
||||
state_version: number;
|
||||
contributors: number[];
|
||||
last_usable_on: string | null;
|
||||
release_mode?: string;
|
||||
release_setup_id?: number | null;
|
||||
};
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
capabilities: Record<string, boolean>;
|
||||
records: RecordEntry[];
|
||||
contexts: Record<
|
||||
string,
|
||||
{
|
||||
reconciled: boolean;
|
||||
physical: { quantity: number };
|
||||
usable_milli: number;
|
||||
blocked_milli: number;
|
||||
lots: Lot[];
|
||||
}
|
||||
>;
|
||||
items: { id: number; code: string; name: string; unit: string }[];
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
history: RecordEntry[];
|
||||
};
|
||||
const qty = (n: number) =>
|
||||
(n / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
const states: Record<string, string> = {
|
||||
held: 'Em quarentena',
|
||||
released: 'Libertado',
|
||||
expired: 'Validade insuficiente',
|
||||
unidentified: 'Identificação incompleta',
|
||||
active: 'Activo',
|
||||
draft: 'Para conferência',
|
||||
registered: 'Identificação registada',
|
||||
};
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: RecordEntry }) {
|
||||
const revision =
|
||||
old?.state === 'active' || !!old?.snapshot.previous_setup_id;
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'prepare',
|
||||
reason: '',
|
||||
data: {
|
||||
stock_item_id: old ? String(old.data.stock_item_id) : '',
|
||||
warehouse_id: old ? String(old.data.warehouse_id) : '',
|
||||
requires_expiry: old?.data.requires_expiry ?? true,
|
||||
quarantine_on_receipt: old?.data.quarantine_on_receipt ?? true,
|
||||
minimum_remaining_days: String(
|
||||
old?.data.minimum_remaining_days ?? 0,
|
||||
),
|
||||
lots: revision
|
||||
? []
|
||||
: (old?.data.lots.map((l) => ({
|
||||
code: l.code ?? '',
|
||||
expires_on: l.expires_on ?? '',
|
||||
no_expiry: l.no_expiry,
|
||||
quantity: String(l.quantity_milli / 1000),
|
||||
})) ?? []),
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/stock/lotes');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.stock_item_id}
|
||||
disabled={revision}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
stock_item_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} · {i.name} ({i.unit})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Armazém
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.warehouse_id}
|
||||
disabled={revision}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
warehouse_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Dias mínimos de validade restante
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
max={3650}
|
||||
className={controlClass}
|
||||
value={form.data.data.minimum_remaining_days}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
minimum_remaining_days: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.data.requires_expiry}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
requires_expiry: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Este artigo exige prazo de validade
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.data.quarantine_on_receipt}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
quarantine_on_receipt: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Exigir quarentena nas novas recepções
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dispensar quarentena exige aprovação independente desta
|
||||
configuração. Aplica-se apenas a futuras recepções conformes,
|
||||
identificadas e com validade suficiente. Reservas de qualidade,
|
||||
transferências, saldo inicial e quantidades encontradas em
|
||||
contagem continuam em quarentena.
|
||||
</p>
|
||||
{revision ? (
|
||||
<p className="text-sm">
|
||||
A revisão reutiliza a configuração activa e não cria saldo
|
||||
nem altera movimentos. Validade exigida e dias mínimos
|
||||
passam a determinar a disponibilidade actual; a regra de
|
||||
quarentena aplica-se às novas recepções.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O saldo actual é reutilizado. Sem repartição, será
|
||||
criado um lote por identificar com a totalidade do
|
||||
saldo. Um só lote dispensa repetir a quantidade; vários
|
||||
lotes exigem repartição exacta.
|
||||
</p>
|
||||
{form.data.data.lots.map((lot, i) => (
|
||||
<fieldset
|
||||
key={i}
|
||||
className="grid gap-3 rounded border p-3 md:grid-cols-4"
|
||||
>
|
||||
<legend>Lote {i + 1}</legend>
|
||||
{(['code', 'expires_on', 'quantity'] as const).map(
|
||||
(key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="grid gap-1 text-sm"
|
||||
>
|
||||
{
|
||||
{
|
||||
code: 'Código',
|
||||
expires_on: 'Validade',
|
||||
quantity: 'Quantidade',
|
||||
}[key]
|
||||
}
|
||||
<input
|
||||
className={controlClass}
|
||||
type={
|
||||
key === 'expires_on'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={lot[key]}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
lots: form.data.data.lots.map(
|
||||
(l, index) =>
|
||||
index === i
|
||||
? {
|
||||
...l,
|
||||
[key]: e
|
||||
.target
|
||||
.value,
|
||||
}
|
||||
: l,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lot.no_expiry}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
lots: form.data.data.lots.map(
|
||||
(l, index) =>
|
||||
index === i
|
||||
? {
|
||||
...l,
|
||||
no_expiry:
|
||||
e.target
|
||||
.checked,
|
||||
}
|
||||
: l,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Sem prazo
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
lots: form.data.data.lots.filter(
|
||||
(_, index) => index !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remover repartição
|
||||
</Button>
|
||||
</fieldset>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
lots: [
|
||||
...form.data.data.lots,
|
||||
{
|
||||
code: '',
|
||||
expires_on: '',
|
||||
no_expiry: false,
|
||||
quantity: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Acrescentar lote ao saldo existente
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Evidência da conferência
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Guardar preparação</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Decision({
|
||||
record,
|
||||
lot,
|
||||
action,
|
||||
}: {
|
||||
record: { record_key: string; version: number };
|
||||
lot?: Lot & { warehouse_id: number };
|
||||
action: 'activate' | 'hold' | 'release';
|
||||
}) {
|
||||
return (
|
||||
<InventoryEventActions
|
||||
route="/stock/lotes"
|
||||
record={record}
|
||||
choices={[
|
||||
{
|
||||
value: action,
|
||||
label:
|
||||
action === 'activate'
|
||||
? 'Conferir e activar'
|
||||
: action === 'hold'
|
||||
? 'Colocar em quarentena'
|
||||
: 'Conferir e libertar',
|
||||
},
|
||||
]}
|
||||
data={
|
||||
lot
|
||||
? {
|
||||
lot_id: lot.id,
|
||||
warehouse_id: lot.warehouse_id,
|
||||
state_version: lot.state_version,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function Identify({ lot }: { lot: Lot }) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: lot.record_key,
|
||||
version: lot.version,
|
||||
action: 'identify',
|
||||
reason: '',
|
||||
data: {
|
||||
code: lot.data.code ?? '',
|
||||
expires_on: lot.data.expires_on ?? '',
|
||||
no_expiry: lot.data.no_expiry,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Identificar ou corrigir dados do lote
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 grid gap-3 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/stock/lotes');
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground md:col-span-2">
|
||||
A correcção preserva o histórico e exige nova libertação
|
||||
independente em todos os locais.
|
||||
</p>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label className="grid gap-1 text-sm">
|
||||
Código do lote
|
||||
<input
|
||||
className={controlClass}
|
||||
value={form.data.data.code}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
code: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Validade
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.data.expires_on}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
expires_on: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.data.no_expiry}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
no_expiry: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Confirmo que o artigo não tem prazo de validade
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Motivo / evidência
|
||||
<input
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>
|
||||
Registar identificação
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Lots(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Lotes, validade e quarentena" />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém"
|
||||
title="Lotes, validade e quarentena"
|
||||
description="O saldo físico mantém a sua origem. A qualidade determina a quantidade utilizável."
|
||||
/>
|
||||
<Panel title="Antes de activar">
|
||||
<p className="text-sm">
|
||||
A activação é por artigo e armazém e exige conferência
|
||||
por outra pessoa. O saldo inicial entra sempre em
|
||||
quarentena. Novas recepções seguem a regra de quarentena
|
||||
aprovada. As saídas escolhem automaticamente os lotes
|
||||
libertados com validade mais próxima.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Transferências exigem controlo activo nos dois armazéns.
|
||||
A chegada coloca o lote inteiro no destino em
|
||||
quarentena, incluindo quantidades desse lote já
|
||||
existentes no local. Ajustes por lote reutilizam uma
|
||||
contagem com conferência independente. Os parâmetros
|
||||
podem ser revistos com impacto conferido. A previsão
|
||||
exclui entradas futuras sem qualificação do lote.
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>
|
||||
O controlo por lotes ainda não está instalado neste
|
||||
ambiente.
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
<Panel title="Preparar novo controlo">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((record) => {
|
||||
const context = props.contexts[record.record_key];
|
||||
return (
|
||||
<Panel
|
||||
key={record.id}
|
||||
title={`${record.snapshot.item.code} · ${record.snapshot.item.name} · ${record.snapshot.warehouse.name}`}
|
||||
description={
|
||||
states[record.state] ?? record.state
|
||||
}
|
||||
>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Validade{' '}
|
||||
{record.data.requires_expiry
|
||||
? 'obrigatória'
|
||||
: 'ou confirmação explícita de artigo sem prazo'}{' '}
|
||||
· Mínimo restante:{' '}
|
||||
{record.data.minimum_remaining_days}{' '}
|
||||
dias · Novas recepções:{' '}
|
||||
{(record.data.quarantine_on_receipt ??
|
||||
true)
|
||||
? 'quarentena obrigatória'
|
||||
: 'dispensa apenas quando conformes e completamente identificadas'}
|
||||
.
|
||||
</p>
|
||||
{record.snapshot.impact &&
|
||||
record.state === 'draft' && (
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
Impacto conferido da revisão:
|
||||
utilizável actual{' '}
|
||||
{qty(
|
||||
record.snapshot.impact
|
||||
.before_milli,
|
||||
)}{' '}
|
||||
→{' '}
|
||||
{qty(
|
||||
record.snapshot.impact
|
||||
.after_milli,
|
||||
)}{' '}
|
||||
{record.snapshot.item.unit}.
|
||||
Mudanças no saldo ou qualidade
|
||||
exigem actualizar esta
|
||||
preparação.
|
||||
</p>
|
||||
)}
|
||||
{record.state === 'draft' ? (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Saldo conferido:{' '}
|
||||
{qty(
|
||||
record.snapshot.balance
|
||||
.quantity,
|
||||
)}{' '}
|
||||
{record.snapshot.item.unit}.
|
||||
{record.snapshot
|
||||
.previous_setup_id
|
||||
? 'A revisão não volta a repartir o saldo; activa apenas os parâmetros conferidos.'
|
||||
: 'Todo o saldo ficará em quarentena após activação.'}
|
||||
</p>
|
||||
{record.data.lots.map((lot, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="mt-2 text-sm"
|
||||
>
|
||||
{lot.code ??
|
||||
'Por identificar'}{' '}
|
||||
·{' '}
|
||||
{lot.expires_on ??
|
||||
(lot.no_expiry
|
||||
? 'Sem prazo confirmado'
|
||||
: 'Validade por confirmar')}{' '}
|
||||
· {qty(lot.quantity_milli)}
|
||||
</p>
|
||||
))}
|
||||
{props.capabilities.prepare && (
|
||||
<details className="mt-3">
|
||||
<summary>
|
||||
Actualizar preparação
|
||||
após nova conferência
|
||||
</summary>
|
||||
<Prepare
|
||||
props={props}
|
||||
old={record}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
{props.capabilities.review &&
|
||||
!record.snapshot.contributors.includes(
|
||||
props.actorId,
|
||||
) && (
|
||||
<Decision
|
||||
record={record}
|
||||
action="activate"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
context && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Físico:{' '}
|
||||
{qty(
|
||||
context.physical
|
||||
.quantity,
|
||||
)}{' '}
|
||||
· Utilizável:{' '}
|
||||
{qty(context.usable_milli)}{' '}
|
||||
· Indisponível:{' '}
|
||||
{qty(context.blocked_milli)}{' '}
|
||||
{record.snapshot.item.unit}
|
||||
</p>
|
||||
{!context.reconciled && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 text-destructive"
|
||||
>
|
||||
Repartição divergente.
|
||||
Confira as origens antes
|
||||
de operar.
|
||||
</p>
|
||||
)}
|
||||
{props.capabilities.prepare &&
|
||||
!props.records.some(
|
||||
(r) =>
|
||||
r.record_key ===
|
||||
record.record_key &&
|
||||
r.state === 'draft',
|
||||
) && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Rever parâmetros
|
||||
por referência a
|
||||
esta
|
||||
configuração
|
||||
</summary>
|
||||
<Prepare
|
||||
props={props}
|
||||
old={record}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
{context.lots.map((lot) => (
|
||||
<article
|
||||
key={lot.id}
|
||||
className="mt-4 rounded-lg border p-4"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{lot.data.code ??
|
||||
`Lote por identificar #${lot.id}`}{' '}
|
||||
·{' '}
|
||||
{states[lot.state]}
|
||||
</p>
|
||||
{lot.state ===
|
||||
'released' &&
|
||||
lot.release_mode ===
|
||||
'receipt_policy' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dispensa de
|
||||
quarentena
|
||||
pela
|
||||
configuração
|
||||
aprovada #
|
||||
{
|
||||
lot.release_setup_id
|
||||
}
|
||||
. Não
|
||||
corresponde
|
||||
a uma
|
||||
conferência
|
||||
manual deste
|
||||
lote.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{qty(
|
||||
lot.quantity_milli,
|
||||
)}{' '}
|
||||
{
|
||||
record.snapshot
|
||||
.item.unit
|
||||
}{' '}
|
||||
· Validade:{' '}
|
||||
{lot.data
|
||||
.expires_on ??
|
||||
(lot.data
|
||||
.no_expiry
|
||||
? 'Sem prazo confirmado'
|
||||
: 'Por confirmar')}
|
||||
{lot.last_usable_on &&
|
||||
` · Último dia utilizável: ${lot.last_usable_on}`}
|
||||
</p>
|
||||
{props.capabilities
|
||||
.prepare && (
|
||||
<Identify
|
||||
key={`identify-${lot.version}`}
|
||||
lot={lot}
|
||||
/>
|
||||
)}
|
||||
{context.reconciled &&
|
||||
lot.quantity_milli >
|
||||
0 && (
|
||||
<>
|
||||
{props
|
||||
.capabilities
|
||||
.release &&
|
||||
lot.eligible &&
|
||||
lot.state !==
|
||||
'released' &&
|
||||
!lot.contributors.includes(
|
||||
props.actorId,
|
||||
) && (
|
||||
<Decision
|
||||
key={`release-${lot.version}-${lot.state_version}`}
|
||||
record={
|
||||
lot
|
||||
}
|
||||
lot={{
|
||||
...lot,
|
||||
warehouse_id:
|
||||
record
|
||||
.data
|
||||
.warehouse_id,
|
||||
}}
|
||||
action="release"
|
||||
/>
|
||||
)}
|
||||
{props
|
||||
.capabilities
|
||||
.hold &&
|
||||
lot.state ===
|
||||
'released' && (
|
||||
<Decision
|
||||
key={`hold-${lot.version}-${lot.state_version}`}
|
||||
record={
|
||||
lot
|
||||
}
|
||||
lot={{
|
||||
...lot,
|
||||
warehouse_id:
|
||||
record
|
||||
.data
|
||||
.warehouse_id,
|
||||
}}
|
||||
action="hold"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
<Panel title="Histórico de conferências">
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Consultar os últimos 200 registos
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{props.history.map((record) => (
|
||||
<p key={record.id} className="text-sm">
|
||||
#{record.id} ·{' '}
|
||||
{states[record.state] ??
|
||||
record.state}{' '}
|
||||
· {record.snapshot.actor.name} ·{' '}
|
||||
{record.created_at} ·{' '}
|
||||
{record.reason}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||||
import { LotCountFields, countTotal } from '@/components/inventory-lot-count';
|
||||
import type {
|
||||
CountInput,
|
||||
LotCountSource,
|
||||
} from '@/components/inventory-lot-count';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
@@ -30,6 +35,43 @@ type Operation = {
|
||||
warehouse: { name: string };
|
||||
destination_warehouse?: { name: string } | null;
|
||||
equipment?: { code: string; description: string } | null;
|
||||
reversal?: {
|
||||
source: {
|
||||
document: { reference: string };
|
||||
rows: {
|
||||
source_movement_id: number;
|
||||
warehouse_name: string;
|
||||
quantity_milli: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
lot_count?: {
|
||||
rows: {
|
||||
lot_id: number | null;
|
||||
code: string | null;
|
||||
previous_milli: number;
|
||||
counted_milli: number;
|
||||
delta_milli: number;
|
||||
}[];
|
||||
};
|
||||
lot_adjustment?: {
|
||||
rows: {
|
||||
lot_id: number | null;
|
||||
code: string | null;
|
||||
previous_milli: number;
|
||||
counted_milli: number;
|
||||
delta_milli: number;
|
||||
}[];
|
||||
};
|
||||
lot_partition?: {
|
||||
plan: {
|
||||
lot_id: number;
|
||||
quantity: number;
|
||||
code: string | null;
|
||||
expires_on: string | null;
|
||||
metadata_id: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
@@ -41,10 +83,18 @@ type Props = {
|
||||
warehouse_id: number;
|
||||
quantity: number;
|
||||
version: number;
|
||||
lot_control?: {
|
||||
usable_milli: number;
|
||||
blocked_milli: number;
|
||||
reconciled: boolean;
|
||||
} | null;
|
||||
}[];
|
||||
operations: Page<Operation>;
|
||||
counts: Operation[];
|
||||
canOperate: boolean;
|
||||
correctedOperations?: Record<number, string>;
|
||||
canAdjust?: boolean;
|
||||
lotCounts?: LotCountSource[];
|
||||
demo?: boolean;
|
||||
equipment?: { id: number; code: string; description: string }[];
|
||||
};
|
||||
@@ -62,12 +112,15 @@ export default function Operations({
|
||||
operations,
|
||||
counts,
|
||||
canOperate,
|
||||
correctedOperations = {},
|
||||
canAdjust = false,
|
||||
lotCounts = [],
|
||||
demo = false,
|
||||
equipment = [],
|
||||
}: Props) {
|
||||
const [message, setMessage] = useState('');
|
||||
const form = useForm({
|
||||
kind: 'issue',
|
||||
kind: canOperate ? 'issue' : 'adjustment',
|
||||
reference: '',
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
stock_item_id: String(items[0]?.id ?? ''),
|
||||
@@ -86,6 +139,28 @@ export default function Operations({
|
||||
String(b.stock_item_id) === form.data.stock_item_id &&
|
||||
String(b.warehouse_id) === form.data.warehouse_id,
|
||||
);
|
||||
const countSource = lotCounts.find(
|
||||
(s) =>
|
||||
String(s.stock_item_id) === form.data.stock_item_id &&
|
||||
String(s.warehouse_id) === form.data.warehouse_id,
|
||||
);
|
||||
const [countInputs, setCountInputs] = useState<
|
||||
Record<string, CountInput[]>
|
||||
>({});
|
||||
const countKey = countSource?.hash ?? '';
|
||||
const countRows =
|
||||
countInputs[countKey] ??
|
||||
countSource?.lots
|
||||
.filter((lot) => lot.quantity_milli > 0)
|
||||
.map((lot) => ({
|
||||
lot_id: lot.lot_id,
|
||||
new_key: null,
|
||||
quantity: '',
|
||||
code: lot.code ?? '',
|
||||
expires_on: lot.expires_on ?? '',
|
||||
no_expiry: lot.no_expiry,
|
||||
})) ??
|
||||
[];
|
||||
return (
|
||||
<>
|
||||
<Head title="Operações de stock" />
|
||||
@@ -124,9 +199,9 @@ export default function Operations({
|
||||
)}
|
||||
<Panel
|
||||
title="Registar operação"
|
||||
description="Só Recepção / Armazém executa movimentos. Administração, Procurement e Finanças consultam. Periodicidade, tolerâncias e investigação de divergências aguardam decisão da empresa."
|
||||
description="Armazém regista as contagens. Ajustes por lote exigem pessoa independente com competência própria de conferência."
|
||||
>
|
||||
{canOperate ? (
|
||||
{canOperate || canAdjust ? (
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
@@ -140,6 +215,15 @@ export default function Operations({
|
||||
form.transform((d) => ({
|
||||
...d,
|
||||
ledger_version: balance?.version ?? 0,
|
||||
...(d.kind === 'count' && countSource
|
||||
? {
|
||||
lot_version: countSource.hash,
|
||||
lot_counts: countRows,
|
||||
quantity:
|
||||
countTotal(countRows) ??
|
||||
'0.000',
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
form.post('/stock/operacoes', {
|
||||
onSuccess: () => {
|
||||
@@ -164,11 +248,17 @@ export default function Operations({
|
||||
form.setData('kind', e.target.value)
|
||||
}
|
||||
>
|
||||
{Object.entries(kinds).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
{Object.entries(kinds)
|
||||
.filter(
|
||||
([k]) =>
|
||||
canOperate ||
|
||||
k === 'adjustment',
|
||||
)
|
||||
.map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm">
|
||||
@@ -229,6 +319,19 @@ export default function Operations({
|
||||
física sobre este saldo; movimentos posteriores
|
||||
exigem nova contagem.
|
||||
</p>
|
||||
{balance?.lot_control && (
|
||||
<p className="text-sm sm:col-span-2">
|
||||
{balance.lot_control.reconciled
|
||||
? `Controlo por lotes · Utilizável: ${quantity(balance.lot_control.usable_milli)} · Indisponível: ${quantity(balance.lot_control.blocked_milli)}. As saídas usam primeiro a validade mais próxima.`
|
||||
: 'Repartição de lotes divergente. Confira as origens antes de operar.'}{' '}
|
||||
Transferências exigem controlo nos dois
|
||||
armazéns e colocam o lote inteiro no destino
|
||||
em quarentena, incluindo o saldo desse lote
|
||||
já existente no local. Ajustes por lote
|
||||
reutilizam uma contagem e exigem conferência
|
||||
independente.
|
||||
</p>
|
||||
)}
|
||||
{form.data.kind === 'transfer' && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Armazém de destino
|
||||
@@ -294,6 +397,11 @@ export default function Operations({
|
||||
Seleccionar contagem
|
||||
</option>
|
||||
{counts
|
||||
.filter(
|
||||
(c) =>
|
||||
canOperate ||
|
||||
!!c.snapshot.lot_count,
|
||||
)
|
||||
.filter(
|
||||
(c) =>
|
||||
String(c.warehouse_id) ===
|
||||
@@ -314,6 +422,17 @@ export default function Operations({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : form.data.kind === 'count' && countSource ? (
|
||||
<LotCountFields
|
||||
source={countSource}
|
||||
rows={countRows}
|
||||
setRows={(rows) =>
|
||||
setCountInputs({
|
||||
...countInputs,
|
||||
[countKey]: rows,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<label className="grid gap-2 text-sm">
|
||||
{form.data.kind === 'count'
|
||||
@@ -372,7 +491,14 @@ export default function Operations({
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
<FormErrors errors={form.errors} />
|
||||
<Button disabled={form.processing}>
|
||||
<Button
|
||||
disabled={
|
||||
form.processing ||
|
||||
(form.data.kind === 'adjustment' &&
|
||||
!!balance?.lot_control &&
|
||||
!canAdjust)
|
||||
}
|
||||
>
|
||||
{demo
|
||||
? 'Rever simulação'
|
||||
: 'Confirmar operação'}
|
||||
@@ -401,7 +527,15 @@ export default function Operations({
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<strong>
|
||||
{o.reference} · {kinds[o.kind]}
|
||||
{o.reference} ·{' '}
|
||||
{kinds[o.kind] ??
|
||||
{
|
||||
supplier_return:
|
||||
'Devolução ao fornecedor',
|
||||
write_off: 'Abate físico',
|
||||
reversal: 'Estorno integral',
|
||||
}[o.kind] ??
|
||||
o.kind}
|
||||
</strong>
|
||||
<time>
|
||||
{new Date(o.recorded_at).toLocaleString(
|
||||
@@ -411,9 +545,10 @@ export default function Operations({
|
||||
</div>
|
||||
<p className="mt-2">
|
||||
{o.snapshot.stock_item.name} ·{' '}
|
||||
{quantity(o.quantity_milli)}{' '}
|
||||
{o.snapshot.stock_item.unit} ·{' '}
|
||||
{o.snapshot.warehouse.name}
|
||||
{o.kind === 'reversal'
|
||||
? `${o.snapshot.reversal?.source.rows.length ?? 0} movimentos corrigidos`
|
||||
: `${quantity(o.quantity_milli)} ${o.snapshot.stock_item.unit}`}{' '}
|
||||
· {o.snapshot.warehouse.name}
|
||||
{o.snapshot.destination_warehouse &&
|
||||
` → ${o.snapshot.destination_warehouse.name}`}
|
||||
{o.destination && ` → ${o.destination}`}
|
||||
@@ -431,6 +566,115 @@ export default function Operations({
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">{o.reason}</p>
|
||||
{correctedOperations[o.id] && (
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
Operação estornada integralmente por{' '}
|
||||
{correctedOperations[o.id]}. O original
|
||||
permanece no histórico.
|
||||
</p>
|
||||
)}
|
||||
{o.snapshot.reversal && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p>
|
||||
Origem:{' '}
|
||||
{
|
||||
o.snapshot.reversal.source
|
||||
.document.reference
|
||||
}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{o.snapshot.reversal.source.rows.map(
|
||||
(row) => (
|
||||
<li
|
||||
key={
|
||||
row.source_movement_id
|
||||
}
|
||||
>
|
||||
{row.warehouse_name} ·{' '}
|
||||
{row.quantity_milli > 0
|
||||
? '+'
|
||||
: ''}
|
||||
{quantity(
|
||||
row.quantity_milli,
|
||||
)}{' '}
|
||||
{
|
||||
o.snapshot
|
||||
.stock_item.unit
|
||||
}{' '}
|
||||
· Corrige movimento #
|
||||
{row.source_movement_id}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(o.snapshot.lot_count ??
|
||||
o.snapshot.lot_adjustment) && (
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Conferência por lote
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{(
|
||||
o.snapshot.lot_count ??
|
||||
o.snapshot.lot_adjustment
|
||||
)?.rows.map((row, index) => (
|
||||
<li key={row.lot_id ?? index}>
|
||||
{row.code ??
|
||||
'Por identificar'}{' '}
|
||||
· Referência:{' '}
|
||||
{quantity(
|
||||
row.previous_milli,
|
||||
)}{' '}
|
||||
· Contado:{' '}
|
||||
{quantity(
|
||||
row.counted_milli,
|
||||
)}{' '}
|
||||
· Diferença:{' '}
|
||||
{quantity(row.delta_milli)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{o.snapshot.lot_partition && (
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Lotes usados nesta operação
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{o.snapshot.lot_partition.plan.map(
|
||||
(lot) => (
|
||||
<li key={lot.lot_id}>
|
||||
{lot.code ??
|
||||
`Lote #${lot.lot_id}`}{' '}
|
||||
·{' '}
|
||||
{quantity(lot.quantity)}{' '}
|
||||
{
|
||||
o.snapshot
|
||||
.stock_item.unit
|
||||
}{' '}
|
||||
· Validade:{' '}
|
||||
{lot.expires_on ??
|
||||
'Sem prazo confirmado'}{' '}
|
||||
· Identificação #
|
||||
{lot.metadata_id}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
{o.kind === 'transfer' && (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
A entrada colocou o lote inteiro
|
||||
no destino em quarentena.
|
||||
Consulte a conferência actual de
|
||||
lotes para conhecer a
|
||||
disponibilidade.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{o.snapshot.equipment && (
|
||||
<p className="mt-2">
|
||||
Equipamento no momento da saída:{' '}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { PageTitle, Panel, FormErrors, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
type RecordRow = { id:number; record_key:string; version:number; state:string; reason:string; created_at:string; data:{ policy_key?:string; requester_id?:number; cost_center_id?:number; unit_price?:string; tax_rate?:string; needed_days?:number; valid_until?:string; message?:string; purchases?:{id:number;number:string;quantity_milli:number}[] }; snapshot:{item:{id:number;code?:string;name?:string};warehouse:{id:number;name?:string};requester?:{name:string};cost_center?:{code:string;name:string};origins?:{warehouse_request_id:number;destination:string;equipment:{code:string;description:string}|null;remaining_milli:number}[]} };
|
||||
type Props={ready:boolean;canPrepare:boolean;canReview:boolean;rules:RecordRow[];needs:RecordRow[];history:RecordRow[];policies:RecordRow[];requesters:{id:number;name:string;department_id:number}[];centers:{id:number;code:string;name:string;department_id:number}[]};
|
||||
const states:Record<string,string>={draft:'Por activar',approved:'Activa',suspended:'Suspensa',pending:'Necessidade pendente',created:'Rascunho criado',covered:'Reposição em curso',satisfied:'Disponibilidade suficiente'};
|
||||
function RuleForm({props,rule}:{props:Props;rule?:RecordRow}) {
|
||||
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule?.record_key??null,version:rule?.version??0,action:'save',reason:'',data:{policy_key:rule?.data.policy_key??'',requester_id:rule?.data.requester_id?.toString()??'',cost_center_id:rule?.data.cost_center_id?.toString()??'',unit_price:rule?.data.unit_price??'',tax_rate:rule?.data.tax_rate??'',needed_days:rule?.data.needed_days?.toString()??'',valid_until:rule?.data.valid_until??''}});
|
||||
const requester=props.requesters.find(u=>u.id.toString()===form.data.data.requester_id);
|
||||
const set=(key:keyof typeof form.data.data,value:string)=>form.setData('data',{...form.data.data,[key]:value,...(key==='requester_id'?{cost_center_id:''}:{})});
|
||||
return <form className="grid gap-3 md:grid-cols-2" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/>
|
||||
<label>Política aprovada · artigo/armazém<select required className={controlClass} value={form.data.data.policy_key} onChange={e=>set('policy_key',e.target.value)}><option value="">Seleccione</option>{props.policies.map(p=><option key={p.record_key} value={p.record_key}>{p.snapshot.item.code} · {p.snapshot.item.name} · {p.snapshot.warehouse.name}</option>)}</select></label>
|
||||
<label>Requisitante responsável<select required className={controlClass} value={form.data.data.requester_id} onChange={e=>set('requester_id',e.target.value)}><option value="">Seleccione</option>{props.requesters.map(u=><option key={u.id} value={u.id}>{u.name}</option>)}</select></label>
|
||||
<label>Centro de custo<select required className={controlClass} value={form.data.data.cost_center_id} onChange={e=>set('cost_center_id',e.target.value)}><option value="">Seleccione</option>{props.centers.filter(c=>c.department_id===requester?.department_id).map(c=><option key={c.id} value={c.id}>{c.code} · {c.name}</option>)}</select></label>
|
||||
<label>Preço estimado (MZN), se não existir no acordo<input inputMode="decimal" placeholder="Vazio: reutilizar acordo em MZN" className={controlClass} value={form.data.data.unit_price} onChange={e=>set('unit_price',e.target.value)}/></label>
|
||||
<label>Imposto estimado (%), se diferente do parâmetro da empresa<input inputMode="decimal" placeholder="Vazio: reutilizar parâmetro da empresa" className={controlClass} value={form.data.data.tax_rate} onChange={e=>set('tax_rate',e.target.value)}/></label>
|
||||
<label>Prazo pretendido após geração (dias)<input required type="number" min="0" max="366" className={controlClass} value={form.data.data.needed_days} onChange={e=>set('needed_days',e.target.value)}/></label>
|
||||
<label>Validade desta parametrização e estimativa<input required type="date" className={controlClass} value={form.data.data.valid_until} onChange={e=>set('valid_until',e.target.value)}/></label>
|
||||
<label>Fundamento<textarea required minLength={10} className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/></label>
|
||||
<p className="text-sm md:col-span-2">Mínimo, alvo, máximo e embalagem são reutilizados da política aprovada. A activação exige outro responsável. Alterar uma regra exige nova activação e afecta apenas novas gerações; documentos existentes conservam a sua origem.</p><Button disabled={form.processing}>Guardar para activação</Button></form>;
|
||||
}
|
||||
function Decision({rule,action}:{rule:RecordRow;action:'approve'|'suspend'}) {
|
||||
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule.record_key,version:rule.version,action,reason:'',data:{}});
|
||||
return <form className="mt-3 flex flex-wrap gap-3" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/><input aria-label="Fundamento da decisão" required minLength={10} placeholder="Fundamento da decisão" className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/><Button disabled={form.processing}>{action==='approve'?'Activar parametrização':'Suspender parametrização'}</Button></form>;
|
||||
}
|
||||
export default function Replenishment(props:Props){return <><Head title="Reposição automática"/><div className="space-y-6 p-6"><PageTitle eyebrow="Armazém e compras" title="Reposição automática" description="Reutilizar a política aprovada e preparar a compra quando houver falta ou atingir o mínimo. As aprovações de compra mantêm-se."/><div className="flex gap-4"><Link href="/stock/disponibilidade">Disponibilidade</Link><Link href="/stock/requisicoes">Requisições ao armazém</Link><Link href="/compras/fornecimento?view=calendar">Políticas de reposição</Link></div>
|
||||
{!props.ready?<Panel title="Instalação pendente"><p>Instale a reserva e reposição para activar estas regras.</p></Panel>:<>
|
||||
{props.canPrepare&&<Panel title="Parametrizar artigo e armazém"><RuleForm props={props}/></Panel>}
|
||||
<Panel title="Parametrizações">{props.rules.length===0?<p>Sem parametrizações. Não são assumidos requisitantes ou centros de custo.</p>:props.rules.map(r=><div className="mb-4 rounded border p-4" key={r.id}><p className="font-medium">{r.snapshot.item.code} · {r.snapshot.item.name} · {r.snapshot.warehouse.name}</p><p>{states[r.state]} · Versão {r.version} · {r.snapshot.requester?.name} · {r.snapshot.cost_center?.code}</p><p>Preço estimado {r.data.unit_price} 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"/>}{props.canPrepare&&<details className="mt-3"><summary>Rever parametrização</summary><RuleForm key={r.id} props={props} rule={r}/></details>}</div>)}</Panel>
|
||||
<Panel title="Necessidades e compras associadas">{props.needs.length===0?<p>Sem necessidades registadas após alterações de stock ou activação de regras.</p>:props.needs.map(n=><div key={n.id} className="mb-4 rounded border p-4"><p className="font-medium">Artigo #{n.snapshot.item.id} · Armazém #{n.snapshot.warehouse.id} · {states[n.state]}</p><p>{n.data.message}</p>{n.snapshot.origins?.map((o,i)=><p key={i}><Link href={`/stock/requisicoes/${o.warehouse_request_id}`}>RA-{o.warehouse_request_id}</Link> · {o.destination}{o.equipment?` · ${o.equipment.code} · ${o.equipment.description}`:''} · Pendente {o.remaining_milli/1000}</p>)}{n.data.purchases?.map(p=><p key={p.id}><Link href={`/requisicoes/${p.id}`}>{p.number}</Link> · Quantidade original {p.quantity_milli/1000}</p>)}{(props.canPrepare||props.canReview)&&<Button variant="outline" className="mt-3" onClick={()=>router.post(`/stock/reposicao/${n.id}/avaliar`,{}, {preserveState:'errors'})}>Reavaliar necessidade</Button>}</div>)}</Panel>
|
||||
<Panel title="Histórico"><details><summary>Consultar versões e fundamentos</summary>{props.history.map(h=><p key={h.id} className="mt-2">#{h.id} · {h.created_at} · Versão {h.version} · {states[h.state]} · {h.reason}</p>)}</details></Panel></>}
|
||||
</div></>}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
type Line = {
|
||||
item: { code: string; name: string; unit: string };
|
||||
quantity_milli: number;
|
||||
destination: string;
|
||||
equipment: { code: string; description: string } | null;
|
||||
};
|
||||
type Event = {
|
||||
id: number;
|
||||
version: number;
|
||||
action: string;
|
||||
actor_id: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
actor?: { id: number; name: string };
|
||||
lines?: {
|
||||
index: number;
|
||||
quantity_milli: number;
|
||||
stock_operation_id: number;
|
||||
}[];
|
||||
delivery_event_id?: number;
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
record: {
|
||||
id: number;
|
||||
snapshot: {
|
||||
requester: { name: string };
|
||||
warehouse: { name: string };
|
||||
cost_center: { code: string; name: string };
|
||||
destination: string;
|
||||
reason: string;
|
||||
lines: Line[];
|
||||
};
|
||||
};
|
||||
progress: {
|
||||
state: string;
|
||||
version: number;
|
||||
remaining: number[];
|
||||
reserved: number[];
|
||||
cancelled: number[];
|
||||
reservation_ready: boolean;
|
||||
delivered: number[];
|
||||
partial_requested: boolean;
|
||||
partial_allowed: boolean;
|
||||
events: Event[];
|
||||
received_deliveries: number[];
|
||||
};
|
||||
ready: boolean;
|
||||
canSubmit: boolean;
|
||||
canReview: boolean;
|
||||
canDeliver: boolean;
|
||||
canReceive: boolean;
|
||||
canManageReservation: boolean;
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
submitted: 'Submetida',
|
||||
authorized: 'Autorizada',
|
||||
partial: 'Parcialmente entregue',
|
||||
delivered: 'Entregue',
|
||||
closed: 'Encerrada — recepção confirmada',
|
||||
rejected: 'Rejeitada',
|
||||
submit: 'Submissão',
|
||||
authorize: 'Autorização',
|
||||
reject: 'Rejeição',
|
||||
deliver: 'Entrega física',
|
||||
receive: 'Confirmação de recepção',
|
||||
reserve: 'Actualizar reserva de stock',
|
||||
cancel: 'Cancelar saldo e libertar reserva',
|
||||
cancelled: 'Saldo cancelado',
|
||||
};
|
||||
function Command({
|
||||
props,
|
||||
action,
|
||||
delivery,
|
||||
}: {
|
||||
props: Props;
|
||||
action: string;
|
||||
delivery?: number;
|
||||
}) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
version: props.progress.version,
|
||||
action,
|
||||
reason: '',
|
||||
partial_allowed: false,
|
||||
delivery_event_id: delivery,
|
||||
lines: props.progress.remaining
|
||||
.map((qty, index) => ({ index, quantity: '', remaining: qty }))
|
||||
.filter((line) => line.remaining > 0),
|
||||
});
|
||||
return (
|
||||
<details className="rounded border p-3 print:hidden">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{labels[action]}
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((values) => ({
|
||||
...values,
|
||||
lines:
|
||||
action === 'deliver'
|
||||
? values.lines
|
||||
.filter((line) => line.quantity !== '')
|
||||
.map(({ index, quantity }) => ({
|
||||
index,
|
||||
quantity,
|
||||
}))
|
||||
: undefined,
|
||||
}));
|
||||
form.post(`/stock/requisicoes/${props.record.id}/eventos`, {
|
||||
preserveState: 'errors',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{(action === 'submit' || action === 'authorize') && (
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.partial_allowed}
|
||||
disabled={
|
||||
action === 'authorize' &&
|
||||
!props.progress.partial_requested
|
||||
}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'partial_allowed',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{action === 'submit'
|
||||
? 'Aceito entregas parciais'
|
||||
: 'Permitir entregas parciais aceites pelo requisitante'}
|
||||
</label>
|
||||
)}
|
||||
{action === 'deliver' && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Preencha só as quantidades entregues agora. A
|
||||
requisição é o documento de saída, sem factura. A
|
||||
data de registo é a data efectiva da entrega.{' '}
|
||||
{props.progress.partial_allowed
|
||||
? 'As quantidades não entregues mantêm-se pendentes.'
|
||||
: 'Esta autorização exige uma entrega integral.'}
|
||||
</p>
|
||||
{form.data.lines.map((line, i) => (
|
||||
<label className="block" key={line.index}>
|
||||
{
|
||||
props.record.snapshot.lines[line.index].item
|
||||
.code
|
||||
}{' '}
|
||||
· Pendente {line.remaining / 1000}{' '}
|
||||
{
|
||||
props.record.snapshot.lines[line.index].item
|
||||
.unit
|
||||
}
|
||||
<input
|
||||
inputMode="decimal"
|
||||
placeholder="Quantidade efectivamente entregue"
|
||||
className={controlClass}
|
||||
value={line.quantity}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'lines',
|
||||
form.data.lines.map((row, j) =>
|
||||
j === i
|
||||
? {
|
||||
...row,
|
||||
quantity:
|
||||
e.target.value,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<label className="block">
|
||||
Fundamento / registo da intervenção
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>{labels[action]}</Button>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export default function WarehouseRequest(props: Props) {
|
||||
const r = props.record;
|
||||
const p = props.progress;
|
||||
return (
|
||||
<>
|
||||
<Head title={`Requisição RA-${r.id}`} />
|
||||
<div className="space-y-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="Documento de saída interna · Sem factura"
|
||||
title={`Requisição RA-${r.id}`}
|
||||
description={`Estado: ${labels[p.state] ?? p.state}`}
|
||||
/>
|
||||
<div className="flex gap-4 print:hidden">
|
||||
<Link href="/stock/requisicoes">
|
||||
Requisições ao armazém
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
Imprimir requisição e entregas
|
||||
</Button>
|
||||
</div>
|
||||
<Panel title="Utilização e origem">
|
||||
<p>Requisitante: {r.snapshot.requester.name}</p>
|
||||
<p>Armazém: {r.snapshot.warehouse.name}</p>
|
||||
<p>
|
||||
Centro de custo: {r.snapshot.cost_center.code} ·{' '}
|
||||
{r.snapshot.cost_center.name}
|
||||
</p>
|
||||
<p>Local de utilização: {r.snapshot.destination}</p>
|
||||
<p>Finalidade: {r.snapshot.reason}</p>
|
||||
</Panel>
|
||||
<Panel title="Artigos e atendimento">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Artigo / unidade',
|
||||
'Destino',
|
||||
'Pedido',
|
||||
'Entregue',
|
||||
'Reservado',
|
||||
'Cancelado',
|
||||
'Pendente',
|
||||
].map((label) => (
|
||||
<th
|
||||
className="border-b p-3"
|
||||
key={label}
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.snapshot.lines.map((line, index) => (
|
||||
<tr key={index}>
|
||||
<td className="border-b p-3">
|
||||
{line.item.code} · {line.item.name}{' '}
|
||||
({line.item.unit})
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{line.equipment
|
||||
? `${line.equipment.code} · ${line.equipment.description}`
|
||||
: line.destination}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{line.quantity_milli / 1000}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{p.delivered[index] / 1000}
|
||||
</td>
|
||||
<td className="border-b p-3">{p.reservation_ready ? p.reserved[index] / 1000 : 'Por instalar'}</td>
|
||||
<td className="border-b p-3">{p.cancelled[index] / 1000}</td>
|
||||
<td className="border-b p-3">
|
||||
{p.remaining[index] / 1000}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-4 text-sm">
|
||||
{p.reservation_ready ? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.' : 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>Autorização e entrega ainda não instaladas.</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<div className="space-y-3 print:hidden">
|
||||
{props.canSubmit && (
|
||||
<Command
|
||||
key={`submit:${p.version}`}
|
||||
props={props}
|
||||
action="submit"
|
||||
/>
|
||||
)}
|
||||
{props.canReview && (
|
||||
<>
|
||||
<Command
|
||||
key={`authorize:${p.version}`}
|
||||
props={props}
|
||||
action="authorize"
|
||||
/>
|
||||
<Command
|
||||
key={`reject:${p.version}`}
|
||||
props={props}
|
||||
action="reject"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{props.canDeliver && (
|
||||
<Command
|
||||
key={`deliver:${p.version}`}
|
||||
props={props}
|
||||
action="deliver"
|
||||
/>
|
||||
)}
|
||||
{props.canManageReservation && p.reservation_ready && <>
|
||||
<Command key={`reserve:${p.version}`} props={props} action="reserve" />
|
||||
<Command key={`cancel:${p.version}`} props={props} action="cancel" />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
<Panel title="Decisões, entregas e recepção">
|
||||
{p.events.length === 0 ? (
|
||||
<p>Sem intervenções.</p>
|
||||
) : (
|
||||
p.events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="mb-4 rounded border p-3"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{labels[event.action]} · Registo #{event.id}{' '}
|
||||
·{' '}
|
||||
{event.data.actor?.name ??
|
||||
`Utilizador #${event.actor_id}`}
|
||||
</p>
|
||||
<p>{event.created_at}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{event.reason}
|
||||
</p>
|
||||
{event.action === 'deliver' && (
|
||||
<>
|
||||
<p>
|
||||
Documento de saída: RA-{r.id} ·
|
||||
Entrega #{event.id}
|
||||
</p>
|
||||
{event.data.lines?.map((line) => (
|
||||
<p key={line.index}>
|
||||
{
|
||||
r.snapshot.lines[line.index]
|
||||
.item.code
|
||||
}{' '}
|
||||
· {line.quantity_milli / 1000}{' '}
|
||||
{
|
||||
r.snapshot.lines[line.index]
|
||||
.item.unit
|
||||
}{' '}
|
||||
· Operação de stock #
|
||||
{line.stock_operation_id}
|
||||
</p>
|
||||
))}
|
||||
<p>
|
||||
{p.received_deliveries.includes(
|
||||
event.id,
|
||||
)
|
||||
? 'Recepção confirmada pelo requisitante'
|
||||
: 'Recepção por confirmar'}
|
||||
</p>
|
||||
{props.canReceive &&
|
||||
!p.received_deliveries.includes(
|
||||
event.id,
|
||||
) && (
|
||||
<Command
|
||||
key={`receive:${event.id}:${p.version}`}
|
||||
props={props}
|
||||
action="receive"
|
||||
delivery={event.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{event.action === 'receive' && (
|
||||
<p>
|
||||
Relativa à entrega #
|
||||
{event.data.delivery_event_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
type Item = { id: number; code: string; name: string; unit: string };
|
||||
type Line = {
|
||||
stock_item_id: string;
|
||||
quantity: string;
|
||||
use: string;
|
||||
equipment_id: string;
|
||||
};
|
||||
type Snapshot = {
|
||||
requester: { name: string };
|
||||
warehouse: { name: string };
|
||||
cost_center: { code: string; name: string };
|
||||
destination: string;
|
||||
reason: string;
|
||||
priority: string;
|
||||
lines: {
|
||||
item: Item;
|
||||
quantity_milli: number;
|
||||
use: string;
|
||||
destination: string;
|
||||
equipment: { code: string; description: string } | null;
|
||||
}[];
|
||||
};
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
canPrepare: boolean;
|
||||
items: Item[];
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
costCenters: { id: number; code: string; name: string }[];
|
||||
equipment: { id: number; code: string; description: string }[];
|
||||
records: null | {
|
||||
data: {
|
||||
id: number;
|
||||
status: string;
|
||||
snapshot: Snapshot;
|
||||
progress?: { state: string };
|
||||
}[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
};
|
||||
const blank = (): Line => ({
|
||||
stock_item_id: '',
|
||||
quantity: '',
|
||||
use: 'internal',
|
||||
equipment_id: '',
|
||||
});
|
||||
export default function Requests(props: Props) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
warehouse_id: '',
|
||||
cost_center_id: '',
|
||||
destination: '',
|
||||
reason: '',
|
||||
priority: 'normal',
|
||||
lines: [blank()],
|
||||
});
|
||||
const line = (index: number, values: Partial<Line>) =>
|
||||
form.setData(
|
||||
'lines',
|
||||
form.data.lines.map((row, i) =>
|
||||
i === index ? { ...row, ...values } : row,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head title="Requisições ao armazém" />
|
||||
<div className="space-y-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="Armazém e activos"
|
||||
title="Requisições ao armazém"
|
||||
description="Registo inicial com destino de utilização. Um rascunho não autoriza, reserva ou retira material."
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Uso interno sem factura: a requisição autorizada acompanha a
|
||||
saída e as entregas. Reserva física exclusiva e geração
|
||||
automática de compra por falta ainda estão em implementação.
|
||||
A venda a terceiros pertence à composição SGF.
|
||||
</p>
|
||||
<Link href="/stock/disponibilidade">
|
||||
Consultar disponibilidade (conforme o seu acesso)
|
||||
</Link>
|
||||
{!props.ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>
|
||||
O registo de requisições ainda não está instalado.
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
props.canPrepare && (
|
||||
<Panel title="Preparar rascunho">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/stock/requisicoes', {
|
||||
preserveState: 'errors',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
Armazém
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.warehouse_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'warehouse_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione</option>
|
||||
{props.warehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} · {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Centro de custo
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.cost_center_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'cost_center_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione do seu departamento
|
||||
</option>
|
||||
{props.costCenters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code} · {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Onde será utilizado
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.destination}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'destination',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Prioridade
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.priority}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'priority',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="normal">
|
||||
Normal
|
||||
</option>
|
||||
<option value="urgent">
|
||||
Urgente
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
Finalidade/justificação
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'reason',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{form.data.lines.map((row, index) => (
|
||||
<fieldset
|
||||
key={index}
|
||||
className="grid gap-3 rounded border p-3 md:grid-cols-2"
|
||||
>
|
||||
<legend>Artigo {index + 1}</legend>
|
||||
<label>
|
||||
Artigo do cadastro
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={row.stock_item_id}
|
||||
onChange={(e) =>
|
||||
line(index, {
|
||||
stock_item_id:
|
||||
e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione
|
||||
</option>
|
||||
{props.items.map((item) => (
|
||||
<option
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
>
|
||||
{item.code} ·{' '}
|
||||
{item.name} ({item.unit}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Quantidade
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
inputMode="decimal"
|
||||
placeholder="Ex.: 2.500"
|
||||
value={row.quantity}
|
||||
onChange={(e) =>
|
||||
line(index, {
|
||||
quantity:
|
||||
e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Utilização
|
||||
<select
|
||||
className={controlClass}
|
||||
value={row.use}
|
||||
onChange={(e) =>
|
||||
line(index, {
|
||||
use: e.target.value,
|
||||
equipment_id: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="internal">
|
||||
Uso interno no local
|
||||
indicado
|
||||
</option>
|
||||
<option value="equipment">
|
||||
Em equipamento
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{row.use === 'equipment' && (
|
||||
<label>
|
||||
Equipamento de destino
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={row.equipment_id}
|
||||
onChange={(e) =>
|
||||
line(index, {
|
||||
equipment_id:
|
||||
e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione do seu
|
||||
departamento
|
||||
</option>
|
||||
{props.equipment.map(
|
||||
(eq) => (
|
||||
<option
|
||||
key={eq.id}
|
||||
value={eq.id}
|
||||
>
|
||||
{eq.code} ·{' '}
|
||||
{eq.description}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.data.lines.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setData(
|
||||
'lines',
|
||||
form.data.lines.filter(
|
||||
(_, i) =>
|
||||
i !== index,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar linha
|
||||
</Button>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={form.data.lines.length >= 50}
|
||||
onClick={() =>
|
||||
form.setData('lines', [
|
||||
...form.data.lines,
|
||||
blank(),
|
||||
])
|
||||
}
|
||||
>
|
||||
Acrescentar artigo
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
form.processing ||
|
||||
props.costCenters.length === 0
|
||||
}
|
||||
>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</div>
|
||||
{props.costCenters.length === 0 && (
|
||||
<p>
|
||||
É necessário um centro de custo
|
||||
associado ao seu departamento.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
)
|
||||
)}
|
||||
<Panel title="Requisições registadas">
|
||||
{!props.records?.data.length ? (
|
||||
<p>Sem registos no seu âmbito.</p>
|
||||
) : (
|
||||
props.records.data.map((record) => (
|
||||
<details
|
||||
key={record.id}
|
||||
className="mb-3 rounded border p-4"
|
||||
>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
RA-{record.id} ·{' '}
|
||||
{
|
||||
(
|
||||
{
|
||||
draft: 'Rascunho',
|
||||
submitted: 'Submetida',
|
||||
authorized: 'Autorizada',
|
||||
partial:
|
||||
'Parcialmente entregue',
|
||||
delivered: 'Entregue',
|
||||
closed: 'Encerrada',
|
||||
rejected: 'Rejeitada',
|
||||
} as Record<string, string>
|
||||
)[record.progress?.state ?? 'draft']
|
||||
}{' '}
|
||||
· {record.snapshot.requester.name} ·{' '}
|
||||
{record.snapshot.warehouse.name}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/stock/requisicoes/${record.id}`}
|
||||
>
|
||||
Abrir requisição, autorizar ou registar
|
||||
entrega
|
||||
</Link>
|
||||
<p>
|
||||
Destino: {record.snapshot.destination}
|
||||
</p>
|
||||
<p>
|
||||
Centro:{' '}
|
||||
{record.snapshot.cost_center.code} ·{' '}
|
||||
{record.snapshot.cost_center.name}
|
||||
</p>
|
||||
<p>Finalidade: {record.snapshot.reason}</p>
|
||||
{record.snapshot.lines.map(
|
||||
(item, index) => (
|
||||
<p key={index}>
|
||||
{item.item.code} ·{' '}
|
||||
{item.item.name} ·{' '}
|
||||
{item.quantity_milli / 1000}{' '}
|
||||
{item.item.unit} ·{' '}
|
||||
{item.equipment
|
||||
? `Equipamento ${item.equipment.code} · ${item.equipment.description}`
|
||||
: item.destination}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{props.records?.prev_page_url && (
|
||||
<Link href={props.records.prev_page_url}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{props.records?.next_page_url && (
|
||||
<Link href={props.records.next_page_url}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { InventoryEventActions } from '@/components/inventory-event-actions';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Row = {
|
||||
source_movement_id: number;
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
original_quantity_milli: number;
|
||||
quantity_milli: number;
|
||||
original_type: string;
|
||||
lots: { lot_id: number | null; code: string | null; quantity: number }[];
|
||||
};
|
||||
type Entry = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
reference: string;
|
||||
source_kind: string;
|
||||
source_operation_id: number | null;
|
||||
receipt_item_id: number | null;
|
||||
event?: { event_on: string; evidence: string };
|
||||
};
|
||||
snapshot: {
|
||||
execution_lots?: {
|
||||
lot_id: number;
|
||||
movement_id: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
contributors: number[];
|
||||
reviewed_by?: number;
|
||||
actor: { name: string };
|
||||
source: {
|
||||
document: { reference: string };
|
||||
item: { code: string; name: string; unit: string };
|
||||
rows: Row[];
|
||||
};
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
today: string;
|
||||
canViewDispositions: boolean;
|
||||
capabilities: Record<string, boolean>;
|
||||
records: Entry[];
|
||||
operations: { id: number; label: string }[];
|
||||
receipts: { id: number; label: string }[];
|
||||
history: Record<string, Entry[]>;
|
||||
};
|
||||
const qty = (milli: number) =>
|
||||
(milli / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
const states: Record<string, string> = {
|
||||
draft: 'Em preparação',
|
||||
returned: 'Para actualização',
|
||||
approved: 'Aprovado para estorno',
|
||||
executed: 'Estorno integral registado',
|
||||
};
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: Entry }) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'save',
|
||||
reason: '',
|
||||
data: {
|
||||
reference: old?.data.reference ?? '',
|
||||
source_kind: old?.data.source_kind ?? 'operation',
|
||||
source_operation_id: old?.data.source_operation_id
|
||||
? String(old.data.source_operation_id)
|
||||
: '',
|
||||
receipt_item_id: old?.data.receipt_item_id
|
||||
? String(old.data.receipt_item_id)
|
||||
: '',
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/stock/estornos');
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label className="grid gap-1 text-sm">
|
||||
Origem
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.data.source_kind}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
source_kind: e.target.value,
|
||||
source_operation_id: '',
|
||||
receipt_item_id: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="operation">
|
||||
Saída, transferência, ajuste, devolução ou abate
|
||||
</option>
|
||||
<option value="receipt">Linha física de recepção</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.data.data.source_kind === 'operation' ? (
|
||||
<label className="grid gap-1 text-sm">
|
||||
Operação original
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.source_operation_id}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
source_operation_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccione a operação a corrigir
|
||||
</option>
|
||||
{props.operations.map((op) => (
|
||||
<option key={op.id} value={op.id}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="grid gap-1 text-sm">
|
||||
Linha de recepção original
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.receipt_item_id}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
receipt_item_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione a linha a anular</option>
|
||||
{props.receipts.map((receipt) => (
|
||||
<option key={receipt.id} value={receipt.id}>
|
||||
{receipt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência única do documento de correcção
|
||||
<input
|
||||
required
|
||||
maxLength={100}
|
||||
className={controlClass}
|
||||
value={form.data.data.reference}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
reference: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm md:col-span-2">
|
||||
Erro identificado e evidência da correcção
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>
|
||||
Preparar estorno integral
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground md:col-span-2">
|
||||
Artigo, locais, lotes e quantidades são reutilizados
|
||||
integralmente da origem. O registo original permanece no
|
||||
histórico. Não use estorno para simular consumo, devolução
|
||||
parcial ou uma operação nova.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
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
|
||||
)
|
||||
choices.push({ value: 'execute', label: 'Confirmar estorno integral' });
|
||||
return (
|
||||
<InventoryEventActions
|
||||
route="/stock/estornos"
|
||||
record={record}
|
||||
choices={choices}
|
||||
today={props.today}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reversals(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Estornos de stock" />
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém"
|
||||
title="Estornos de stock"
|
||||
description="Corrigir por referência ao original, sem apagar documentos nem repetir quantidades."
|
||||
/>
|
||||
{props.canViewDispositions && (
|
||||
<Link
|
||||
href="/stock/ocorrencias"
|
||||
className="text-sm underline"
|
||||
>
|
||||
Devoluções e abates
|
||||
</Link>
|
||||
)}
|
||||
<Panel title="Antes de corrigir">
|
||||
<p className="text-sm">
|
||||
O estorno é integral e só pode ocorrer uma vez por
|
||||
origem. A preparação, aprovação e comprovação da
|
||||
execução têm responsabilidades separadas. Quantidades
|
||||
repostas em locais com controlo por lote ficam em
|
||||
quarentena, incluindo saldo anterior da mesma
|
||||
identidade. As retiradas exigem a quantidade física dos
|
||||
lotes originais.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A correcção actualiza o stock, a satisfação de reservas
|
||||
e as quantidades efectivas de recepção. Não cria
|
||||
crédito, restituição ou estorno financeiro. Documentos e
|
||||
obrigações já validados precisam de conferência própria
|
||||
em Finanças. Não há estorno parcial, estorno de outro
|
||||
estorno ou de aceitação de serviços neste percurso.
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>
|
||||
O diário de stock ainda não está instalado neste
|
||||
ambiente.
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{props.capabilities.prepare && (
|
||||
<Panel title="Preparar correcção">
|
||||
<Prepare props={props} />
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((record) => (
|
||||
<Panel
|
||||
key={record.id}
|
||||
title={`${record.data.reference} · ${states[record.state]}`}
|
||||
description={`Origem: ${record.snapshot.source.document.reference}`}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{record.snapshot.source.item.code} ·{' '}
|
||||
{record.snapshot.source.item.name}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2 text-sm">
|
||||
{record.snapshot.source.rows.map((row) => (
|
||||
<li key={row.source_movement_id}>
|
||||
{row.warehouse_name} · Movimento
|
||||
original #{row.source_movement_id}:{' '}
|
||||
{qty(row.original_quantity_milli)} →
|
||||
correcção{' '}
|
||||
{row.quantity_milli > 0 ? '+' : ''}
|
||||
{qty(row.quantity_milli)}{' '}
|
||||
{record.snapshot.source.item.unit}
|
||||
{row.lots.length > 0 && (
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{row.lots.map((lot, i) => (
|
||||
<li
|
||||
key={
|
||||
lot.lot_id ?? i
|
||||
}
|
||||
>
|
||||
{lot.code ??
|
||||
(lot.lot_id
|
||||
? `Lote #${lot.lot_id}`
|
||||
: 'Identidade desconhecida a conferir')}{' '}
|
||||
·{' '}
|
||||
{qty(lot.quantity)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{record.reason}
|
||||
</p>
|
||||
{props.capabilities.prepare &&
|
||||
['draft', 'returned'].includes(
|
||||
record.state,
|
||||
) && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Actualizar preparação
|
||||
</summary>
|
||||
<Prepare
|
||||
props={props}
|
||||
old={record}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
<RecordActions props={props} record={record} />
|
||||
{record.state === 'executed' && (
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
Correcção em{' '}
|
||||
{record.data.event?.event_on} ·{' '}
|
||||
{record.data.event?.evidence}. O
|
||||
original foi preservado e não pode ser
|
||||
estornado novamente.
|
||||
</p>
|
||||
)}
|
||||
{record.snapshot.execution_lots &&
|
||||
record.snapshot.execution_lots.length >
|
||||
0 && (
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Repartição efectiva do estorno
|
||||
por lote
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{record.snapshot.execution_lots.map(
|
||||
(lot) => (
|
||||
<li
|
||||
key={`${lot.movement_id}-${lot.lot_id}`}
|
||||
>
|
||||
Lote #{lot.lot_id} ·{' '}
|
||||
{lot.quantity > 0
|
||||
? '+'
|
||||
: ''}
|
||||
{qty(lot.quantity)}{' '}
|
||||
{
|
||||
record.snapshot
|
||||
.source.item
|
||||
.unit
|
||||
}{' '}
|
||||
· Movimento #
|
||||
{lot.movement_id}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<details className="mt-4 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Histórico preservado
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{props.history[record.record_key]?.map(
|
||||
(version) => (
|
||||
<li key={version.id}>
|
||||
Versão {version.version} ·{' '}
|
||||
{states[version.state]} ·{' '}
|
||||
{
|
||||
version.snapshot.actor
|
||||
.name
|
||||
}{' '}
|
||||
· {version.created_at} ·{' '}
|
||||
{version.reason}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Fields = Record<string, string | number | boolean | null>;
|
||||
type Application = {
|
||||
operation_id: number;
|
||||
reference: string;
|
||||
quantity_milli: number;
|
||||
};
|
||||
type RecordVersion = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
state: string;
|
||||
data: Fields & { applications?: Application[] };
|
||||
reason: string;
|
||||
created_at: string;
|
||||
snapshot: {
|
||||
effective_allocated_milli?: number;
|
||||
reversed_operations?: number[];
|
||||
item: { name: string; code: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
substitute?: { name: string; code: string; unit: string };
|
||||
order?: { number: string };
|
||||
};
|
||||
};
|
||||
type Source = {
|
||||
id: number;
|
||||
label: string;
|
||||
stock_item_id: number | null;
|
||||
quantity_milli: number;
|
||||
reference?: string;
|
||||
needed_on?: string;
|
||||
};
|
||||
type Props = {
|
||||
kind: 'reservation' | 'delivery' | 'alternative';
|
||||
ready: boolean;
|
||||
canPrepare: boolean;
|
||||
canReview: boolean;
|
||||
eligible: Record<string, boolean>;
|
||||
pending: Record<string, number>;
|
||||
reversedOperations?: number[];
|
||||
records: RecordVersion[];
|
||||
history: Record<string, RecordVersion[]>;
|
||||
items: { id: number; name: string; code: string; unit: string }[];
|
||||
warehouses: { id: number; name: string; code: string }[];
|
||||
requestLines: Source[];
|
||||
orderLines: Source[];
|
||||
operations: {
|
||||
id: number;
|
||||
reference: string;
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
quantity_milli: number;
|
||||
}[];
|
||||
};
|
||||
const titles = {
|
||||
reservation: 'Reservas de necessidades',
|
||||
delivery: 'Entregas confirmadas',
|
||||
alternative: 'Alternativas técnicas',
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
draft: 'Em análise técnica',
|
||||
returned: 'Devolvida para correcção',
|
||||
approved: 'Validada tecnicamente',
|
||||
suspended: 'Suspensa',
|
||||
active: 'Activa',
|
||||
cancelled: 'Cancelada',
|
||||
fulfilled: 'Satisfeita por saídas',
|
||||
};
|
||||
const compatibility: Record<string, string> = {
|
||||
equivalent: 'Equivalente técnica',
|
||||
conditional: 'Substituição condicionada',
|
||||
incompatible: 'Não compatível',
|
||||
pending: 'Pendente de análise',
|
||||
};
|
||||
const quantity = (milli: number) =>
|
||||
(milli / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||||
const defaults: Fields = {
|
||||
stock_item_id: '',
|
||||
warehouse_id: '',
|
||||
...(props.kind === 'alternative'
|
||||
? {
|
||||
substitute_id: '',
|
||||
application: '',
|
||||
compatibility: 'pending',
|
||||
conditions: '',
|
||||
evidence: '',
|
||||
factor_numerator: 1,
|
||||
factor_denominator: 1,
|
||||
valid_until: '',
|
||||
}
|
||||
: props.kind === 'reservation'
|
||||
? {
|
||||
requisition_item_id: '',
|
||||
quantity: '',
|
||||
needed_on: '',
|
||||
additional: false,
|
||||
reference: '',
|
||||
}
|
||||
: {
|
||||
purchase_order_item_id: '',
|
||||
quantity: '',
|
||||
expected_on: '',
|
||||
confirmed_on: '',
|
||||
evidence: '',
|
||||
}),
|
||||
};
|
||||
if (old) {
|
||||
for (const key of Object.keys(defaults))
|
||||
if (key in old.data) defaults[key] = old.data[key];
|
||||
if (old.data.quantity_milli !== undefined)
|
||||
defaults.quantity = Number(old.data.quantity_milli) / 1000;
|
||||
}
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'save',
|
||||
reason: '',
|
||||
data: defaults,
|
||||
});
|
||||
const update = (key: string, value: Fields[string]) =>
|
||||
form.setData('data', { ...form.data.data, [key]: value });
|
||||
const field = (
|
||||
key: string,
|
||||
label: string,
|
||||
type = 'text',
|
||||
optional = false,
|
||||
) => (
|
||||
<label className="grid gap-1 text-sm">
|
||||
{label}
|
||||
{optional && ' (opcional)'}
|
||||
<input
|
||||
className={controlClass}
|
||||
type={type}
|
||||
required={!optional}
|
||||
min={type === 'number' ? 0 : undefined}
|
||||
step={type === 'number' ? '0.001' : undefined}
|
||||
maxLength={type === 'text' ? 4000 : undefined}
|
||||
value={String(form.data.data[key] ?? '')}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const selector = (
|
||||
key: string,
|
||||
label: string,
|
||||
entries: { id: number; label: string }[],
|
||||
) => (
|
||||
<label className="grid gap-1 text-sm">
|
||||
{label}
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={String(form.data.data[key])}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar cadastro existente</option>
|
||||
{entries.map((entry) => (
|
||||
<option value={entry.id} key={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
const sources =
|
||||
props.kind === 'delivery' ? props.orderLines : props.requestLines;
|
||||
return (
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((input) => {
|
||||
const data = { ...input.data };
|
||||
if (props.kind !== 'alternative') {
|
||||
data.quantity_milli = Math.round(
|
||||
Number(data.quantity) * 1000,
|
||||
);
|
||||
delete data.quantity;
|
||||
}
|
||||
return { ...input, data };
|
||||
});
|
||||
form.post(`/compras/fornecimento/${props.kind}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () =>
|
||||
form.setData('operation_key', crypto.randomUUID()),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{props.kind !== 'alternative' && (
|
||||
<label className="grid gap-1 text-sm">
|
||||
{props.kind === 'delivery'
|
||||
? 'Linha da ordem emitida'
|
||||
: 'Reutilizar necessidade aprovada (opcional)'}
|
||||
<select
|
||||
required={props.kind === 'delivery'}
|
||||
className={controlClass}
|
||||
value={String(
|
||||
form.data.data[
|
||||
props.kind === 'delivery'
|
||||
? 'purchase_order_item_id'
|
||||
: 'requisition_item_id'
|
||||
],
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const source = sources.find(
|
||||
(s) => s.id === Number(e.target.value),
|
||||
);
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
[props.kind === 'delivery'
|
||||
? 'purchase_order_item_id'
|
||||
: 'requisition_item_id']:
|
||||
e.target.value,
|
||||
...(source
|
||||
? {
|
||||
...(source.stock_item_id
|
||||
? {
|
||||
stock_item_id:
|
||||
source.stock_item_id,
|
||||
}
|
||||
: {}),
|
||||
quantity:
|
||||
source.quantity_milli / 1000,
|
||||
...(source.reference
|
||||
? {
|
||||
reference:
|
||||
source.reference,
|
||||
needed_on:
|
||||
source.needed_on ??
|
||||
'',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{props.kind === 'delivery'
|
||||
? 'Seleccionar ordem e linha'
|
||||
: 'Necessidade nova sem requisição anterior'}
|
||||
</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{selector(
|
||||
'stock_item_id',
|
||||
'Artigo e unidade',
|
||||
props.items.map((i) => ({
|
||||
id: i.id,
|
||||
label: `${i.code} · ${i.name} (${i.unit})`,
|
||||
})),
|
||||
)}
|
||||
{selector(
|
||||
'warehouse_id',
|
||||
'Armazém de destino ou utilização',
|
||||
props.warehouses.map((w) => ({
|
||||
id: w.id,
|
||||
label: `${w.code} · ${w.name}`,
|
||||
})),
|
||||
)}
|
||||
{props.kind === 'alternative' ? (
|
||||
<>
|
||||
{selector(
|
||||
'substitute_id',
|
||||
'Artigo substituto — neste sentido',
|
||||
props.items
|
||||
.filter(
|
||||
(i) =>
|
||||
i.id !==
|
||||
Number(form.data.data.stock_item_id),
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: `${i.code} · ${i.name} (${i.unit})`,
|
||||
})),
|
||||
)}
|
||||
{field(
|
||||
'application',
|
||||
'Aplicação ou equipamento autorizado',
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Resultado da análise
|
||||
<select
|
||||
className={controlClass}
|
||||
value={String(form.data.data.compatibility)}
|
||||
onChange={(e) =>
|
||||
update('compatibility', e.target.value)
|
||||
}
|
||||
>
|
||||
{Object.entries(compatibility).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{key === 'equivalent'
|
||||
? 'Equivalente — a validar'
|
||||
: label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{form.data.data.compatibility === 'conditional' &&
|
||||
field(
|
||||
'conditions',
|
||||
'Condições obrigatórias de utilização',
|
||||
)}
|
||||
{field(
|
||||
'evidence',
|
||||
'Referência das especificações comparadas e evidência técnica',
|
||||
)}
|
||||
{field('valid_until', 'Validade da análise', 'date')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field(
|
||||
'quantity',
|
||||
props.kind === 'delivery'
|
||||
? 'Quantidade acumulada confirmada na linha (inclui recepções anteriores)'
|
||||
: 'Quantidade na unidade do artigo',
|
||||
'number',
|
||||
)}
|
||||
{props.kind === 'reservation' ? (
|
||||
<>
|
||||
{field(
|
||||
'needed_on',
|
||||
'Data da necessidade',
|
||||
'date',
|
||||
)}
|
||||
{!form.data.data.requisition_item_id &&
|
||||
field(
|
||||
'reference',
|
||||
'Referência única da necessidade',
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(
|
||||
form.data.data.additional,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
'additional',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Procura adicional à previsão habitual
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field(
|
||||
'expected_on',
|
||||
'Disponível para utilização em',
|
||||
'date',
|
||||
)}
|
||||
{field(
|
||||
'confirmed_on',
|
||||
'Data da confirmação do fornecedor',
|
||||
'date',
|
||||
)}
|
||||
{field(
|
||||
'evidence',
|
||||
'Referência da confirmação recebida',
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{props.kind === 'alternative' && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Conversão técnica de quantidades
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
Quantidade do substituto = quantidade original ×
|
||||
numerador ÷ denominador. O factor pertence apenas a esta
|
||||
relação e necessita da validação técnica.
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{field('factor_numerator', 'Numerador', 'number')}
|
||||
{field('factor_denominator', 'Denominador', 'number')}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fundamento da preparação ou alteração
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing || !props.ready}>
|
||||
Guardar{' '}
|
||||
{props.kind === 'alternative'
|
||||
? 'análise para validação'
|
||||
: 'registo'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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 form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: record.record_key,
|
||||
version: record.version,
|
||||
action: '',
|
||||
reason: '',
|
||||
data: { stock_operation_id: '', quantity_milli: 0 },
|
||||
quantity: '',
|
||||
});
|
||||
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'],
|
||||
]
|
||||
: [];
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{props.canPrepare &&
|
||||
(props.kind === 'alternative'
|
||||
? ['draft', 'returned'].includes(record.state)
|
||||
: record.state === 'active' || reopened) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Rever os dados desta preparação
|
||||
</summary>
|
||||
<Prepare props={props} old={record} />
|
||||
</details>
|
||||
)}
|
||||
{choices.length > 0 && (
|
||||
<label className="grid gap-1 text-sm">
|
||||
Acção da minha responsabilidade
|
||||
<select
|
||||
className={controlClass}
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
setAction(e.target.value);
|
||||
form.setData('action', e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar acção</option>
|
||||
{choices.map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{action && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((input) => ({
|
||||
...input,
|
||||
data: {
|
||||
...input.data,
|
||||
quantity_milli: Math.round(
|
||||
Number(input.quantity) * 1000,
|
||||
),
|
||||
},
|
||||
}));
|
||||
form.post(`/compras/fornecimento/${props.kind}`, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{action === 'fulfil' && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Saída já registada
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={form.data.data.stock_operation_id}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
stock_operation_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar saída existente
|
||||
</option>
|
||||
{props.operations
|
||||
.filter(
|
||||
(o) =>
|
||||
o.stock_item_id ===
|
||||
Number(
|
||||
record.data
|
||||
.stock_item_id,
|
||||
) &&
|
||||
o.warehouse_id ===
|
||||
Number(
|
||||
record.data
|
||||
.warehouse_id,
|
||||
),
|
||||
)
|
||||
.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.reference} ·{' '}
|
||||
{quantity(o.quantity_milli)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Quantidade satisfeita nesta saída
|
||||
<input
|
||||
className={controlClass}
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
required
|
||||
value={form.data.quantity}
|
||||
onChange={(e) =>
|
||||
form.setData('quantity', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fundamento da decisão
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Confirmar</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Facts({
|
||||
record: r,
|
||||
pending,
|
||||
reversed,
|
||||
}: {
|
||||
record: RecordVersion;
|
||||
pending?: number;
|
||||
reversed?: number[];
|
||||
}) {
|
||||
const corrected = reversed ?? r.snapshot.reversed_operations ?? [];
|
||||
const applied =
|
||||
r.snapshot.effective_allocated_milli ??
|
||||
r.data.applications?.reduce((sum, a) => sum + a.quantity_milli, 0) ??
|
||||
0;
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
{r.snapshot.substitute && (
|
||||
<>
|
||||
<p>
|
||||
Substituto:{' '}
|
||||
<strong>
|
||||
{r.snapshot.substitute.code} ·{' '}
|
||||
{r.snapshot.substitute.name}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{compatibility[String(r.data.compatibility)]} ·
|
||||
Aplicação: {String(r.data.application)}
|
||||
</p>
|
||||
<p>
|
||||
Válida até {String(r.data.valid_until)} · Conversão:{' '}
|
||||
{String(r.data.factor_numerator)} /{' '}
|
||||
{String(r.data.factor_denominator)}
|
||||
</p>
|
||||
{r.data.conditions && (
|
||||
<p>Condições: {String(r.data.conditions)}</p>
|
||||
)}
|
||||
<p>Evidência: {String(r.data.evidence)}</p>
|
||||
</>
|
||||
)}
|
||||
{r.data.quantity_milli !== undefined && (
|
||||
<p>
|
||||
{r.snapshot.order
|
||||
? 'Quantidade acumulada confirmada: '
|
||||
: 'Quantidade: '}
|
||||
<strong>
|
||||
{quantity(Number(r.data.quantity_milli))}{' '}
|
||||
{r.snapshot.item.unit}
|
||||
</strong>
|
||||
{r.data.applications &&
|
||||
` · Pendente: ${quantity(pending ?? Number(r.data.quantity_milli) - applied)}`}
|
||||
</p>
|
||||
)}
|
||||
{r.data.needed_on && (
|
||||
<p>
|
||||
Necessária em {String(r.data.needed_on)} ·{' '}
|
||||
{r.data.additional
|
||||
? 'Procura adicional'
|
||||
: 'Incluída na previsão habitual'}{' '}
|
||||
· {String(r.data.reference)}
|
||||
</p>
|
||||
)}
|
||||
{r.data.expected_on && (
|
||||
<p>
|
||||
{r.snapshot.order?.number} · Disponibilidade prevista:{' '}
|
||||
{String(r.data.expected_on)} · Confirmação de{' '}
|
||||
{String(r.data.confirmed_on)}
|
||||
</p>
|
||||
)}
|
||||
{r.data.applications?.map((a, i) => (
|
||||
<p key={i}>
|
||||
Saída {a.reference}: {quantity(a.quantity_milli)}{' '}
|
||||
{r.snapshot.item.unit}
|
||||
{corrected.includes(a.operation_id) &&
|
||||
' · Estornada; não satisfaz a pendência actual'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Coordination(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title={titles[props.kind]} />
|
||||
<div className="space-y-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Compras · Fornecimento e reposição"
|
||||
title={titles[props.kind]}
|
||||
description={
|
||||
props.kind === 'alternative'
|
||||
? 'Relações direccionais por artigo, aplicação e local. A validação num sentido não autoriza o sentido inverso.'
|
||||
: props.kind === 'reservation'
|
||||
? 'Necessidades conhecidas por artigo e local, satisfeitas por saídas já registadas no armazém.'
|
||||
: 'Confirmações do fornecedor sobre ordens existentes. Apenas as quantidades pendentes entram no calendário.'
|
||||
}
|
||||
/>
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
<Panel title="Responsabilidade desta etapa">
|
||||
<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.'
|
||||
: '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 && (
|
||||
<Panel
|
||||
title={
|
||||
props.kind === 'alternative'
|
||||
? 'Preparar análise técnica'
|
||||
: props.kind === 'reservation'
|
||||
? 'Reservar uma necessidade'
|
||||
: 'Confirmar uma entrega'
|
||||
}
|
||||
>
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Abrir preparação
|
||||
</summary>
|
||||
<Prepare props={props} />
|
||||
</details>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.length === 0 && (
|
||||
<Panel title="Sem registos">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não existem registos para a sua empresa nesta
|
||||
área.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((r) => (
|
||||
<Panel
|
||||
key={r.id}
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<Facts
|
||||
record={r}
|
||||
pending={
|
||||
props.kind === 'reservation'
|
||||
? props.pending[r.record_key]
|
||||
: undefined
|
||||
}
|
||||
reversed={props.reversedOperations}
|
||||
/>
|
||||
{props.kind === 'reservation' &&
|
||||
r.state === 'fulfilled' &&
|
||||
(props.pending[r.record_key] ?? 0) > 0 && (
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
Pendência reaberta por estorno de saída. A
|
||||
associação anterior permanece no histórico.
|
||||
</p>
|
||||
)}
|
||||
{props.kind === 'delivery' && r.state === 'active' && (
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
Ainda por receber:{' '}
|
||||
{quantity(props.pending[r.record_key] ?? 0)}{' '}
|
||||
{r.snapshot.item.unit}
|
||||
</p>
|
||||
)}
|
||||
{props.kind === 'alternative' && (
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{props.eligible[r.record_key]
|
||||
? 'Equivalência vigente para esta aplicação e sentido'
|
||||
: 'Excluída da utilização automática; consulte estado, condições, validade e cadastro'}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{r.reason}
|
||||
</p>
|
||||
<Actions props={props} record={r} />
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Histórico preservado
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-4">
|
||||
{props.history[r.record_key]?.map((h) => (
|
||||
<li
|
||||
className="border-l-2 pl-4 text-sm"
|
||||
key={h.id}
|
||||
>
|
||||
<p className="font-medium">
|
||||
Versão {h.version} ·{' '}
|
||||
{states[h.state]} ·{' '}
|
||||
{new Date(
|
||||
h.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
<p className="mb-2">{h.reason}</p>
|
||||
<Facts record={h} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<p className="flex flex-wrap gap-5 text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
href="/compras/fornecimento?view=calendar"
|
||||
>
|
||||
Reposição e calendário
|
||||
</Link>
|
||||
<Link className="underline" href="/stock/operacoes">
|
||||
Saídas e inventários
|
||||
</Link>
|
||||
<Link className="underline" href="/recepcoes">
|
||||
Recepções
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Analysis = {
|
||||
days: number;
|
||||
total_milli: number;
|
||||
daily_mean_milli: string;
|
||||
active_days: number;
|
||||
zero_balance_days: number;
|
||||
negative_balance_days: number;
|
||||
invalid_issue: boolean;
|
||||
opening_milli: number;
|
||||
movement_count: number;
|
||||
movement_types: Record<string, number>;
|
||||
convention: string;
|
||||
series: {
|
||||
date: string;
|
||||
consumption_milli: number;
|
||||
balance_milli: number;
|
||||
}[];
|
||||
};
|
||||
type HistoryRecord = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
stock_item_id: number;
|
||||
warehouse_id: number;
|
||||
purpose: string;
|
||||
period_from: string;
|
||||
period_until: string;
|
||||
quality: string;
|
||||
pattern: string;
|
||||
};
|
||||
snapshot: {
|
||||
item: { code: string; name: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
analysis: Analysis;
|
||||
contributors: number[];
|
||||
actor: { name: string };
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
actorId: number;
|
||||
canPrepare: boolean;
|
||||
canReview: boolean;
|
||||
records: HistoryRecord[];
|
||||
history: Record<string, HistoryRecord[]>;
|
||||
items: { id: number; code: string; name: string; unit: string }[];
|
||||
warehouses: { id: number; code: string; name: string }[];
|
||||
defaultPeriod: { from: string; until: string };
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
draft: 'Para conferência',
|
||||
returned: 'Devolvida para actualização',
|
||||
approved: 'Análise conferida',
|
||||
suspended: 'Análise suspensa',
|
||||
};
|
||||
const quality: Record<string, string> = {
|
||||
unknown: 'Cobertura por confirmar',
|
||||
complete: 'Cobertura completa sem procura oculta conhecida',
|
||||
incomplete: 'Período incompleto ou âmbito misto não separado',
|
||||
stockout: 'Ruptura ou procura não satisfeita conhecida',
|
||||
};
|
||||
const patterns: Record<string, string> = {
|
||||
unknown: 'Padrão por confirmar',
|
||||
regular: 'Regular — média simples aplicável',
|
||||
seasonal: 'Sazonal — exige método próprio',
|
||||
intermittent: 'Intermitente — exige método próprio',
|
||||
};
|
||||
const movementNames: Record<string, string> = {
|
||||
issue: 'Saídas de consumo',
|
||||
receipt_in: 'Recepções',
|
||||
transfer_in: 'Transferências de entrada',
|
||||
transfer_out: 'Transferências de saída',
|
||||
adjustment: 'Ajustes',
|
||||
adjustment_in: 'Ajustes de entrada por lote',
|
||||
adjustment_out: 'Ajustes de saída por lote',
|
||||
supplier_return: 'Devoluções ao fornecedor',
|
||||
write_off: 'Abates físicos',
|
||||
reversal_in: 'Estornos de entrada',
|
||||
reversal_out: 'Estornos de saída',
|
||||
count: 'Contagens',
|
||||
};
|
||||
const qty = (milli: number | string, mean = false) =>
|
||||
(Number(milli) / 1000).toLocaleString('pt-MZ', {
|
||||
maximumFractionDigits: mean ? 9 : 3,
|
||||
});
|
||||
|
||||
function Prepare({ props, old }: { props: Props; old?: HistoryRecord }) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'save',
|
||||
reason: '',
|
||||
data: {
|
||||
stock_item_id: old ? String(old.data.stock_item_id) : '',
|
||||
warehouse_id: old ? String(old.data.warehouse_id) : '',
|
||||
purpose: old?.data.purpose ?? 'internal',
|
||||
period_from: old?.data.period_from ?? props.defaultPeriod.from,
|
||||
period_until: old?.data.period_until ?? props.defaultPeriod.until,
|
||||
quality: old?.data.quality ?? 'unknown',
|
||||
pattern: old?.data.pattern ?? 'unknown',
|
||||
},
|
||||
});
|
||||
const select = (
|
||||
key: keyof typeof form.data.data,
|
||||
label: string,
|
||||
options: [string, string][],
|
||||
disabled = false,
|
||||
) => (
|
||||
<label className="grid gap-1 text-sm">
|
||||
{label}
|
||||
<select
|
||||
className={controlClass}
|
||||
required
|
||||
disabled={disabled}
|
||||
value={form.data.data[key]}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{options.map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/compras/fornecimento/history', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{select(
|
||||
'stock_item_id',
|
||||
'Artigo existente',
|
||||
[
|
||||
['', 'Seleccionar artigo'],
|
||||
...props.items.map(
|
||||
(i) =>
|
||||
[
|
||||
String(i.id),
|
||||
`${i.code} · ${i.name} (${i.unit})`,
|
||||
] as [string, string],
|
||||
),
|
||||
],
|
||||
Boolean(old),
|
||||
)}
|
||||
{select(
|
||||
'warehouse_id',
|
||||
'Local de armazenamento',
|
||||
[
|
||||
['', 'Seleccionar armazém'],
|
||||
...props.warehouses.map(
|
||||
(w) =>
|
||||
[String(w.id), `${w.code} · ${w.name}`] as [
|
||||
string,
|
||||
string,
|
||||
],
|
||||
),
|
||||
],
|
||||
Boolean(old),
|
||||
)}
|
||||
{select(
|
||||
'purpose',
|
||||
'Finalidade dos consumos analisados',
|
||||
[
|
||||
['internal', 'Uso interno e reposição'],
|
||||
['resale', 'Revenda'],
|
||||
],
|
||||
Boolean(old),
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Desde
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.data.period_from}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
period_from: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Até (dia completo)
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.data.period_until}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
period_until: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{select(
|
||||
'quality',
|
||||
'Cobertura do histórico',
|
||||
Object.entries(quality),
|
||||
)}
|
||||
{select(
|
||||
'pattern',
|
||||
'Padrão observado',
|
||||
Object.entries(patterns),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O sistema calcula os valores a partir dos movimentos. Confirme a
|
||||
finalidade e a cobertura do período; quando as saídas misturam
|
||||
finalidades sem separação, assinale cobertura incompleta.
|
||||
</p>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Evidências e fundamento da cobertura/padrão
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing || !props.ready}>
|
||||
Calcular e guardar análise para conferência
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Facts({
|
||||
record: r,
|
||||
series = false,
|
||||
}: {
|
||||
record: HistoryRecord;
|
||||
series?: boolean;
|
||||
}) {
|
||||
const a = r.snapshot.analysis;
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
{r.data.period_from} a {r.data.period_until} · {a.days} dias
|
||||
corridos ·{' '}
|
||||
{r.data.purpose === 'resale' ? 'Revenda' : 'Uso interno'}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<p>
|
||||
Consumo registado
|
||||
<br />
|
||||
<strong>
|
||||
{qty(a.total_milli)} {r.snapshot.item.unit}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
Média observada por dia
|
||||
<br />
|
||||
<strong>
|
||||
{qty(a.daily_mean_milli, true)} {r.snapshot.item.unit}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
Dias com consumo
|
||||
<br />
|
||||
<strong>
|
||||
{a.active_days} de {a.days}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
Saldo final nulo
|
||||
<br />
|
||||
<strong>{a.zero_balance_days} dias</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
{quality[r.data.quality]} · {patterns[r.data.pattern]}
|
||||
</p>
|
||||
{(a.invalid_issue ||
|
||||
a.negative_balance_days > 0 ||
|
||||
a.opening_milli < 0) && (
|
||||
<p className="font-medium">
|
||||
Foram detectadas inconsistências de movimentos ou saldos
|
||||
negativos. Reconcilie o razão antes de reutilizar a média.
|
||||
</p>
|
||||
)}
|
||||
{a.total_milli === 0 && (
|
||||
<p className="font-medium">
|
||||
Não há consumo registado neste período. Só é considerado
|
||||
consumo zero confirmado após conferência da cobertura
|
||||
completa; ausência de saídas não prova ausência de procura.
|
||||
</p>
|
||||
)}
|
||||
{r.state === 'approved' &&
|
||||
(r.data.quality !== 'complete' ||
|
||||
r.data.pattern !== 'regular') && (
|
||||
<p className="font-medium">
|
||||
A análise está conferida, mas não pode alimentar
|
||||
automaticamente uma média simples. Mantém-se o
|
||||
planeamento manual ou por necessidades conhecidas.
|
||||
</p>
|
||||
)}
|
||||
{series && (
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Consultar dias e movimentos considerados
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<p>{a.convention}</p>
|
||||
<p>
|
||||
{a.movement_count} movimentos no período ·{' '}
|
||||
{Object.entries(a.movement_types)
|
||||
.map(
|
||||
([type, count]) =>
|
||||
`${movementNames[type] ?? 'Outro movimento'}: ${count}`,
|
||||
)
|
||||
.join(' · ') || 'Sem movimentos no período'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Dias sem saída entram no denominador. Saldo final
|
||||
positivo não prova ausência de rupturas durante o
|
||||
dia; a conferência humana verifica lacunas e procura
|
||||
não satisfeita.
|
||||
</p>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Data</th>
|
||||
<th className="text-right">Consumo</th>
|
||||
<th className="text-right">
|
||||
Saldo final
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.series.map((day) => (
|
||||
<tr key={day.date}>
|
||||
<td>{day.date}</td>
|
||||
<td className="text-right">
|
||||
{qty(day.consumption_milli)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{qty(day.balance_milli)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Decision({
|
||||
props,
|
||||
record: r,
|
||||
}: {
|
||||
props: Props;
|
||||
record: HistoryRecord;
|
||||
}) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: r.record_key,
|
||||
version: r.version,
|
||||
action: '',
|
||||
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) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Actualizar análise e evidências
|
||||
</summary>
|
||||
<Prepare props={props} old={r} />
|
||||
</details>
|
||||
)}
|
||||
{props.canReview && independent && r.state !== 'suspended' && (
|
||||
<>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Decisão da minha responsabilidade
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.action}
|
||||
onChange={(e) =>
|
||||
form.setData('action', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar acção</option>
|
||||
{r.state === 'approved' ? (
|
||||
<option value="suspend">
|
||||
Suspender reutilização
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="approve">
|
||||
Conferir e aprovar análise
|
||||
</option>
|
||||
<option value="return">
|
||||
Devolver para correcção
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{form.data.action && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/compras/fornecimento/history', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{form.data.action === 'approve' && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={form.data.data.confirm_scope}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
confirm_scope: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Conferi os movimentos, a finalidade, a
|
||||
cobertura declarada, o padrão e a existência
|
||||
de rupturas ou procura oculta. A ausência de
|
||||
movimentos não foi tomada automaticamente
|
||||
como consumo zero.
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fundamento da decisão
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>
|
||||
Confirmar decisão
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function History(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Histórico de consumo" />
|
||||
<div className="space-y-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Armazém · Reposição"
|
||||
title="Histórico de consumo"
|
||||
description="Reutilizar as saídas reais do razão, analisar um período fechado e conferir a qualidade antes de o aplicar numa política."
|
||||
/>
|
||||
<Panel title="Responsabilidades">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O preparador selecciona artigo, local e período. Os
|
||||
valores são calculados pelo sistema. Outra pessoa
|
||||
confere cobertura e padrão. A política escolhe a análise
|
||||
aprovada e define o mínimo de dias e a idade máxima
|
||||
admissível; sazonalidade, intermitência e dados
|
||||
incompletos mantêm percurso manual.
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
<Panel title="Analisar período existente">
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Seleccionar artigo e período
|
||||
</summary>
|
||||
<Prepare props={props} />
|
||||
</details>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.length === 0 && (
|
||||
<Panel title="Sem análises">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda não foi conferido um período. O sistema mantém
|
||||
a previsão histórica indisponível; não atribui
|
||||
consumo zero por falta de dados.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((r) => (
|
||||
<Panel
|
||||
key={r.id}
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<Facts record={r} series />
|
||||
<p className="mt-3 text-sm">{r.reason}</p>
|
||||
<Decision props={props} record={r} />
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Histórico de análises e decisões
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-4">
|
||||
{props.history[r.record_key]?.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="border-l-2 pl-4 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
Versão {h.version} ·{' '}
|
||||
{states[h.state]} ·{' '}
|
||||
{h.snapshot.actor.name} ·{' '}
|
||||
{new Date(
|
||||
h.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
<p className="my-2">{h.reason}</p>
|
||||
<Facts record={h} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<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
|
||||
políticas seguem as permissões próprias desses menus.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
PageTitle,
|
||||
Panel,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Policy = {
|
||||
key: string;
|
||||
version: number;
|
||||
label: string;
|
||||
issue: string | null;
|
||||
quantity_milli?: number;
|
||||
delivery_on?: string | null;
|
||||
unit_price?: string | null;
|
||||
tax_rate?: string | null;
|
||||
unit?: string;
|
||||
};
|
||||
type Suggestion = {
|
||||
id: number;
|
||||
record_key: string;
|
||||
version: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
data: {
|
||||
policy_key: string;
|
||||
policy_version: number;
|
||||
quantity_milli: number;
|
||||
delivery_on: string;
|
||||
unit_price: string;
|
||||
tax_rate: string;
|
||||
manual_plan: boolean;
|
||||
price_source: string;
|
||||
tax_source: string;
|
||||
requisition_id?: number;
|
||||
requisition_number?: string;
|
||||
};
|
||||
snapshot: {
|
||||
forecast_history?: {
|
||||
available: boolean;
|
||||
reason?: string | null;
|
||||
period_from: string;
|
||||
period_until: string;
|
||||
version: number;
|
||||
analysis: { days: number; daily_mean_milli: string };
|
||||
};
|
||||
item: { code: string; name: string; unit: string };
|
||||
warehouse: { name: string };
|
||||
contributors: number[];
|
||||
reviewed_by?: number;
|
||||
actor: { name: string };
|
||||
prepared_on: string;
|
||||
balance: { quantity: number; version: number };
|
||||
events: {
|
||||
reservations: { date: string; quantity: number }[];
|
||||
receipts: { date: string; quantity: number }[];
|
||||
warnings: string[];
|
||||
};
|
||||
policy: { purpose: string; origin: string };
|
||||
projection: {
|
||||
lot_warning?: string;
|
||||
usable_milli?: number;
|
||||
available: boolean;
|
||||
risk_date?: string;
|
||||
request_date?: string;
|
||||
order_date?: string;
|
||||
series?: { date: string; balance_milli: number }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
actorId: number;
|
||||
ready: boolean;
|
||||
records: Suggestion[];
|
||||
policies: Policy[];
|
||||
canPrepare: boolean;
|
||||
canReview: boolean;
|
||||
canConvert: boolean;
|
||||
excess: Record<string, boolean>;
|
||||
costCenters: { id: number; code: string; name: string }[];
|
||||
requestLinks: Record<string, { id: number; number: string }>;
|
||||
history: Record<string, Suggestion[]>;
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
draft: 'Para conferência',
|
||||
returned: 'Devolvida para actualização',
|
||||
approved: 'Aprovada para requisitar',
|
||||
cancelled: 'Cancelada',
|
||||
converted: 'Requisição criada — em acompanhamento',
|
||||
closed: 'Acompanhamento encerrado',
|
||||
};
|
||||
const quantity = (value: number) =>
|
||||
(value / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||||
|
||||
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 form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: old?.record_key,
|
||||
version: old?.version ?? 0,
|
||||
action: 'save',
|
||||
reason: '',
|
||||
data: {
|
||||
policy_key: old?.data.policy_key ?? '',
|
||||
unit_price: option?.unit_price ?? old?.data.unit_price ?? '',
|
||||
tax_rate: option?.tax_rate ?? old?.data.tax_rate ?? '',
|
||||
quantity: old?.data.manual_plan
|
||||
? String(old.data.quantity_milli / 1000)
|
||||
: '',
|
||||
delivery_on: old?.data.manual_plan ? old.data.delivery_on : '',
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((input) => ({
|
||||
...input,
|
||||
data: {
|
||||
policy_key: input.data.policy_key,
|
||||
unit_price: input.data.unit_price || null,
|
||||
tax_rate: input.data.tax_rate || null,
|
||||
quantity_milli:
|
||||
input.data.quantity === ''
|
||||
? null
|
||||
: Math.round(
|
||||
Number(input.data.quantity) * 1000,
|
||||
),
|
||||
delivery_on: input.data.delivery_on || null,
|
||||
},
|
||||
}));
|
||||
form.post('/compras/fornecimento/suggestion', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo e local com política aprovada
|
||||
<select
|
||||
required
|
||||
disabled={Boolean(old)}
|
||||
className={controlClass}
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
const p = props.policies.find(
|
||||
(o) => o.key === e.target.value,
|
||||
);
|
||||
setSelected(e.target.value);
|
||||
form.setData('data', {
|
||||
policy_key: e.target.value,
|
||||
unit_price: p?.unit_price ?? '',
|
||||
tax_rate: p?.tax_rate ?? '',
|
||||
quantity: '',
|
||||
delivery_on: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar política existente</option>
|
||||
{props.policies.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
{old && !option && (
|
||||
<option value={old.data.policy_key}>
|
||||
{old.snapshot.item.code} · política de origem
|
||||
indisponível
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{option?.issue && (
|
||||
<p role="status" className="text-sm">
|
||||
{option.issue}
|
||||
</p>
|
||||
)}
|
||||
{option && !option.issue && (
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p>
|
||||
Proposta calculada:{' '}
|
||||
<strong>
|
||||
{quantity(option.quantity_milli ?? 0)} {option.unit}
|
||||
</strong>{' '}
|
||||
· Receber em{' '}
|
||||
<strong>
|
||||
{option.delivery_on ?? 'data manual por definir'}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
O servidor volta a conferir as fontes ao guardar. O
|
||||
cálculo não cria requisição, ordem ou movimento de
|
||||
stock.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estimativa por unidade em MZN
|
||||
<input
|
||||
className={controlClass}
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
required
|
||||
value={form.data.data.unit_price}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
unit_price: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option?.unit_price
|
||||
? 'Preenchida a partir do acordo; uma alteração fica fundamentada.'
|
||||
: 'Indique a estimativa e a fonte no fundamento abaixo.'}
|
||||
</span>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Taxa de IVA da estimativa (%)
|
||||
<input
|
||||
className={controlClass}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
required
|
||||
value={form.data.data.tax_rate}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
tax_rate: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Valor configurado da empresa quando disponível; confirme
|
||||
a aplicação ao artigo.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Propor quantidade ou data diferentes
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
Deixe em branco para reutilizar o cálculo actual. Alterações
|
||||
ficam identificadas e exigem conferência explícita.
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Quantidade manual
|
||||
<input
|
||||
className={controlClass}
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={form.data.data.quantity}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
quantity: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Data manual de recepção
|
||||
<input
|
||||
className={controlClass}
|
||||
type="date"
|
||||
value={form.data.data.delivery_on}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
delivery_on: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fundamento e fontes das estimativas
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={
|
||||
form.processing ||
|
||||
!props.ready ||
|
||||
!option ||
|
||||
Boolean(option.issue)
|
||||
}
|
||||
>
|
||||
Guardar sugestão para conferência
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Facts({
|
||||
record: r,
|
||||
detailed = false,
|
||||
}: {
|
||||
record: Suggestion;
|
||||
detailed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<p>
|
||||
Quantidade
|
||||
<br />
|
||||
<strong>
|
||||
{quantity(r.data.quantity_milli)} {r.snapshot.item.unit}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
Receber em
|
||||
<br />
|
||||
<strong>{r.data.delivery_on}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Estimativa por unidade
|
||||
<br />
|
||||
<strong>{r.data.unit_price} MZN</strong>
|
||||
</p>
|
||||
<p>
|
||||
IVA da estimativa
|
||||
<br />
|
||||
<strong>{r.data.tax_rate}%</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{r.snapshot.policy.purpose === 'resale'
|
||||
? 'Revenda'
|
||||
: 'Uso interno'}{' '}
|
||||
·{' '}
|
||||
{r.snapshot.policy.origin === 'import'
|
||||
? 'Importação directa'
|
||||
: 'Compra local'}{' '}
|
||||
· Política versão {r.data.policy_version} · Preparação de{' '}
|
||||
{r.snapshot.prepared_on}
|
||||
</p>
|
||||
<p>
|
||||
Preço:{' '}
|
||||
{r.data.price_source === 'agreement'
|
||||
? 'reutilizado do acordo'
|
||||
: 'estimativa manual fundamentada'}{' '}
|
||||
· IVA:{' '}
|
||||
{r.data.tax_source === 'company_default'
|
||||
? 'configuração da empresa'
|
||||
: 'taxa confirmada manualmente'}
|
||||
</p>
|
||||
{r.snapshot.projection.lot_warning && (
|
||||
<p className="font-medium">
|
||||
{r.snapshot.projection.lot_warning} Utilizável no cálculo:{' '}
|
||||
{quantity(r.snapshot.projection.usable_milli ?? 0)}.
|
||||
</p>
|
||||
)}
|
||||
{r.snapshot.forecast_history && (
|
||||
<p>
|
||||
{r.snapshot.forecast_history.available
|
||||
? `Média histórica preservada: ${r.snapshot.forecast_history.period_from} a ${r.snapshot.forecast_history.period_until}, ${r.snapshot.forecast_history.analysis.days} dias, versão ${r.snapshot.forecast_history.version}.`
|
||||
: `Histórico indisponível no cálculo: ${r.snapshot.forecast_history.reason}`}
|
||||
</p>
|
||||
)}
|
||||
{r.data.manual_plan && (
|
||||
<p className="font-medium">
|
||||
Quantidade/data propostas manualmente ou procura habitual
|
||||
incompleta.
|
||||
</p>
|
||||
)}
|
||||
{r.snapshot.events.warnings.map((w) => (
|
||||
<p key={w} className="font-medium">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
{detailed && (
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Base preservada da previsão
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>
|
||||
Stock físico no cálculo:{' '}
|
||||
{quantity(r.snapshot.balance.quantity)}{' '}
|
||||
{r.snapshot.item.unit}
|
||||
</p>
|
||||
<p>
|
||||
{r.snapshot.events.reservations.length} reservas
|
||||
pendentes · {r.snapshot.events.receipts.length}{' '}
|
||||
entregas utilizáveis confirmadas
|
||||
</p>
|
||||
<p>
|
||||
Iniciar requisição até{' '}
|
||||
{r.snapshot.projection.request_date ??
|
||||
'data manual'}{' '}
|
||||
· Emitir ordem até{' '}
|
||||
{r.snapshot.projection.order_date ?? 'data manual'}
|
||||
</p>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Data</th>
|
||||
<th className="text-right">
|
||||
Saldo sem a nova reposição
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.snapshot.projection.series?.map(
|
||||
(day) => (
|
||||
<tr key={day.date}>
|
||||
<td>{day.date}</td>
|
||||
<td className="text-right">
|
||||
{quantity(
|
||||
day.balance_milli,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Decisions({ props, record: r }: { props: Props; record: Suggestion }) {
|
||||
const form = useForm({
|
||||
operation_key: crypto.randomUUID(),
|
||||
record_key: r.record_key,
|
||||
version: r.version,
|
||||
action: '',
|
||||
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
|
||||
)
|
||||
choices.push(['convert', 'Criar requisição a partir desta aprovação']);
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{props.excess[r.record_key] && (
|
||||
<p className="text-sm font-medium">
|
||||
A quantidade excede o máximo no horizonte. Reveja
|
||||
quantidade, calendário ou política antes da aprovação.
|
||||
</p>
|
||||
)}
|
||||
{props.canPrepare && ['draft', 'returned'].includes(r.state) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Actualizar fontes e preparação
|
||||
</summary>
|
||||
<Prepare props={props} old={r} />
|
||||
</details>
|
||||
)}
|
||||
{choices.length > 0 && (
|
||||
<label className="grid gap-1 text-sm">
|
||||
Acção da minha responsabilidade
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.action}
|
||||
onChange={(e) => form.setData('action', e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar acção</option>
|
||||
{choices.map(([key, label]) => (
|
||||
<option value={key} key={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.data.action && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post('/compras/fornecimento/suggestion', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
{form.data.action === 'approve' &&
|
||||
(r.data.manual_plan ||
|
||||
r.snapshot.events.warnings.length > 0) && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
checked={form.data.data.confirm_exception}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
confirm_exception: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Conferi a proposta manual ou as pendências e
|
||||
fundamento a decisão abaixo.
|
||||
</label>
|
||||
)}
|
||||
{form.data.action === 'convert' && (
|
||||
<>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Centro de custo do meu departamento
|
||||
<select
|
||||
className={controlClass}
|
||||
required
|
||||
value={form.data.data.cost_center_id}
|
||||
onChange={(e) =>
|
||||
form.setData('data', {
|
||||
...form.data.data,
|
||||
cost_center_id: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar centro existente
|
||||
</option>
|
||||
{props.costCenters.map((center) => (
|
||||
<option
|
||||
value={center.id}
|
||||
key={center.id}
|
||||
>
|
||||
{center.code} · {center.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Será criado um único rascunho com o artigo do
|
||||
catálogo, quantidade, data e estimativas
|
||||
aprovadas. A requisição mantém as aprovações,
|
||||
controlo orçamental e etapas de compra
|
||||
habituais.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<label className="grid gap-1 text-sm">
|
||||
{form.data.action === 'convert'
|
||||
? 'Justificação da requisição'
|
||||
: 'Fundamento da decisão'}
|
||||
<textarea
|
||||
className="rounded-lg border p-3"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Confirmar</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Suggestions(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Sugestões de reposição" />
|
||||
<div className="space-y-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Compras · Reposição"
|
||||
title="Sugestões de reposição"
|
||||
description="Preparar, conferir e requisitar a partir dos dados já registados. Cada sugestão conserva o cálculo e as fontes utilizados."
|
||||
/>
|
||||
<Panel title="Responsabilidades">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O preparador reúne a proposta; outra pessoa confere. O
|
||||
solicitante autorizado converte a aprovação numa
|
||||
requisição do seu departamento. A pessoa que conferiu
|
||||
não se torna autora dessa requisição. Uma necessidade em
|
||||
acompanhamento mantém a ligação ao documento existente.
|
||||
</p>
|
||||
</Panel>
|
||||
{!props.ready && (
|
||||
<p role="status">Instalação do fornecimento pendente.</p>
|
||||
)}
|
||||
{props.canPrepare && (
|
||||
<Panel title="Preparar sugestão">
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Seleccionar artigo e política
|
||||
</summary>
|
||||
<Prepare props={props} />
|
||||
</details>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.length === 0 && (
|
||||
<Panel title="Sem sugestões">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O cálculo do calendário não cria documentos
|
||||
automaticamente. Um preparador autorizado pode
|
||||
guardar uma sugestão para conferência.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{props.records.map((r) => (
|
||||
<Panel
|
||||
key={r.id}
|
||||
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
|
||||
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
|
||||
>
|
||||
<Facts record={r} detailed />
|
||||
<p className="mt-3 text-sm">{r.reason}</p>
|
||||
{r.data.requisition_id && (
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{props.requestLinks[
|
||||
String(r.data.requisition_id)
|
||||
] ? (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/requisicoes/${r.data.requisition_id}`}
|
||||
>
|
||||
Abrir {r.data.requisition_number}
|
||||
</Link>
|
||||
) : (
|
||||
`Documento criado: ${r.data.requisition_number}. A consulta segue as permissões da requisição.`
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Decisions props={props} record={r} />
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm">
|
||||
Histórico de preparação e decisões
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-4">
|
||||
{props.history[r.record_key]?.map((h) => (
|
||||
<li
|
||||
className="border-l-2 pl-4 text-sm"
|
||||
key={h.id}
|
||||
>
|
||||
<p className="font-medium">
|
||||
Versão {h.version} ·{' '}
|
||||
{states[h.state]} ·{' '}
|
||||
{h.snapshot.actor.name} ·{' '}
|
||||
{new Date(
|
||||
h.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</p>
|
||||
<p className="my-2">{h.reason}</p>
|
||||
<Facts record={h} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</Panel>
|
||||
))}
|
||||
<p className="flex flex-wrap gap-5 text-sm">
|
||||
<Link
|
||||
className="underline"
|
||||
href="/compras/fornecimento?view=calendar"
|
||||
>
|
||||
Reposição e calendário
|
||||
</Link>
|
||||
<Link className="underline" href="/requisicoes">
|
||||
Requisições
|
||||
</Link>
|
||||
<Link
|
||||
className="underline"
|
||||
href="/administracao/parametrizacao"
|
||||
>
|
||||
Perfis e responsabilidades
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WorkContextState } from '@/types/work-context';
|
||||
|
||||
export default function WorkContext({
|
||||
context,
|
||||
}: {
|
||||
context: WorkContextState;
|
||||
}) {
|
||||
const selected = context.active ? context.options[context.active] : null;
|
||||
return (
|
||||
<>
|
||||
<Head title="Painel Inicial" />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="SGC"
|
||||
title="Painel Inicial"
|
||||
description="Escolha a área e a opção empresarial para trabalhar. A escolha mantém as suas permissões."
|
||||
/>
|
||||
{context.available.length === 0 && (
|
||||
<Panel title="Sem contexto autorizado">
|
||||
<p>
|
||||
Contacte os administradores da empresa. A conta e a
|
||||
parametrização continuam disponíveis quando
|
||||
autorizadas.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{['procurement', 'people'].map((area) => {
|
||||
const entries = context.available.filter(
|
||||
(key) => context.options[key].area === area,
|
||||
);
|
||||
if (!entries.length) return null;
|
||||
return (
|
||||
<Panel
|
||||
key={area}
|
||||
title={
|
||||
area === 'procurement'
|
||||
? 'Compras'
|
||||
: 'Pessoas e contratações'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{entries.length === 1 ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.post('/contexto', { area })
|
||||
}
|
||||
>
|
||||
Escolher área ·{' '}
|
||||
{context.options[entries[0]].label}
|
||||
</Button>
|
||||
) : (
|
||||
entries.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={
|
||||
context.active === key
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
onClick={() =>
|
||||
router.post('/contexto', {
|
||||
area,
|
||||
option: key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{context.options[key].label}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
{selected && (
|
||||
<Panel title={`${selected.areaLabel} · ${selected.label}`}>
|
||||
{selected.operational ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={
|
||||
selected.area === 'people'
|
||||
? '/pessoas'
|
||||
: '/requisicoes'
|
||||
}
|
||||
>
|
||||
Entrar na área
|
||||
</Link>
|
||||
</Button>
|
||||
{selected.area === 'people' && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pessoas/relatorios">
|
||||
Relatórios de pessoas e contratações
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
Contexto seleccionado. O percurso operacional
|
||||
desta opção ainda está em implementação; não
|
||||
estão disponíveis operações ou dados de outras
|
||||
opções.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { roleLabels, statusLabels } from '@/types/procurement';
|
||||
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
unread: number;
|
||||
view: string;
|
||||
notices: {
|
||||
total: number;
|
||||
data: {
|
||||
id: number;
|
||||
reference: string;
|
||||
role: string;
|
||||
kind: string;
|
||||
outcome: string | null;
|
||||
current_status: string;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
href: string;
|
||||
}[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
};
|
||||
export default function Notices({ ready, unread, view, notices }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Avisos de aprovação" />
|
||||
<div className="space-y-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="Compras"
|
||||
title="Avisos de aprovação"
|
||||
description="As suas intervenções pendentes e os resultados das requisições que submeteu. Ler um aviso não aprova o pedido."
|
||||
/>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href="/requisicoes/avisos">Todos os visíveis</Link>
|
||||
<Link href="/requisicoes/avisos?view=tasks">
|
||||
Intervenções pendentes
|
||||
</Link>
|
||||
<Link href="/requisicoes/avisos?view=outcomes">
|
||||
Resultados dos meus pedidos
|
||||
</Link>
|
||||
<Link href="/requisicoes/avisos?view=unread">
|
||||
Por ler ({unread})
|
||||
</Link>
|
||||
<Link href="/requisicoes?view=approvals">
|
||||
As minhas aprovações
|
||||
</Link>
|
||||
</div>
|
||||
<Panel
|
||||
title={
|
||||
view === 'unread'
|
||||
? 'Por ler'
|
||||
: view === 'outcomes'
|
||||
? 'Resultados dos meus pedidos'
|
||||
: view === 'tasks'
|
||||
? 'Intervenções pendentes'
|
||||
: 'Avisos internos'
|
||||
}
|
||||
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."
|
||||
>
|
||||
{!ready ? (
|
||||
<p>Instalação dos avisos internos pendente.</p>
|
||||
) : notices.data.length === 0 ? (
|
||||
<p>Sem avisos neste âmbito.</p>
|
||||
) : (
|
||||
notices.data.map((notice) => (
|
||||
<div
|
||||
className="mb-3 rounded-md border p-4"
|
||||
key={notice.id}
|
||||
>
|
||||
<Link
|
||||
className="font-medium underline"
|
||||
href={notice.href}
|
||||
>
|
||||
{notice.reference} ·{' '}
|
||||
{roleLabels[notice.role] ?? notice.role}
|
||||
</Link>
|
||||
{notice.kind === 'outcome' && (
|
||||
<p className="mt-2 text-sm">
|
||||
Resultado registado:{' '}
|
||||
{notice.outcome === 'approved'
|
||||
? 'requisição aprovada'
|
||||
: 'requisição rejeitada'}
|
||||
. Estado actual:{' '}
|
||||
{statusLabels[notice.current_status] ??
|
||||
notice.current_status}
|
||||
. Consulte o processo para conhecer o
|
||||
fundamento e os passos seguintes.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm">
|
||||
{notice.read_at ? 'Lido' : 'Por ler'} ·
|
||||
Criado em {notice.created_at}
|
||||
</p>
|
||||
{!notice.read_at && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/requisicoes/avisos/${notice.id}/ler`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Marcar como lido
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{notices.prev_page_url && (
|
||||
<Link href={notices.prev_page_url}>Anterior</Link>
|
||||
)}
|
||||
{notices.next_page_url && (
|
||||
<Link href={notices.next_page_url}>Seguinte</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export type ReceiptLineProgress = {
|
||||
rejected_milli: number;
|
||||
delivery_pending_milli: number;
|
||||
acceptance_pending_milli: number;
|
||||
supplier_returned_milli?: number;
|
||||
effective_accepted_milli?: number;
|
||||
};
|
||||
export type ReceiptProgress = {
|
||||
lines: ReceiptLineProgress[];
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ApprovalStep = {
|
||||
};
|
||||
export type Requisition = {
|
||||
id: number;
|
||||
process_version?: number;
|
||||
number: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type WorkContextState = {
|
||||
enforced: boolean;
|
||||
active: string | null;
|
||||
available: string[];
|
||||
purchased: string[];
|
||||
paths: string[];
|
||||
sharedPaths: string[];
|
||||
permissions: string[];
|
||||
options: Record<
|
||||
string,
|
||||
{ area: string; areaLabel: string; label: string; operational: boolean }
|
||||
>;
|
||||
};
|
||||
Reference in New Issue
Block a user