818 lines
58 KiB
TypeScript
818 lines
58 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { InventoryNotice, ReceiptProgressTable } from '@/components/inventory';
|
|
import {
|
|
FormErrors,
|
|
PageTitle,
|
|
Panel,
|
|
controlClass,
|
|
} from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { quantity, qualityStates, receiptStates } from '@/types/inventory';
|
|
import type {
|
|
Receipt,
|
|
ReceiptProgress,
|
|
StockItem,
|
|
Warehouse,
|
|
} from '@/types/inventory';
|
|
|
|
type Line = {
|
|
include: boolean;
|
|
purchase_order_item_id: number;
|
|
received_quantity: string;
|
|
accepted_quantity: string;
|
|
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 };
|
|
supplier_name: string;
|
|
progress: ReceiptProgress;
|
|
receipts: Receipt[];
|
|
warehouses: Warehouse[];
|
|
stockItems: StockItem[];
|
|
canConfirm: boolean;
|
|
lotScopes: {
|
|
stock_item_id: number;
|
|
warehouse_id: number;
|
|
quarantine_on_receipt: boolean;
|
|
}[];
|
|
};
|
|
export default function ReceiveOrder({
|
|
order,
|
|
supplier_name,
|
|
progress,
|
|
receipts,
|
|
warehouses,
|
|
stockItems,
|
|
canConfirm,
|
|
lotScopes,
|
|
}: Props) {
|
|
const form = useForm({
|
|
idempotency_key: crypto.randomUUID(),
|
|
delivery_note: '',
|
|
received_on: '',
|
|
warehouse_id: '',
|
|
items: progress.lines.map((line) => ({
|
|
include: false,
|
|
purchase_order_item_id: line.id,
|
|
received_quantity: '',
|
|
accepted_quantity: '',
|
|
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>) =>
|
|
form.setData(
|
|
'items',
|
|
form.data.items.map((line, i) =>
|
|
i === index ? { ...line, ...updates } : line,
|
|
),
|
|
);
|
|
const outstanding = progress.lines.some(
|
|
(line) => line.delivery_pending_milli > 0,
|
|
);
|
|
return (
|
|
<>
|
|
<Head title={`Receber ${order.number}`} />
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow={order.number}
|
|
title="Conferir entrega"
|
|
description={supplier_name}
|
|
/>
|
|
<InventoryNotice />
|
|
<Panel title="Quantidades da ordem">
|
|
<ReceiptProgressTable progress={progress} />
|
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
As quantidades rejeitadas contam como conferidas, mas
|
|
não entram em stock. Substituições ou reposições após
|
|
rejeição não são autorizadas automaticamente.
|
|
</p>
|
|
</Panel>
|
|
{canConfirm && outstanding ? (
|
|
<Panel
|
|
title="Confirmar recepção"
|
|
description="A confirmação é definitiva e auditada. O emitente da ordem não pode confirmar a recepção."
|
|
>
|
|
<form
|
|
className="space-y-5"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
form.transform((data) => ({
|
|
...data,
|
|
warehouse_id: data.warehouse_id || null,
|
|
items: data.items
|
|
.filter((line) => line.include)
|
|
.map(
|
|
({
|
|
include: _include,
|
|
...line
|
|
}) => ({
|
|
...line,
|
|
stock_item_id:
|
|
line.stock_item_id || null,
|
|
}),
|
|
),
|
|
}));
|
|
form.post(`/ordens/${order.id}/recepcoes`);
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="receipt-note">
|
|
Guia de remessa
|
|
</Label>
|
|
<Input
|
|
id="receipt-note"
|
|
required
|
|
maxLength={100}
|
|
value={form.data.delivery_note}
|
|
onChange={(e) =>
|
|
form.setData(
|
|
'delivery_note',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="receipt-date">
|
|
Data da recepção
|
|
</Label>
|
|
<Input
|
|
id="receipt-date"
|
|
type="date"
|
|
required
|
|
value={form.data.received_on}
|
|
onChange={(e) =>
|
|
form.setData(
|
|
'received_on',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="receipt-warehouse">
|
|
Armazém (para bens aceites)
|
|
</Label>
|
|
<select
|
|
id="receipt-warehouse"
|
|
className={controlClass}
|
|
value={form.data.warehouse_id}
|
|
onChange={(e) =>
|
|
form.setData(
|
|
'warehouse_id',
|
|
e.target.value,
|
|
)
|
|
}
|
|
>
|
|
<option value="">Seleccione</option>
|
|
{warehouses.map((w) => (
|
|
<option key={w.id} value={w.id}>
|
|
{w.code} · {w.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-5">
|
|
{progress.lines.map(
|
|
(source, index) =>
|
|
source.delivery_pending_milli > 0 && (
|
|
<fieldset
|
|
className="rounded-xl border p-4"
|
|
key={source.id}
|
|
>
|
|
<legend className="px-2 text-sm font-semibold">
|
|
{source.description} · Por
|
|
entregar{' '}
|
|
{quantity(
|
|
source.delivery_pending_milli,
|
|
)}{' '}
|
|
{source.unit}
|
|
</legend>
|
|
<label className="mb-4 flex items-center gap-3 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
form.data.items[
|
|
index
|
|
].include
|
|
}
|
|
onChange={(e) =>
|
|
change(index, {
|
|
include:
|
|
e.target
|
|
.checked,
|
|
})
|
|
}
|
|
/>
|
|
Incluir nesta recepção
|
|
</label>
|
|
{form.data.items[index]
|
|
.include && (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`received-${source.id}`}
|
|
>
|
|
Quantidade
|
|
recebida
|
|
</Label>
|
|
<Input
|
|
id={`received-${source.id}`}
|
|
type="number"
|
|
required
|
|
min="0.001"
|
|
step="0.001"
|
|
max={
|
|
source.delivery_pending_milli /
|
|
1000
|
|
}
|
|
value={
|
|
form.data
|
|
.items[
|
|
index
|
|
]
|
|
.received_quantity
|
|
}
|
|
onChange={(e) =>
|
|
change(
|
|
index,
|
|
{
|
|
received_quantity:
|
|
e
|
|
.target
|
|
.value,
|
|
...(form
|
|
.data
|
|
.items[
|
|
index
|
|
]
|
|
.quality_status ===
|
|
'compliant'
|
|
? {
|
|
accepted_quantity:
|
|
e
|
|
.target
|
|
.value,
|
|
}
|
|
: {}),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`quality-${source.id}`}
|
|
>
|
|
Qualidade
|
|
</Label>
|
|
<select
|
|
id={`quality-${source.id}`}
|
|
className={
|
|
controlClass
|
|
}
|
|
value={
|
|
form.data
|
|
.items[
|
|
index
|
|
]
|
|
.quality_status
|
|
}
|
|
onChange={(e) =>
|
|
change(
|
|
index,
|
|
{
|
|
quality_status:
|
|
e
|
|
.target
|
|
.value,
|
|
...(e
|
|
.target
|
|
.value ===
|
|
'nonconforming'
|
|
? {
|
|
accepted_quantity:
|
|
'0',
|
|
stock_item_id:
|
|
'',
|
|
}
|
|
: e
|
|
.target
|
|
.value ===
|
|
'compliant'
|
|
? {
|
|
accepted_quantity:
|
|
form
|
|
.data
|
|
.items[
|
|
index
|
|
]
|
|
.received_quantity,
|
|
}
|
|
: {}),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
{Object.entries(
|
|
qualityStates,
|
|
).map(
|
|
([
|
|
value,
|
|
label,
|
|
]) => (
|
|
<option
|
|
key={
|
|
value
|
|
}
|
|
value={
|
|
value
|
|
}
|
|
>
|
|
{
|
|
label
|
|
}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`accepted-${source.id}`}
|
|
>
|
|
Quantidade
|
|
aceite
|
|
</Label>
|
|
<Input
|
|
id={`accepted-${source.id}`}
|
|
type="number"
|
|
required
|
|
min="0"
|
|
step="0.001"
|
|
readOnly={
|
|
form.data
|
|
.items[
|
|
index
|
|
]
|
|
.quality_status !==
|
|
'reservations'
|
|
}
|
|
value={
|
|
form.data
|
|
.items[
|
|
index
|
|
]
|
|
.accepted_quantity
|
|
}
|
|
onChange={(e) =>
|
|
change(
|
|
index,
|
|
{
|
|
accepted_quantity:
|
|
e
|
|
.target
|
|
.value,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
{source.purchase_type !==
|
|
'service' ? (
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`stock-${source.id}`}
|
|
>
|
|
Artigo de
|
|
stock ·
|
|
unidade{' '}
|
|
{
|
|
source.unit
|
|
}
|
|
</Label>
|
|
<select
|
|
id={`stock-${source.id}`}
|
|
className={
|
|
controlClass
|
|
}
|
|
value={
|
|
form
|
|
.data
|
|
.items[
|
|
index
|
|
]
|
|
.stock_item_id
|
|
}
|
|
onChange={(
|
|
e,
|
|
) =>
|
|
change(
|
|
index,
|
|
{
|
|
stock_item_id:
|
|
e
|
|
.target
|
|
.value,
|
|
},
|
|
)
|
|
}
|
|
>
|
|
<option value="">
|
|
Seleccione
|
|
para a
|
|
quantidade
|
|
aceite
|
|
</option>
|
|
{stockItems
|
|
.filter(
|
|
(
|
|
item,
|
|
) =>
|
|
item.unit.toLowerCase() ===
|
|
source.unit.toLowerCase(),
|
|
)
|
|
.map(
|
|
(
|
|
item,
|
|
) => (
|
|
<option
|
|
key={
|
|
item.id
|
|
}
|
|
value={
|
|
item.id
|
|
}
|
|
>
|
|
{
|
|
item.code
|
|
}{' '}
|
|
·{' '}
|
|
{
|
|
item.name
|
|
}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<p className="self-center text-sm text-muted-foreground">
|
|
Serviço: a
|
|
aceitação não
|
|
cria existências
|
|
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}`}
|
|
>
|
|
Observações e
|
|
divergências
|
|
</Label>
|
|
<textarea
|
|
id={`notes-${source.id}`}
|
|
className={`${controlClass} h-24 py-3`}
|
|
required={
|
|
form.data
|
|
.items[
|
|
index
|
|
]
|
|
.quality_status !==
|
|
'compliant'
|
|
}
|
|
maxLength={3000}
|
|
value={
|
|
form.data
|
|
.items[
|
|
index
|
|
].notes
|
|
}
|
|
onChange={(e) =>
|
|
change(
|
|
index,
|
|
{
|
|
notes: e
|
|
.target
|
|
.value,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</fieldset>
|
|
),
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Configure previamente os armazéns e artigos em
|
|
Stock. Não existem conversões automáticas de
|
|
unidade, tolerâncias de excesso ou saldo inicial
|
|
manual.
|
|
</p>
|
|
<Button disabled={form.processing}>
|
|
Confirmar recepção e entradas aceites
|
|
</Button>
|
|
</form>
|
|
</Panel>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{outstanding
|
|
? 'A confirmação exige o perfil Recepção / Armazém e uma pessoa distinta do emitente.'
|
|
: 'Não existem quantidades de entrega pendentes nesta ordem.'}
|
|
</p>
|
|
)}
|
|
<Panel title="Recepções anteriores">
|
|
<div className="divide-y">
|
|
{receipts.map((receipt) => (
|
|
<Link
|
|
key={receipt.id}
|
|
href={`/recepcoes/${receipt.id}`}
|
|
className="flex flex-wrap justify-between gap-3 py-3 text-sm"
|
|
>
|
|
<span>
|
|
{receipt.number} · Guia{' '}
|
|
{receipt.delivery_note}
|
|
</span>
|
|
<span>{receiptStates[receipt.status]}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
{receipts.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Ainda não há recepções confirmadas.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
ReceiveOrder.layout = {
|
|
breadcrumbs: [
|
|
{ title: 'Recepção', href: '/recepcoes' },
|
|
{ title: 'Conferir ordem', href: '#' },
|
|
],
|
|
};
|