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

427 lines
19 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
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 = {
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 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.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">
<Link href="/stock/requisicoes">
Requisições ao armazém
</Link>
<Button variant="outline" onClick={() => window.print()}>
Imprimir requisição e entregas
</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>
<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>
</Panel>
{!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 && (
<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>
{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>
</>
);
}