542 lines
24 KiB
TypeScript
542 lines
24 KiB
TypeScript
import { Head, useForm } from '@inertiajs/react';
|
||
import { useState } from 'react';
|
||
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
|
||
import {
|
||
PageTitle,
|
||
Panel,
|
||
FormErrors,
|
||
controlClass,
|
||
} from '@/components/procurement';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { quantity } from '@/types/inventory';
|
||
import type { StockItem, Warehouse, Page } from '@/types/inventory';
|
||
|
||
type Operation = {
|
||
id: number;
|
||
kind: string;
|
||
reference: string;
|
||
reason: string;
|
||
destination?: string;
|
||
quantity_milli: number;
|
||
theoretical_milli: number;
|
||
stock_item_id: number;
|
||
warehouse_id: number;
|
||
recorded_at: string;
|
||
snapshot: {
|
||
actor: { name: string };
|
||
responsible: { name: string };
|
||
stock_item: { name: string; unit: string };
|
||
warehouse: { name: string };
|
||
destination_warehouse?: { name: string } | null;
|
||
equipment?: { code: string; description: string } | null;
|
||
};
|
||
};
|
||
type Props = {
|
||
items: StockItem[];
|
||
warehouses: Warehouse[];
|
||
users: { id: number; name: string }[];
|
||
balances: {
|
||
stock_item_id: number;
|
||
warehouse_id: number;
|
||
quantity: number;
|
||
version: number;
|
||
}[];
|
||
operations: Page<Operation>;
|
||
counts: Operation[];
|
||
canOperate: boolean;
|
||
demo?: boolean;
|
||
equipment?: { id: number; code: string; description: string }[];
|
||
};
|
||
const kinds: Record<string, string> = {
|
||
issue: 'Saída',
|
||
transfer: 'Transferência',
|
||
count: 'Contagem física',
|
||
adjustment: 'Ajuste de inventário',
|
||
};
|
||
export default function Operations({
|
||
items,
|
||
warehouses,
|
||
users,
|
||
balances,
|
||
operations,
|
||
counts,
|
||
canOperate,
|
||
demo = false,
|
||
equipment = [],
|
||
}: Props) {
|
||
const [message, setMessage] = useState('');
|
||
const form = useForm({
|
||
kind: 'issue',
|
||
reference: '',
|
||
idempotency_key: crypto.randomUUID(),
|
||
stock_item_id: String(items[0]?.id ?? ''),
|
||
warehouse_id: String(warehouses[0]?.id ?? ''),
|
||
destination_warehouse_id: '',
|
||
responsible_id: '',
|
||
destination: '',
|
||
reason: '',
|
||
quantity: '',
|
||
count_id: '',
|
||
ledger_version: 0,
|
||
equipment_id: '',
|
||
});
|
||
const balance = balances.find(
|
||
(b) =>
|
||
String(b.stock_item_id) === form.data.stock_item_id &&
|
||
String(b.warehouse_id) === form.data.warehouse_id,
|
||
);
|
||
return (
|
||
<>
|
||
<Head title="Operações de stock" />
|
||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
||
<PageTitle
|
||
eyebrow="Armazém"
|
||
title="Saídas, transferências e inventários"
|
||
description="Movimentos com fundamento, contagens físicas e histórico preservado."
|
||
/>
|
||
<InventoryNotice />
|
||
{canOperate && form.data.kind === 'issue' && (
|
||
<label className="grid gap-2 text-sm">
|
||
Equipamento de destino (obrigatório para peças
|
||
associadas)
|
||
<select
|
||
className={controlClass}
|
||
value={form.data.equipment_id}
|
||
onChange={(e) =>
|
||
form.setData('equipment_id', e.target.value)
|
||
}
|
||
>
|
||
<option value="">Sem equipamento</option>
|
||
{equipment.map((e) => (
|
||
<option key={e.id} value={e.id}>
|
||
{e.code} · {e.description}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{demo && (
|
||
<p className="rounded-lg border bg-teal-50 p-4 text-sm text-teal-900">
|
||
Demonstração pública com dados fictícios. O formulário
|
||
apenas simula a revisão.
|
||
</p>
|
||
)}
|
||
<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."
|
||
>
|
||
{canOperate ? (
|
||
<form
|
||
className="grid gap-4 sm:grid-cols-2"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (demo) {
|
||
setMessage(
|
||
'Simulação revista. Nenhum movimento real foi criado.',
|
||
);
|
||
return;
|
||
}
|
||
form.transform((d) => ({
|
||
...d,
|
||
ledger_version: balance?.version ?? 0,
|
||
}));
|
||
form.post('/stock/operacoes', {
|
||
onSuccess: () => {
|
||
form.setData({
|
||
...form.data,
|
||
idempotency_key:
|
||
crypto.randomUUID(),
|
||
reference: '',
|
||
quantity: '',
|
||
reason: '',
|
||
});
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<label className="grid gap-2 text-sm">
|
||
Operação
|
||
<select
|
||
className={controlClass}
|
||
value={form.data.kind}
|
||
onChange={(e) =>
|
||
form.setData('kind', e.target.value)
|
||
}
|
||
>
|
||
{Object.entries(kinds).map(([k, v]) => (
|
||
<option key={k} value={k}>
|
||
{v}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="grid gap-2 text-sm">
|
||
Referência única do documento
|
||
<Input
|
||
required
|
||
value={form.data.reference}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'reference',
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="grid gap-2 text-sm">
|
||
Artigo
|
||
<select
|
||
className={controlClass}
|
||
value={form.data.stock_item_id}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'stock_item_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
{items.map((i) => (
|
||
<option key={i.id} value={i.id}>
|
||
{i.code} · {i.name} ({i.unit})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="grid gap-2 text-sm">
|
||
Armazém de origem / contagem
|
||
<select
|
||
className={controlClass}
|
||
value={form.data.warehouse_id}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'warehouse_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
{warehouses.map((w) => (
|
||
<option key={w.id} value={w.id}>
|
||
{w.code} · {w.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<p className="text-sm font-semibold sm:col-span-2">
|
||
Stock teórico:{' '}
|
||
{quantity(balance?.quantity ?? 0)} · versão{' '}
|
||
{balance?.version ?? 0}. Confirme a contagem
|
||
física sobre este saldo; movimentos posteriores
|
||
exigem nova contagem.
|
||
</p>
|
||
{form.data.kind === 'transfer' && (
|
||
<label className="grid gap-2 text-sm">
|
||
Armazém de destino
|
||
<select
|
||
required
|
||
className={controlClass}
|
||
value={
|
||
form.data.destination_warehouse_id
|
||
}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'destination_warehouse_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
<option value="">Seleccionar</option>
|
||
{warehouses
|
||
.filter(
|
||
(w) =>
|
||
String(w.id) !==
|
||
form.data.warehouse_id,
|
||
)
|
||
.map((w) => (
|
||
<option key={w.id} value={w.id}>
|
||
{w.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{form.data.kind === 'issue' && (
|
||
<label className="grid gap-2 text-sm">
|
||
Destino (departamento, requisição ou
|
||
utilização)
|
||
<Input
|
||
required
|
||
value={form.data.destination}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'destination',
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
{form.data.kind === 'adjustment' ? (
|
||
<label className="grid gap-2 text-sm">
|
||
Contagem a ajustar
|
||
<select
|
||
required
|
||
className={controlClass}
|
||
value={form.data.count_id}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'count_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
<option value="">
|
||
Seleccionar contagem
|
||
</option>
|
||
{counts
|
||
.filter(
|
||
(c) =>
|
||
String(c.warehouse_id) ===
|
||
form.data
|
||
.warehouse_id &&
|
||
String(c.stock_item_id) ===
|
||
form.data.stock_item_id,
|
||
)
|
||
.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.reference}: contado{' '}
|
||
{quantity(c.quantity_milli)}
|
||
, teórico{' '}
|
||
{quantity(
|
||
c.theoretical_milli,
|
||
)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : (
|
||
<label className="grid gap-2 text-sm">
|
||
{form.data.kind === 'count'
|
||
? 'Quantidade física contada (inclui zero)'
|
||
: 'Quantidade'}
|
||
<Input
|
||
required
|
||
type="number"
|
||
min={
|
||
form.data.kind === 'count'
|
||
? 0
|
||
: 0.001
|
||
}
|
||
step="0.001"
|
||
value={form.data.quantity}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'quantity',
|
||
e.target.value,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
<label className="grid gap-2 text-sm">
|
||
Responsável
|
||
<select
|
||
required
|
||
className={controlClass}
|
||
value={form.data.responsible_id}
|
||
onChange={(e) =>
|
||
form.setData(
|
||
'responsible_id',
|
||
e.target.value,
|
||
)
|
||
}
|
||
>
|
||
<option value="">Seleccionar</option>
|
||
{users.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="grid gap-2 text-sm sm:col-span-2">
|
||
Fundamento / explicação da divergência
|
||
<textarea
|
||
required
|
||
className={controlClass}
|
||
value={form.data.reason}
|
||
onChange={(e) =>
|
||
form.setData('reason', e.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
<div className="sm:col-span-2">
|
||
<FormErrors errors={form.errors} />
|
||
<Button disabled={form.processing}>
|
||
{demo
|
||
? 'Rever simulação'
|
||
: 'Confirmar operação'}
|
||
</Button>
|
||
<p role="status" className="mt-3 text-sm">
|
||
{message}
|
||
</p>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<p className="text-sm">
|
||
Consulta do histórico. O seu perfil não executa
|
||
operações de armazém.
|
||
</p>
|
||
)}
|
||
</Panel>
|
||
<Panel
|
||
title="Histórico auditado"
|
||
description="Contagens não alteram o saldo. Ajustes usam a diferença exacta de uma contagem válida; transferências registam saída e entrada na mesma transacção."
|
||
>
|
||
<div className="space-y-4">
|
||
{operations.data.map((o) => (
|
||
<article
|
||
key={o.id}
|
||
className="rounded-lg border p-4 text-sm"
|
||
>
|
||
<div className="flex flex-wrap justify-between gap-2">
|
||
<strong>
|
||
{o.reference} · {kinds[o.kind]}
|
||
</strong>
|
||
<time>
|
||
{new Date(o.recorded_at).toLocaleString(
|
||
'pt-MZ',
|
||
)}
|
||
</time>
|
||
</div>
|
||
<p className="mt-2">
|
||
{o.snapshot.stock_item.name} ·{' '}
|
||
{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}`}
|
||
</p>
|
||
{(o.kind === 'count' ||
|
||
o.kind === 'adjustment') && (
|
||
<p>
|
||
Teórico: {quantity(o.theoretical_milli)}{' '}
|
||
· Contado: {quantity(o.quantity_milli)}{' '}
|
||
· Divergência:{' '}
|
||
{quantity(
|
||
o.quantity_milli -
|
||
o.theoretical_milli,
|
||
)}
|
||
</p>
|
||
)}
|
||
<p className="mt-2">{o.reason}</p>
|
||
{o.snapshot.equipment && (
|
||
<p className="mt-2">
|
||
Equipamento no momento da saída:{' '}
|
||
{o.snapshot.equipment.code} ·{' '}
|
||
{o.snapshot.equipment.description}
|
||
</p>
|
||
)}
|
||
<p className="mt-2 text-muted-foreground">
|
||
Registado por {o.snapshot.actor.name} ·
|
||
Responsável: {o.snapshot.responsible.name}
|
||
</p>
|
||
</article>
|
||
))}
|
||
{operations.data.length === 0 && (
|
||
<p className="text-sm">Sem operações registadas.</p>
|
||
)}
|
||
</div>
|
||
<InventoryPagination page={operations} />
|
||
</Panel>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function OperationsDemo() {
|
||
const snapshot = {
|
||
actor: { name: 'Operador fictício de armazém' },
|
||
responsible: { name: 'Responsável fictício de produção' },
|
||
stock_item: { name: 'Papel A4 de exemplo', unit: 'resma' },
|
||
warehouse: { name: 'Central fictício' },
|
||
destination_warehouse: null,
|
||
};
|
||
const base = {
|
||
stock_item_id: 1,
|
||
warehouse_id: 1,
|
||
recorded_at: '2026-10-28T10:00:00+02:00',
|
||
snapshot,
|
||
theoretical_milli: 12000,
|
||
};
|
||
const data: Operation[] = [
|
||
{
|
||
...base,
|
||
id: 3,
|
||
kind: 'count',
|
||
reference: 'INV-DEMO-003',
|
||
quantity_milli: 11000,
|
||
reason: 'Contagem física fictícia: menos uma resma que o saldo teórico.',
|
||
},
|
||
{
|
||
...base,
|
||
id: 2,
|
||
kind: 'transfer',
|
||
reference: 'TRF-DEMO-002',
|
||
quantity_milli: 3000,
|
||
reason: 'Reposição do armazém secundário.',
|
||
snapshot: {
|
||
...snapshot,
|
||
destination_warehouse: { name: 'Secundário fictício' },
|
||
},
|
||
},
|
||
{
|
||
...base,
|
||
id: 1,
|
||
kind: 'issue',
|
||
reference: 'SAI-DEMO-001',
|
||
quantity_milli: 2000,
|
||
destination: 'Produção fictícia · REQ-DEMO-001',
|
||
reason: 'Consumo de material na produção.',
|
||
},
|
||
{
|
||
...base,
|
||
id: 4,
|
||
kind: 'adjustment',
|
||
reference: 'AJ-DEMO-004',
|
||
quantity_milli: 11000,
|
||
reason: 'Exemplo independente de ajuste de −1 resma: divergência física confirmada e fundamentada na contagem INV-DEMO-004.',
|
||
},
|
||
];
|
||
return (
|
||
<Operations
|
||
demo
|
||
canOperate
|
||
items={[
|
||
{
|
||
id: 1,
|
||
code: 'PAP-DEMO',
|
||
name: 'Papel A4 fictício',
|
||
unit: 'resma',
|
||
},
|
||
]}
|
||
warehouses={[
|
||
{ id: 1, code: 'CENTRAL', name: 'Central fictício' },
|
||
{ id: 2, code: 'SEC', name: 'Secundário fictício' },
|
||
]}
|
||
users={[{ id: 1, name: 'Responsável fictício' }]}
|
||
balances={[
|
||
{
|
||
stock_item_id: 1,
|
||
warehouse_id: 1,
|
||
quantity: 12000,
|
||
version: 3,
|
||
},
|
||
]}
|
||
counts={[data[0]]}
|
||
operations={{ data, prev_page_url: null, next_page_url: null }}
|
||
/>
|
||
);
|
||
}
|