740 lines
37 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|