Files
compras/resources/js/pages/stock/operations.tsx
T
2026-11-02 08:08:05 +02:00

786 lines
37 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 { LotCountFields, countTotal } from '@/components/inventory-lot-count';
import type {
CountInput,
LotCountSource,
} from '@/components/inventory-lot-count';
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;
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 = {
items: StockItem[];
warehouses: Warehouse[];
users: { id: number; name: string }[];
balances: {
stock_item_id: number;
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 }[];
};
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,
correctedOperations = {},
canAdjust = false,
lotCounts = [],
demo = false,
equipment = [],
}: Props) {
const [message, setMessage] = useState('');
const form = useForm({
kind: canOperate ? 'issue' : 'adjustment',
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,
);
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" />
<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="Armazém regista as contagens. Ajustes por lote exigem pessoa independente com competência própria de conferência."
>
{canOperate || canAdjust ? (
<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,
...(d.kind === 'count' && countSource
? {
lot_version: countSource.hash,
lot_counts: countRows,
quantity:
countTotal(countRows) ??
'0.000',
}
: {}),
}));
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)
.filter(
([k]) =>
canOperate ||
k === 'adjustment',
)
.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>
{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
<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) =>
canOperate ||
!!c.snapshot.lot_count,
)
.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>
) : 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'
? '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 ||
(form.data.kind === 'adjustment' &&
!!balance?.lot_control &&
!canAdjust)
}
>
{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] ??
{
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(
'pt-MZ',
)}
</time>
</div>
<p className="mt-2">
{o.snapshot.stock_item.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}`}
</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>
{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:{' '}
{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 }}
/>
);
}