Files
compras/resources/js/pages/requisitions/form.tsx
T
nala 4d42f083ce
tests / ci (push) Canceled after 0s
commit
2026-10-28 12:06:05 +02:00

379 lines
17 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import { Plus, Save, Trash2 } from 'lucide-react';
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 { money } from '@/types/procurement';
import type { CostCenter, Requisition } from '@/types/procurement';
type Line = {
key: string;
description: string;
category: string;
purchase_type: string;
quantity: string;
unit: string;
unit_price: string;
tax_rate: string;
};
const emptyLine = (): Line => ({
key: crypto.randomUUID(),
description: '',
category: '',
purchase_type: 'consumable',
quantity: '1',
unit: 'un',
unit_price: '',
tax_rate: '0',
});
const lineFields = [
{ key: 'description', label: 'Descrição', type: 'text', maxLength: 255 },
{ key: 'category', label: 'Categoria', type: 'text', maxLength: 100 },
{
key: 'quantity',
label: 'Quantidade',
type: 'number',
min: '0.001',
max: '1000000',
step: '0.001',
},
{ key: 'unit', label: 'Unidade', type: 'text', maxLength: 30 },
{
key: 'unit_price',
label: 'Preço unitário sem IVA (MZN)',
type: 'number',
min: '0.01',
max: '100000000',
step: '0.01',
},
{
key: 'tax_rate',
label: 'Taxa de IVA (%)',
type: 'number',
min: '0',
max: '100',
step: '0.01',
},
] as const;
export default function RequisitionForm({
costCenters,
requisition,
}: {
costCenters: CostCenter[];
requisition: Requisition | null;
}) {
const form = useForm({
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
priority: requisition?.priority ?? 'normal',
needed_by: requisition?.needed_by ?? '',
justification: requisition?.justification ?? '',
items: requisition?.items.map((i) => ({
key: String(i.id),
description: i.description,
category: i.category,
purchase_type: i.purchase_type,
quantity: String(i.quantity),
unit: i.unit,
unit_price: (i.unit_price_minor / 100).toFixed(2),
tax_rate: String(i.tax_rate),
})) ?? [emptyLine()],
});
const changeLine = (index: number, field: keyof Line, value: string) =>
form.setData(
'items',
form.data.items.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
);
const estimate = form.data.items.reduce(
(sum, i) =>
sum +
Math.round(
Math.round(Number(i.quantity) * Number(i.unit_price) * 100) *
(1 + Number(i.tax_rate) / 100),
),
0,
);
return (
<>
<Head
title={requisition ? 'Editar requisição' : 'Nova requisição'}
/>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Pedido de compra"
title={
requisition
? `Editar ${requisition.number}`
: 'Nova requisição'
}
description="Descreva a necessidade e os artigos. Guarde o rascunho e confira os valores antes de submeter."
/>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
if (requisition)
form.put(`/requisicoes/${requisition.id}`);
else form.post('/requisicoes');
}}
>
<FormErrors errors={form.errors} />
{costCenters.length === 0 && (
<div
role="alert"
className="rounded-lg border p-4 text-sm"
>
O seu departamento ainda não tem centros de custo.
Peça ao administrador para criar um antes de
registar pedidos.
</div>
)}
<Panel
title="Informações do pedido"
description="O solicitante e o departamento são determinados pela sua conta."
>
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="cost-center">
Centro de custo
</Label>
<select
id="cost-center"
required
className={controlClass}
value={form.data.cost_center_id}
onChange={(e) =>
form.setData(
'cost_center_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{costCenters.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Prioridade</Label>
<select
id="priority"
className={controlClass}
value={form.data.priority}
onChange={(e) =>
form.setData('priority', e.target.value)
}
>
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="needed-by">
Entrega pretendida
</Label>
<Input
id="needed-by"
type="date"
required
value={form.data.needed_by}
onChange={(e) =>
form.setData(
'needed_by',
e.target.value,
)
}
/>
</div>
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="justification">
Justificação da compra
</Label>
<textarea
id="justification"
required
minLength={10}
maxLength={5000}
className={`${controlClass} h-24 py-3`}
placeholder="Explique a necessidade e a utilização prevista…"
value={form.data.justification}
onChange={(e) =>
form.setData(
'justification',
e.target.value,
)
}
/>
</div>
</div>
</Panel>
<Panel
title="Artigos e serviços"
description="Preços em MZN. Indique a taxa de IVA aplicável; o valor inicial de 0% deve ser revisto por Finanças."
>
<div className="space-y-5">
{form.data.items.map((line, index) => (
<fieldset
className="rounded-lg border bg-muted/20 p-4"
key={line.key}
>
<legend className="px-2 text-xs font-semibold text-muted-foreground">
LINHA {index + 1}
</legend>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{lineFields.map(
({ key, label, ...attributes }) => (
<div
className="space-y-2"
key={key}
>
<Label
htmlFor={`${key}-${line.key}`}
>
{label}
</Label>
<Input
id={`${key}-${line.key}`}
{...attributes}
required
value={line[key]}
onChange={(e) =>
changeLine(
index,
key,
e.target.value,
)
}
/>
</div>
),
)}
<div className="space-y-2">
<Label htmlFor={`type-${line.key}`}>
Tipo de compra
</Label>
<select
id={`type-${line.key}`}
className={controlClass}
value={line.purchase_type}
onChange={(e) =>
changeLine(
index,
'purchase_type',
e.target.value,
)
}
>
<option value="consumable">
Insumos / matéria-prima
</option>
<option value="service">
Serviço
</option>
<option value="other">
Outro
</option>
</select>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
disabled={
form.data.items.length === 1
}
onClick={() =>
form.setData(
'items',
form.data.items.filter(
(_, i) => i !== index,
),
)
}
>
<Trash2 className="size-4" />
Remover linha
</Button>
</div>
</fieldset>
))}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4">
<Button
type="button"
variant="outline"
disabled={form.data.items.length >= 50}
onClick={() =>
form.setData('items', [
...form.data.items,
emptyLine(),
])
}
>
<Plus className="size-4" />
Adicionar linha
</Button>
<p className="text-sm">
Total estimado com IVA{' '}
<strong className="ml-3 text-lg">
{money(
Number.isFinite(estimate)
? estimate
: 0,
)}
</strong>
</p>
</div>
<p className="mt-4 text-xs text-muted-foreground">
O servidor confirma os cálculos ao guardar.
Equipamentos e peças terão formulários próprios numa
próxima etapa.
</p>
</Panel>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link
href={
requisition
? `/requisicoes/${requisition.id}`
: '/requisicoes'
}
>
Cancelar
</Link>
</Button>
<Button
disabled={
form.processing || costCenters.length === 0
}
>
<Save className="size-4" />
Guardar rascunho
</Button>
</div>
</form>
</div>
</>
);
}
RequisitionForm.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Formulário', href: '#' },
],
};