341 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|