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