Files
compras/resources/js/pages/invoices/index.tsx
T
nala 413c201d97
tests / ci (push) Canceled after 0s
commit5
2026-10-29 22:03:09 +02:00

341 lines
16 KiB
TypeScript

import { Head, router, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { InventoryPagination } from '@/components/inventory';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { money } from '@/types/procurement';
import type { Page } from '@/types/inventory';
type Source = {
id: number;
description: string;
unit: string;
unit_price_minor: number;
tax_rate: string;
};
type Line = {
purchase_order_item_id: number;
quantity: string;
unit: string;
unit_price: string;
tax_rate: string;
};
type Invoice = {
id: number;
number: string;
issued_on: string;
status: string;
created_by: number;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
snapshot: { supplier: { name: string }; order_number: string };
items: {
id: number;
quantity_milli: number;
subtotal_minor: number;
tax_minor: number;
snapshot: { description: string; unit: string };
}[];
};
export default function Invoices({
invoices,
orders,
canManage,
payablesAvailable = false,
}: {
invoices: Page<Invoice>;
orders: { id: number; number: string; supplier: string; items: Source[] }[];
canManage: boolean;
payablesAvailable?: boolean;
}) {
const { auth } = usePage().props;
const [orderId, setOrderId] = useState('');
const [validationError, setValidationError] = useState('');
const [validating, setValidating] = useState<number | null>(null);
const form = useForm({
number: '',
issued_on: '',
subtotal: '',
tax: '',
total: '',
items: [] as Line[],
});
const order = orders.find((o) => String(o.id) === orderId);
return (
<>
<Head title="Facturas" />
{payablesAvailable && <a className="m-6 inline-block underline" href="/obrigacoes-fornecedores">Obrigações a fornecedores — ensaio sintético</a>}
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Finanças"
title="Facturas e conferência"
description="Conferência da ordem, recepção aceite e factura. Validação por outra pessoa de Finanças."
/>
<p className="rounded-lg border border-amber-300 p-4 text-sm">
Desenvolvimento. Não autoriza pagamentos. A validação exige
compromisso orçamental aprovado. Diferenças, notas de
crédito e operações entre exercícios ficam bloqueadas.
</p>
{canManage && (
<Panel
title="Registar factura"
description="Transcreva os valores do documento do fornecedor. A conferência exige coincidência exacta; o registo não valida automaticamente a factura."
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((d) => ({
...d,
items: d.items.filter(
(l) => l.quantity !== '',
),
}));
form.post(`/ordens/${orderId}/facturas`, {
onSuccess: () => {
form.reset();
setOrderId('');
},
});
}}
>
<FormErrors errors={form.errors} />
<label>
Ordem
<select
required
className={controlClass}
value={orderId}
onChange={(e) => {
setOrderId(e.target.value);
form.setData(
'items',
orders
.find(
(o) =>
String(o.id) ===
e.target.value,
)
?.items.map((l) => ({
purchase_order_item_id:
l.id,
quantity: '',
unit: l.unit,
unit_price: (
l.unit_price_minor / 100
).toFixed(2),
tax_rate: String(
l.tax_rate,
),
})) ?? [],
);
}}
>
<option value="">Seleccionar ordem</option>
{orders.map((o) => (
<option key={o.id} value={o.id}>
{o.number} · {o.supplier}
</option>
))}
</select>
</label>
<label>
Número do fornecedor
<Input
required
maxLength={100}
value={form.data.number}
onChange={(e) =>
form.setData('number', e.target.value)
}
/>
</label>
<label>
Data da factura
<Input
required
type="date"
value={form.data.issued_on}
onChange={(e) =>
form.setData(
'issued_on',
e.target.value,
)
}
/>
</label>
{form.data.items.map((line, index) => (
<fieldset
key={line.purchase_order_item_id}
className="grid gap-3 rounded border p-4 sm:grid-cols-4"
>
<legend>
{
order?.items.find(
(s) =>
s.id ===
line.purchase_order_item_id,
)?.description
}
</legend>
{(
[
'quantity',
'unit',
'unit_price',
'tax_rate',
] as const
).map((key) => (
<label key={key}>
{
{
quantity:
'Quantidade (vazio para excluir)',
unit: 'Unidade',
unit_price: 'Preço sem IVA',
tax_rate: 'IVA (%)',
}[key]
}
<Input
value={line[key]}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(l, i) =>
i === index
? {
...l,
[key]: e
.target
.value,
}
: l,
),
)
}
/>
</label>
))}
</fieldset>
))}
<p className="text-sm">
Nas facturas parciais o arredondamento é
acumulado por linha para preservar o total da
ordem. Divergências exigem revisão, sem
alteração automática do documento.
</p>
{(['subtotal', 'tax', 'total'] as const).map(
(key) => (
<label key={key}>
{
{
subtotal:
'Subtotal sem IVA (MZN)',
tax: 'IVA (MZN)',
total: 'Total (MZN)',
}[key]
}
<Input
required
inputMode="decimal"
value={form.data[key]}
onChange={(e) =>
form.setData(
key,
e.target.value,
)
}
/>
</label>
),
)}
<Button disabled={!orderId || form.processing}>
Registar para conferência independente
</Button>
</form>
</Panel>
)}
{validationError && (
<p role="alert" className="text-red-700">
{validationError}
</p>
)}
{invoices.data.map((invoice) => (
<Panel
key={invoice.id}
title={`${invoice.number} · ${invoice.snapshot.supplier.name}`}
>
<p>
{invoice.snapshot.order_number} ·{' '}
{invoice.issued_on} ·{' '}
{invoice.status === 'validated'
? 'Validada'
: 'Pendente de validação'}
</p>
<p>
Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
{money(invoice.tax_minor)} · Total{' '}
{money(invoice.total_minor)}
</p>
{payablesAvailable && (
<p className="my-3">
<a className="underline" href={`/obrigacoes-fornecedores?invoice=${invoice.id}`}>
Consultar ou preparar obrigação desta factura
</a>
</p>
)}
<ul className="my-3 space-y-2">
{invoice.items.map((l) => (
<li key={l.id}>
{l.snapshot.description} ·{' '}
{l.quantity_milli / 1000} {l.snapshot.unit}{' '}
· {money(l.subtotal_minor + l.tax_minor)}
</li>
))}
</ul>
{canManage &&
invoice.status === 'pending' &&
invoice.created_by !== auth.user.id && (
<Button
disabled={validating !== null}
onClick={() => {
setValidationError('');
setValidating(invoice.id);
router.post(
`/facturas/${invoice.id}/validar`,
{},
{
onError: (errors) =>
setValidationError(
Object.values(
errors,
).join(' '),
),
onFinish: () =>
setValidating(null),
},
);
}}
>
Conferir e validar
</Button>
)}
</Panel>
))}
{invoices.data.length === 0 && (
<p>Sem facturas registadas nesta empresa.</p>
)}
<InventoryPagination page={invoices} />
</div>
</>
);
}