Files
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

740 lines
37 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import type { WarehouseHealth } from '@/types/inventory';
type Line = {
item: { code: string; name: string; unit: string };
quantity_milli: number;
destination: string;
equipment: { code: string; description: string } | null;
};
type Event = {
id: number;
version: number;
action: string;
actor_id: number;
reason: string;
created_at: string;
data: {
actor?: { id: number; name: string };
lines?: {
index: number;
quantity_milli: number;
stock_operation_id: number;
}[];
delivery_event_id?: number;
};
};
type Props = {
canViewReturns?: boolean;
supplyTrace: {
ready: boolean;
groups: {
item: { id: number; code: string; name: string; unit: string };
line_indexes: number[];
origin_need_id: number;
current_need_id: number;
need_state: string;
message: string;
unreserved_milli: number;
restricted: boolean;
purchases: {
id: number;
number: string;
status: string;
href: string;
next_task: string | null;
orders: {
id: number;
number: string;
href: string | null;
effective_received_milli: number;
receipts: {
id: number;
item_id: number;
received_on: string;
accepted_milli: number;
reversed: boolean;
}[];
}[];
}[];
}[];
};
health: WarehouseHealth;
people: Record<
'requester' | 'review' | 'delivery',
{ id: number; name: string }[]
>;
record: {
id: number;
snapshot: {
requester: { name: string };
warehouse: { name: string };
cost_center: { code: string; name: string };
destination: string;
reason: string;
lines: Line[];
};
};
progress: {
state: string;
version: number;
remaining: number[];
reserved: number[];
cancelled: number[];
reservation_ready: boolean;
delivered: number[];
partial_requested: boolean;
partial_allowed: boolean;
events: Event[];
received_deliveries: number[];
};
ready: boolean;
canSubmit: boolean;
canReview: boolean;
canDeliver: boolean;
canReceive: boolean;
canManageReservation: boolean;
};
const labels: Record<string, string> = {
draft: 'Rascunho',
submitted: 'Submetida',
authorized: 'Autorizada',
partial: 'Parcialmente entregue',
delivered: 'Entregue',
closed: 'Encerrada — recepção confirmada',
rejected: 'Rejeitada',
submit: 'Submissão',
authorize: 'Autorização',
reject: 'Rejeição',
deliver: 'Entrega física',
receive: 'Confirmação de recepção',
reserve: 'Actualizar reserva de stock',
cancel: 'Cancelar saldo e libertar reserva',
cancelled: 'Saldo cancelado',
};
function Command({
props,
action,
delivery,
}: {
props: Props;
action: string;
delivery?: number;
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
version: props.progress.version,
action,
reason: '',
partial_allowed: false,
delivery_event_id: delivery,
lines: props.progress.remaining
.map((qty, index) => ({ index, quantity: '', remaining: qty }))
.filter((line) => line.remaining > 0),
});
return (
<details
id={`warehouse-command-${action}${delivery === undefined ? '' : `-${delivery}`}`}
className="rounded border p-3 print:hidden"
>
<summary className="cursor-pointer font-medium">
{labels[action]}
</summary>
<form
className="mt-3 space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.transform((values) => ({
...values,
lines:
action === 'deliver'
? values.lines
.filter((line) => line.quantity !== '')
.map(({ index, quantity }) => ({
index,
quantity,
}))
: undefined,
}));
form.post(`/stock/requisicoes/${props.record.id}/eventos`, {
preserveState: 'errors',
});
}}
>
<FormErrors errors={form.errors} />
{(action === 'submit' || action === 'authorize') && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.partial_allowed}
disabled={
action === 'authorize' &&
!props.progress.partial_requested
}
onChange={(e) =>
form.setData(
'partial_allowed',
e.target.checked,
)
}
/>
{action === 'submit'
? 'Aceito entregas parciais'
: 'Permitir entregas parciais aceites pelo requisitante'}
</label>
)}
{action === 'deliver' && (
<>
<p className="text-sm">
Preencha só as quantidades entregues agora. A
requisição é o documento de saída, sem factura. A
data de registo é a data efectiva da entrega.{' '}
{props.progress.partial_allowed
? 'As quantidades não entregues mantêm-se pendentes.'
: 'Esta autorização exige uma entrega integral.'}
</p>
{form.data.lines.map((line, i) => (
<label className="block" key={line.index}>
{
props.record.snapshot.lines[line.index].item
.code
}{' '}
· Pendente {line.remaining / 1000}{' '}
{props.progress.reservation_ready &&
`· Reservado ${props.progress.reserved[line.index] / 1000} `}
{
props.record.snapshot.lines[line.index].item
.unit
}
<input
inputMode="decimal"
placeholder="Quantidade efectivamente entregue"
className={controlClass}
value={line.quantity}
onChange={(e) =>
form.setData(
'lines',
form.data.lines.map((row, j) =>
j === i
? {
...row,
quantity:
e.target.value,
}
: row,
),
)
}
/>
</label>
))}
</>
)}
<label className="block">
Fundamento / registo da intervenção
<textarea
required
minLength={10}
maxLength={4000}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>{labels[action]}</Button>
</form>
</details>
);
}
export default function WarehouseRequest(props: Props) {
const r = props.record;
const p = props.progress;
return (
<>
<Head title={`Requisição RA-${r.id}`} />
<div className="space-y-6 p-6">
<PageTitle
eyebrow="Documento de saída interna · Sem factura"
title={`Requisição RA-${r.id}`}
description={`Estado: ${labels[p.state] ?? p.state}`}
/>
<div className="flex gap-4 print:hidden">
{props.canViewReturns && (
<Link
href={`/stock/devolucoes-internas?request_id=${r.id}`}
>
Conferir devolução de excedentes
</Link>
)}
<Link href="/stock/requisicoes">
Requisições ao armazém
</Link>
<Button variant="outline" asChild>
<a href={`/stock/requisicoes/${r.id}/imprimir`}>
Imprimir requisição e entregas
</a>
</Button>
</div>
<Panel title="Utilização e origem">
<p>Requisitante: {r.snapshot.requester.name}</p>
<p>Armazém: {r.snapshot.warehouse.name}</p>
<p>
Centro de custo: {r.snapshot.cost_center.code} ·{' '}
{r.snapshot.cost_center.name}
</p>
<p>Local de utilização: {r.snapshot.destination}</p>
<p>Finalidade: {r.snapshot.reason}</p>
</Panel>
{props.supplyTrace.ready &&
(props.supplyTrace.groups.length > 0 ||
props.health.needs_reservation) && (
<div className="print:hidden">
<Panel title="Reposição e compras associadas">
<p className="text-sm text-muted-foreground">
A reposição serve o artigo neste armazém.
Após uma entrada, o autorizador confere o
stock utilizável e actualiza a reserva do
pedido; a saída acontece na entrega.
</p>
{props.canManageReservation &&
props.supplyTrace.groups.some(
(group) => group.unreserved_milli > 0,
) && (
<Button
className="mt-3"
variant="outline"
onClick={() => {
const command =
document.getElementById(
'warehouse-command-reserve',
);
if (
command instanceof
HTMLDetailsElement
) {
command.open = true;
command.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}
}}
>
Conferir e actualizar reserva
</Button>
)}
{props.supplyTrace.groups.length === 0 && (
<p className="mt-3">
Ainda não existe ligação de reposição
registada para este pedido. A avaliação
depende da parametrização e das
necessidades detectadas.
</p>
)}
{props.supplyTrace.groups.map((group) => (
<section
key={group.origin_need_id}
className="mt-4 rounded-lg border p-4"
>
<h3 className="font-medium">
{group.item.code} ·{' '}
{group.item.name}
</h3>
<p className="text-sm">
Saldo deste pedido sem reserva:{' '}
{group.unreserved_milli / 1000}{' '}
{group.item.unit}. Necessidade de
origem #{group.origin_need_id};
avaliação actual #
{group.current_need_id}.
</p>
<p className="mt-2 text-sm">
{group.message}
</p>
{group.restricted && (
<p className="mt-2 text-sm text-muted-foreground">
O detalhe de algumas compras
exige acesso ao respectivo
processo.
</p>
)}
{group.purchases.map((purchase) => (
<div
key={purchase.id}
className="mt-3 border-t pt-3"
>
<p>
<Link
href={purchase.href}
className="underline"
>
Compra {purchase.number}
</Link>{' '}
·{' '}
{(
{
draft: 'Rascunho a conferir e submeter',
pending:
'Em aprovação',
approved:
'Aprovada',
rejected:
'Rejeitada',
} as Record<
string,
string
>
)[purchase.status] ??
purchase.status}
</p>
{purchase.next_task && (
<p className="text-sm">
Etapa pendente:{' '}
{purchase.next_task}
</p>
)}
{purchase.orders.length ===
0 && (
<p className="text-sm text-muted-foreground">
Sem encomenda emitida
neste processo.
</p>
)}
{purchase.orders.map(
(order) => (
<div
key={order.id}
className="mt-2 text-sm"
>
<p>
Encomenda{' '}
{order.href ? (
<Link
href={
order.href
}
className="underline"
>
{
order.number
}
</Link>
) : (
order.number
)}{' '}
· Entradas
aceites
efectivas neste
armazém:{' '}
{order.effective_received_milli /
1000}{' '}
{
group.item
.unit
}
</p>
{order.receipts
.length ===
0 && (
<p className="text-muted-foreground">
Sem entrada
deste artigo
registada no
destino.
</p>
)}
{order.receipts.map(
(receipt) => (
<p
key={
receipt.item_id
}
>
Recebimento
#
{
receipt.id
}{' '}
·{' '}
{
receipt.received_on
}{' '}
·{' '}
{receipt.accepted_milli /
1000}{' '}
{
group
.item
.unit
}
{receipt.reversed
? ' · Estornado'
: ' · Aceite'}
</p>
),
)}
</div>
),
)}
</div>
))}
</section>
))}
</Panel>
</div>
)}
<Panel title="Artigos e atendimento">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>
{[
'Artigo / unidade',
'Destino',
'Pedido',
'Entregue',
'Reservado',
'Cancelado',
'Pendente',
].map((label) => (
<th
className="border-b p-3"
key={label}
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{r.snapshot.lines.map((line, index) => (
<tr key={index}>
<td className="border-b p-3">
{line.item.code} · {line.item.name}{' '}
({line.item.unit})
</td>
<td className="border-b p-3">
{line.equipment
? `${line.equipment.code} · ${line.equipment.description}`
: line.destination}
</td>
<td className="border-b p-3">
{line.quantity_milli / 1000}
</td>
<td className="border-b p-3">
{p.delivered[index] / 1000}
</td>
<td className="border-b p-3">
{p.reservation_ready
? p.reserved[index] / 1000
: 'Por instalar'}
</td>
<td className="border-b p-3">
{p.cancelled[index] / 1000}
</td>
<td className="border-b p-3">
{p.remaining[index] / 1000}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-sm">
{p.reservation_ready
? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.'
: 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
</p>
{props.health.lines.some((l) => l.attention) && (
<div className="mt-3 space-y-2 rounded border border-amber-400 bg-amber-50 p-3 text-sm text-amber-950">
{props.health.lines
.filter((l) => l.attention)
.map((l) => (
<p key={l.index}>
{r.snapshot.lines[l.index].item.code} ·{' '}
{l.attention}
{l.unreserved_milli > 0
? ` Saldo sem reserva: ${l.unreserved_milli / 1000} ${r.snapshot.lines[l.index].item.unit}.`
: ''}
</p>
))}
</div>
)}
{props.health.unconfirmed_deliveries > 0 && (
<p className="mt-3 text-sm">
{props.health.unconfirmed_deliveries} entrega(s)
aguardam confirmação de recepção pelo requisitante.
Confirmar não gera nova saída.
</p>
)}
</Panel>
<div className="print:hidden">
<Panel title="Pessoas actualmente elegíveis para as tarefas">
<p className="mb-3 text-sm">
Conforme perfis, contexto, departamento e
independência actuais. Esta consulta não atribui
tarefas nem altera decisões anteriores.
</p>
{(
[
[
'requester',
'Requisição e confirmação de recepção',
],
[
'review',
'Autorização e acompanhamento da reserva',
],
['delivery', 'Entrega pelo armazém'],
] as const
).map(([key, label]) => (
<p key={key} className="mb-2">
<strong>{label}:</strong>{' '}
{props.people[key].length
? props.people[key]
.map((u) => u.name)
.join(', ')
: 'Sem pessoa elegível nas competências actualmente atribuídas; requer parametrização.'}
</p>
))}
</Panel>
</div>
{!props.ready ? (
<Panel title="Instalação pendente">
<p>Autorização e entrega ainda não instaladas.</p>
</Panel>
) : (
<div className="space-y-3 print:hidden">
{props.canSubmit && (
<Command
key={`submit:${p.version}`}
props={props}
action="submit"
/>
)}
{props.canReview && (
<>
<Command
key={`authorize:${p.version}`}
props={props}
action="authorize"
/>
<Command
key={`reject:${p.version}`}
props={props}
action="reject"
/>
</>
)}
{props.canDeliver && props.health.delivery_ready && (
<Command
key={`deliver:${p.version}`}
props={props}
action="deliver"
/>
)}
{props.canManageReservation && p.reservation_ready && (
<>
<Command
key={`reserve:${p.version}`}
props={props}
action="reserve"
/>
<Command
key={`cancel:${p.version}`}
props={props}
action="cancel"
/>
</>
)}
</div>
)}
<Panel title="Decisões, entregas e recepção">
{p.events.length === 0 ? (
<p>Sem intervenções.</p>
) : (
p.events.map((event) => (
<div
key={event.id}
className="mb-4 rounded border p-3"
>
<p className="font-medium">
{labels[event.action]} · Registo #{event.id}{' '}
·{' '}
{event.data.actor?.name ??
`Utilizador #${event.actor_id}`}
</p>
<p>{event.created_at}</p>
<p className="whitespace-pre-wrap">
{event.reason}
</p>
{event.action === 'deliver' && (
<>
<p>
Documento de saída: RA-{r.id} ·
Entrega #{event.id}
</p>
<a
href={`/stock/requisicoes/${r.id}/entregas/${event.id}/imprimir`}
target="_blank"
rel="noopener noreferrer"
className="text-sm underline print:hidden"
>
Imprimir apenas esta entrega
</a>
{event.data.lines?.map((line) => (
<p key={line.index}>
{
r.snapshot.lines[line.index]
.item.code
}{' '}
· {line.quantity_milli / 1000}{' '}
{
r.snapshot.lines[line.index]
.item.unit
}{' '}
· Operação de stock #
{line.stock_operation_id}
</p>
))}
<p>
{p.received_deliveries.includes(
event.id,
)
? 'Recepção confirmada pelo requisitante'
: 'Recepção por confirmar'}
</p>
{props.canReceive &&
!p.received_deliveries.includes(
event.id,
) && (
<Command
key={`receive:${event.id}:${p.version}`}
props={props}
action="receive"
delivery={event.id}
/>
)}
</>
)}
{event.action === 'receive' && (
<p>
Relativa à entrega #
{event.data.delivery_event_id}
</p>
)}
</div>
))
)}
</Panel>
</div>
</>
);
}