Implementa conferencia de facturas e consumo por empresa
This commit is contained in:
@@ -136,6 +136,11 @@ export function AppSidebar() {
|
||||
href: '/orcamentos',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
title: 'Facturas',
|
||||
href: '/facturas',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,5 +9,6 @@ export const previewPaths: Record<string, string> = {
|
||||
stock: '/consulta/stock',
|
||||
operations: '/consulta/operacoes-stock',
|
||||
equipment: '/consulta/equipamentos',
|
||||
invoices: '/consulta/facturas',
|
||||
appearance: '/consulta/aparencia',
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ type Budget = {
|
||||
amount_minor: number;
|
||||
reserved_minor: number;
|
||||
committed_minor: number;
|
||||
consumed_minor: number;
|
||||
available_minor: number;
|
||||
created_by: number;
|
||||
approved_by: number | null;
|
||||
@@ -49,8 +50,9 @@ export default function Budgets({
|
||||
<p className="rounded-lg border border-amber-300 p-4 text-sm">
|
||||
Desenvolvimento. O orçamento continua sem validação
|
||||
operacional. Centros sem dotação continuam no circuito
|
||||
anterior, sem controlo orçamental. Facturas e pagamentos
|
||||
ainda não implementados.
|
||||
anterior, sem controlo orçamental. Facturas validadas
|
||||
convertem compromisso em consumo. Pagamentos ainda não
|
||||
implementados.
|
||||
</p>
|
||||
<Panel
|
||||
title="Preparar dotação"
|
||||
@@ -158,7 +160,8 @@ export default function Budgets({
|
||||
</p>
|
||||
<p>
|
||||
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
|
||||
{money(b.committed_minor)} · Disponível:{' '}
|
||||
{money(b.committed_minor)} · Consumido:{' '}
|
||||
{money(b.consumed_minor)} · Disponível:{' '}
|
||||
{money(b.available_minor)}
|
||||
</p>
|
||||
<p className="my-3">{b.reason}</p>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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,
|
||||
}: {
|
||||
invoices: Page<Invoice>;
|
||||
orders: { id: number; number: string; supplier: string; items: Source[] }[];
|
||||
canManage: 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" />
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,7 @@ const navigation = [
|
||||
{ id: 'stock', title: 'Stock e movimentos', icon: Warehouse },
|
||||
{ id: 'operations', title: 'Saídas e inventários', icon: Warehouse },
|
||||
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
|
||||
{ id: 'invoices', title: 'Facturas e conferência', icon: FileText },
|
||||
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
|
||||
];
|
||||
type Detail = { title: string; subtitle: string; rows: [string, string][] };
|
||||
@@ -126,6 +127,7 @@ const navigationGroups = [
|
||||
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
|
||||
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
|
||||
{ title: 'Controlo', ids: ['conflicts'] },
|
||||
{ title: 'Finanças', ids: ['invoices'] },
|
||||
{ title: 'Apresentação', ids: ['appearance'] },
|
||||
];
|
||||
export default function Preview({
|
||||
@@ -661,6 +663,54 @@ export default function Preview({
|
||||
)}
|
||||
{screen === 'operations' && <OperationsDemo />}
|
||||
{screen === 'equipment' && <EquipmentDemo />}
|
||||
{screen === 'invoices' && (
|
||||
<Panel
|
||||
title="Factura parcial · exemplo fictício"
|
||||
description="Sem consultas ou gravações de negócio. Os valores são apenas exemplos; cada empresa define as suas dotações e base de IVA."
|
||||
>
|
||||
<p className="mb-4 text-sm">
|
||||
FT-DEMO-01 · Fornecedor fictício ·
|
||||
OC-DEMO-01 · Validada por uma segunda pessoa
|
||||
de Finanças.
|
||||
</p>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt>Quantidade autorizada e aceite</dt>
|
||||
<dd>10 unidades</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Quantidade facturada</dt>
|
||||
<dd>6 unidades</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Valor desta factura</dt>
|
||||
<dd>600,00 MZN (exemplo sem IVA)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
Compromisso antes da conferência
|
||||
</dt>
|
||||
<dd>1.000,00 MZN</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Consumo após validação</dt>
|
||||
<dd>600,00 MZN</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Compromisso remanescente</dt>
|
||||
<dd>400,00 MZN</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-4 text-sm">
|
||||
O disponível não aumenta: o compromisso
|
||||
converte-se em consumo. Preço, unidade, IVA
|
||||
e quantidades são novamente conferidos no
|
||||
servidor. Sem compromisso aprovado, a
|
||||
factura permanece pendente. Não autoriza
|
||||
pagamentos.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'appearance' && (
|
||||
<>
|
||||
<Panel
|
||||
|
||||
Reference in New Issue
Block a user