Implementa registo e conferencia de pagamentos externos

This commit is contained in:
2026-10-28 14:51:53 +02:00
parent f0f7b8ce9d
commit c41628d738
25 changed files with 1279 additions and 10 deletions
+5
View File
@@ -141,6 +141,11 @@ export function AppSidebar() {
href: '/facturas',
icon: FileCheck2,
},
{
title: 'Pagamentos externos',
href: '/pagamentos',
icon: FileCheck2,
},
]}
/>
)}
+1
View File
@@ -11,5 +11,6 @@ export const previewPaths: Record<string, string> = {
equipment: '/consulta/equipamentos',
invoices: '/consulta/facturas',
bank: '/consulta/dados-bancarios',
payments: '/consulta/pagamentos',
appearance: '/consulta/aparencia',
};
+3 -2
View File
@@ -51,8 +51,9 @@ export default function Budgets({
Desenvolvimento. O orçamento continua sem validação
operacional. Centros sem dotação continuam no circuito
anterior, sem controlo orçamental. Facturas validadas
convertem compromisso em consumo. Pagamentos ainda não
implementados.
convertem compromisso em consumo. Pagamentos externos têm
registo e conferência próprios; o SGC não executa
transferências.
</p>
<Panel
title="Preparar dotação"
+312
View File
@@ -0,0 +1,312 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
Panel,
PageTitle,
controlClass,
} from '@/components/procurement';
import { InventoryPagination } from '@/components/inventory';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Page } from '@/types/inventory';
import { money } from '@/types/procurement';
type Invoice = {
id: number;
number: string;
supplier_id: number;
supplier_name: string;
total_minor: number;
outstanding_minor: number;
bank_id: number;
bank_revision: number;
bank_state: string;
};
type Payment = {
id: number;
invoice_id: number;
created_by: number;
status: string;
amount_minor: number;
currency: string;
paid_on: string;
external_reference: string | null;
evidence_reference: string | null;
supplier_bank_detail_id: number;
};
function Review({ id }: { id: number }) {
const form = useForm({
decision: 'confirmed',
reason: '',
independently_verified: false,
});
return (
<form
className="mt-4 grid gap-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/pagamentos/${id}/conferir`);
}}
>
<FormErrors errors={form.errors} />
<label>
Resultado da conferência
<select
className={controlClass}
value={form.data.decision}
onChange={(e) => form.setData('decision', e.target.value)}
>
<option value="confirmed">Confirmar registo externo</option>
<option value="rejected">Rejeitar conferência</option>
</select>
</label>
<label>
Fundamento
<textarea
required
className="w-full rounded border p-3"
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<label className="flex gap-3">
<input
required
type="checkbox"
checked={form.data.independently_verified}
onChange={(e) =>
form.setData('independently_verified', e.target.checked)
}
/>
Conferi a evidência, os valores e os dados bancários de forma
independente.
</label>
<Button disabled={form.processing}>Guardar conferência</Button>
</form>
);
}
export default function Payments({
invoices,
payments,
canManage,
}: {
invoices: Invoice[];
payments: Page<Payment>;
canManage: boolean;
}) {
const { auth } = usePage().props;
const [invoiceId, setInvoiceId] = useState('');
const form = useForm({
supplier_bank_detail_id: 0,
amount: '',
paid_on: '',
external_reference: '',
evidence_reference: '',
});
const selected = invoices.find((i) => String(i.id) === invoiceId);
return (
<>
<Head title="Pagamentos externos" />
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Finanças"
title="Pagamentos externos e conferência"
description="Registe pagamentos já efectuados fora do SGC. O sistema não executa transferências, remessas ou ordens bancárias."
/>
<p className="rounded-lg border border-amber-300 p-4 text-sm">
Desenvolvimento. Apenas a confirmação independente abate o
saldo da factura; não volta a consumir o orçamento. Dados
bancários e evidências devem ser conferidos pelas pessoas
autorizadas.
</p>
<Panel title="Saldo das facturas validadas">
<div className="space-y-4 text-sm">
{invoices.map((i) => (
<article key={i.id} className="rounded border p-3">
<p className="font-semibold">
{i.number} · {i.supplier_name}
</p>
<p>
Total {money(i.total_minor)} · Confirmado{' '}
{money(i.total_minor - i.outstanding_minor)}{' '}
· Pendente {money(i.outstanding_minor)}
</p>
<p>
{i.outstanding_minor === 0
? 'Liquidada por registos externos confirmados'
: 'Saldo por conferir/liquidar'}{' '}
· Versão bancária{' '}
{i.bank_revision || 'não registada'} ·{' '}
{i.bank_state === 'approved'
? 'Duas validações elegíveis'
: 'Dados bancários indisponíveis para confirmação'}
</p>
{canManage && (
<Link
href={`/fornecedores/${i.supplier_id}/dados-bancarios`}
className="font-semibold text-teal-700"
>
Consultar dados bancários →
</Link>
)}
</article>
))}
{invoices.length === 0 && (
<p>Sem facturas validadas nesta empresa.</p>
)}
</div>
</Panel>
{canManage && (
<Panel
title="Registar pagamento externo para conferência"
description="Uma referência externa identifica um registo por empresa. Use a referência da operação e a evidência do pagamento; não introduza números de conta nestes campos."
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
form.post(`/facturas/${invoiceId}/pagamentos`, {
onSuccess: () => {
form.reset();
setInvoiceId('');
},
});
}}
>
<FormErrors errors={form.errors} />
<label>
Factura
<select
required
className={controlClass}
value={invoiceId}
onChange={(e) => {
setInvoiceId(e.target.value);
form.setData(
'supplier_bank_detail_id',
invoices.find(
(i) =>
String(i.id) ===
e.target.value,
)?.bank_id ?? 0,
);
}}
>
<option value="">Seleccionar</option>
{invoices
.filter((i) => i.outstanding_minor > 0)
.map((i) => (
<option key={i.id} value={i.id}>
{i.number} · {i.supplier_name} ·{' '}
{money(i.outstanding_minor)}
</option>
))}
</select>
</label>
{selected && (
<p className="text-sm">
Versão bancária seleccionada:{' '}
{selected.bank_revision}. Se os dados
mudarem, a confirmação será bloqueada.
</p>
)}
<label>
Montante do pagamento (MZN)
<Input
required
inputMode="decimal"
value={form.data.amount}
onChange={(e) =>
form.setData('amount', e.target.value)
}
/>
</label>
<label>
Data efectiva do pagamento
<Input
required
type="date"
value={form.data.paid_on}
onChange={(e) =>
form.setData('paid_on', e.target.value)
}
/>
</label>
<label>
Referência externa da operação
<Input
required
autoComplete="off"
maxLength={150}
value={form.data.external_reference}
onChange={(e) =>
form.setData(
'external_reference',
e.target.value,
)
}
/>
</label>
<label>
Referência da evidência
<Input
required
autoComplete="off"
maxLength={1000}
value={form.data.evidence_reference}
onChange={(e) =>
form.setData(
'evidence_reference',
e.target.value,
)
}
/>
</label>
<Button
disabled={
!selected ||
selected.bank_state !== 'approved' ||
form.processing
}
>
Registar para conferência independente
</Button>
</form>
</Panel>
)}
{payments.data.map((p) => (
<Panel
key={p.id}
title={`Registo externo #${p.id} · ${invoices.find((i) => i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
>
<p>
{money(p.amount_minor)} · {p.paid_on} ·{' '}
{{
pending: 'Pendente de conferência',
confirmed: 'Confirmado',
rejected: 'Conferência rejeitada',
}[p.status] ?? p.status}
</p>
{p.external_reference && (
<p className="mt-2 text-sm break-all">
Referência: {p.external_reference}
</p>
)}
{p.evidence_reference && (
<p className="text-sm break-all">
Evidência: {p.evidence_reference}
</p>
)}
{canManage &&
p.status === 'pending' &&
p.created_by !== auth.user.id && (
<Review id={p.id} />
)}
</Panel>
))}
<InventoryPagination page={payments} />
</div>
</>
);
}
+34 -1
View File
@@ -120,6 +120,7 @@ const navigation = [
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
{ id: 'invoices', title: 'Facturas e conferência', icon: FileText },
{ id: 'bank', title: 'Dados bancários', icon: ShieldCheck },
{ id: 'payments', title: 'Pagamentos externos', icon: FileText },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
@@ -128,7 +129,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', 'bank'] },
{ title: 'Finanças', ids: ['invoices', 'bank', 'payments'] },
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
@@ -664,6 +665,38 @@ export default function Preview({
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'payments' && (
<Panel
title="Pagamento externo · exemplo fictício"
description="Sem registos reais, contas bancárias ou execução de transferências."
>
<div className="space-y-3 text-sm">
<p>
Factura fictícia de 1.000,00 MZN. Um
pagamento externo de 600,00 MZN é
registado com referência e evidência.
</p>
<p>
Enquanto pendente de conferência, o
saldo continua em 1.000,00 MZN. Após
confirmação por outra pessoa de
Finanças, fica em 400,00 MZN.
</p>
<p>
A versão bancária e as duas validações
são novamente verificadas. Repetir a
confirmação não duplica a liquidação; o
orçamento não é consumido outra vez.
</p>
<p>
Os valores são apenas exemplos. Cada
empresa tem as suas facturas e
montantes; o SGC não efectua a operação
no banco.
</p>
</div>
</Panel>
)}
{screen === 'bank' && (
<Panel
title="Dupla validação bancária · exemplo fictício"