Files
compras/resources/js/pages/requisitions/form.tsx
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

710 lines
40 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';
import { CatalogueSearch } from '@/components/requisition-catalogue';
import type { CatalogueOptions } from '@/components/requisition-catalogue';
type Line = {
source_line_id: string;
catalogue_entry_id: string;
catalogue_version: string;
catalogue_code: string;
key: string;
description: string;
category: string;
purchase_type: string;
quantity: string;
unit: string;
unit_price: string;
tax_rate: string;
equipment_id: string;
spare_part_id: string;
production_area: string;
downtime_started_at: string;
};
const emptyLine = (taxRate: string): Line => ({
source_line_id: '',
catalogue_entry_id: '',
catalogue_version: '',
catalogue_code: '',
key: crypto.randomUUID(),
description: '',
category: '',
purchase_type: 'consumable',
quantity: '1',
unit: 'un',
unit_price: '',
tax_rate: taxRate,
equipment_id: '',
spare_part_id: '',
production_area: '',
downtime_started_at: '',
});
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,
equipment = [],
spareParts = [],
stockItems = [],
defaultTaxRate = '16.00',
catalogue,
}: {
costCenters: CostCenter[];
requisition: Requisition | null;
equipment?: { id: number; code: string; description: string }[];
spareParts?: { id: number; stock_item_id: number }[];
stockItems?: { id: number; code: string; name: string; unit: string }[];
defaultTaxRate?: string;
catalogue?: CatalogueOptions;
}) {
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) => ({
source_line_id: i.id ? String(i.id) : '',
catalogue_entry_id: i.catalogue_entry_id
? String(i.catalogue_entry_id)
: '',
catalogue_version: i.catalogue_snapshot
? String(i.catalogue_snapshot.version)
: '',
catalogue_code: i.catalogue_snapshot?.code ?? '',
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),
equipment_id: i.equipment_id ? String(i.equipment_id) : '',
spare_part_id: i.spare_part_id ? String(i.spare_part_id) : '',
production_area: i.production_area ?? '',
downtime_started_at: i.downtime_started_at?.slice(0, 16) ?? '',
})) ?? [emptyLine(defaultTaxRate)],
});
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">
{catalogue?.ready && (
<CatalogueSearch data={catalogue} />
)}
{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">
{catalogue?.ready && (
<label className="space-y-2 sm:col-span-3">
Fonte do artigo
<select
className={controlClass}
value={
line.catalogue_entry_id
? `${line.catalogue_entry_id}:${line.catalogue_version}`
: ''
}
onChange={(event) => {
const entry =
catalogue.items.find(
(item) =>
`${item.id}:${item.version}` ===
event.target
.value,
);
form.setData(
'items',
form.data.items.map(
(item, i) =>
i === index
? entry
? {
...item,
catalogue_entry_id:
String(
entry.id,
),
catalogue_version:
String(
entry.version,
),
catalogue_code:
entry.code,
description:
entry.description,
category:
entry.category,
unit: entry.unit,
purchase_type:
entry.purchase_type,
}
: {
...item,
catalogue_entry_id:
'',
catalogue_version:
'',
catalogue_code:
'',
}
: item,
),
);
}}
>
<option value="">
Preenchimento livre
</option>
{line.catalogue_entry_id &&
!catalogue.items.some(
(item) =>
String(
item.id,
) ===
line.catalogue_entry_id &&
String(
item.version,
) ===
line.catalogue_version,
) && (
<option
value={`${line.catalogue_entry_id}:${line.catalogue_version}`}
>
{
line.catalogue_code
}{' '}
· versão
preservada{' '}
{
line.catalogue_version
}
</option>
)}
{catalogue.items.map(
(entry) => (
<option
key={`${entry.id}:${entry.version}`}
value={`${entry.id}:${entry.version}`}
>
{entry.code} ·{' '}
{
entry.description
}{' '}
· v
{entry.version}
</option>
),
)}
</select>
{line.catalogue_entry_id && (
<p className="text-sm">
Fonte{' '}
{line.catalogue_code} ·
versão utilizada{' '}
{line.catalogue_version}
. Escolha preenchimento
livre para descrever uma
necessidade diferente.
</p>
)}
</label>
)}
{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}
readOnly={
!!line.catalogue_entry_id &&
[
'description',
'category',
'unit',
].includes(key)
}
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}
disabled={
!!line.catalogue_entry_id
}
onChange={(e) =>
changeLine(
index,
'purchase_type',
e.target.value,
)
}
>
<option value="equipment">
Equipamento (exige regra e
dotação de investimento
aprovadas)
</option>
<option value="spare_part">
Peça sobressalente
</option>
<option value="consumable">
Insumos / matéria-prima
</option>
<option value="service">
Serviço
</option>
<option value="other">
Outro
</option>
</select>
{line.purchase_type ===
'equipment' && (
<label className="mt-3 grid gap-2 text-sm">
Área / linha de produção
<Input
required
value={
line.production_area
}
onChange={(e) =>
changeLine(
index,
'production_area',
e.target.value,
)
}
/>
</label>
)}
{line.purchase_type ===
'spare_part' && (
<div className="mt-3 grid gap-3">
<label className="grid gap-2 text-sm">
Equipamento do
departamento
<select
required
className={
controlClass
}
value={
line.equipment_id
}
onChange={(e) =>
changeLine(
index,
'equipment_id',
e.target
.value,
)
}
>
<option value="">
Seleccionar
</option>
{equipment.map(
(a) => (
<option
key={
a.id
}
value={
a.id
}
>
{a.code}{' '}
·{' '}
{
a.description
}
</option>
),
)}
</select>
</label>
<label className="grid gap-2 text-sm">
Peça cadastrada
<select
required
className={
controlClass
}
value={
line.spare_part_id
}
onChange={(e) =>
changeLine(
index,
'spare_part_id',
e.target
.value,
)
}
>
<option value="">
Seleccionar
</option>
{spareParts.map(
(p) => (
<option
key={
p.id
}
value={
p.id
}
>
{
stockItems.find(
(
s,
) =>
s.id ===
p.stock_item_id,
)
?.code
}{' '}
·{' '}
{
stockItems.find(
(
s,
) =>
s.id ===
p.stock_item_id,
)
?.name
}
</option>
),
)}
</select>
</label>
<label className="grid gap-2 text-sm">
Início da paragem
(obrigatório para peça
crítica urgente; hora
UTC)
<Input
type="datetime-local"
value={
line.downtime_started_at
}
onChange={(e) =>
changeLine(
index,
'downtime_started_at',
e.target
.value,
)
}
/>
</label>
<p className="text-xs text-muted-foreground">
Use a unidade do artigo.
A associação
peça/equipamento é
revalidada no servidor.
Urgência não dispensa
três cotações ou
aprovações.
</p>
</div>
)}
</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(defaultTaxRate),
])
}
>
<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: '#' },
],
};