Files
compras/resources/js/pages/stock/operations.tsx
T

542 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }}
/>
);
}