212 lines
8.7 KiB
TypeScript
212 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|