559 lines
29 KiB
TypeScript
559 lines
29 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;
|
|
equipment_id: string;
|
|
spare_part_id: string;
|
|
production_area: string;
|
|
downtime_started_at: string;
|
|
};
|
|
const emptyLine = (): Line => ({
|
|
key: crypto.randomUUID(),
|
|
description: '',
|
|
category: '',
|
|
purchase_type: 'consumable',
|
|
quantity: '1',
|
|
unit: 'un',
|
|
unit_price: '',
|
|
tax_rate: '0',
|
|
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 = [],
|
|
}: {
|
|
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 }[];
|
|
}) {
|
|
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),
|
|
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()],
|
|
});
|
|
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="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(),
|
|
])
|
|
}
|
|
>
|
|
<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: '#' },
|
|
],
|
|
};
|