Implementa reforcos orcamentais com aprovacao independente

This commit is contained in:
2026-10-28 15:03:49 +02:00
parent c41628d738
commit 153ba2c1af
24 changed files with 1023 additions and 25 deletions
@@ -0,0 +1,97 @@
import { useForm } from '@inertiajs/react';
import { FormErrors, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function ReinforcementProposal({ budgetId }: { budgetId: number }) {
const form = useForm({ reference: '', amount: '', reason: '' });
return (
<details className="mt-4 rounded-lg border p-4">
<summary className="cursor-pointer font-semibold">
Propor reforço desta dotação
</summary>
<form
className="mt-4 grid gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/orcamentos/${budgetId}/reforcos`, {
onSuccess: () => form.reset(),
});
}}
>
<FormErrors errors={form.errors} />
<p className="text-sm">
Aumenta o saldo apenas depois da aprovação por outra pessoa.
Mantém a moeda e a base de IVA da dotação.
</p>
<label>
Referência interna única na empresa
<Input
required
maxLength={100}
value={form.data.reference}
onChange={(e) =>
form.setData('reference', e.target.value)
}
/>
</label>
<label>
Montante do reforço (MZN)
<Input
required
inputMode="decimal"
value={form.data.amount}
onChange={(e) => form.setData('amount', e.target.value)}
/>
</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>
<Button disabled={form.processing}>
Enviar para aprovação independente
</Button>
</form>
</details>
);
}
export function ReinforcementReview({ id }: { id: number }) {
const form = useForm({ decision: 'approved', reason: '' });
return (
<form
className="mt-4 grid gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/reforcos/${id}/decidir`);
}}
>
<FormErrors errors={form.errors} />
<label>
Decisão
<select
className={controlClass}
value={form.data.decision}
onChange={(e) => form.setData('decision', e.target.value)}
>
<option value="approved">Aprovar reforço</option>
<option value="rejected">Rejeitar reforço</option>
</select>
</label>
<label>
Fundamento da decisão
<textarea
required
className="w-full rounded border p-3"
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button disabled={form.processing}>Guardar decisão imutável</Button>
</form>
);
}
+1
View File
@@ -12,5 +12,6 @@ export const previewPaths: Record<string, string> = {
invoices: '/consulta/facturas',
bank: '/consulta/dados-bancarios',
payments: '/consulta/pagamentos',
reinforcements: '/consulta/reforcos',
appearance: '/consulta/aparencia',
};
+73 -1
View File
@@ -8,6 +8,12 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { money } from '@/types/procurement';
import {
ReinforcementProposal,
ReinforcementReview,
} from '@/components/budget-reinforcements';
import { InventoryPagination } from '@/components/inventory';
import type { Page } from '@/types/inventory';
type Budget = {
id: number;
@@ -22,13 +28,27 @@ type Budget = {
approved_by: number | null;
reason: string;
includes_tax: boolean;
reinforced_minor: number;
approved_total_minor: number;
};
export default function Budgets({
centers,
budgets,
reinforcements,
}: {
centers: { id: number; code: string; name: string }[];
budgets: Budget[];
reinforcements: Page<{
id: number;
budget_id: number;
reference: string;
amount_minor: number;
reason: string;
status: string;
created_by: number;
reviewed_by: number | null;
review_reason: string | null;
}>;
}) {
const { auth } = usePage().props;
const form = useForm({
@@ -57,7 +77,7 @@ export default function Budgets({
</p>
<Panel
title="Preparar dotação"
description="Outra pessoa de Administração ou Finanças deve aprovar. Não existem alterações ou reforços nesta etapa."
description="Outra pessoa de Administração ou Finanças deve aprovar. A dotação inicial é preservada; reforços têm aprovação própria."
>
<form
className="grid gap-4"
@@ -165,6 +185,10 @@ export default function Budgets({
{money(b.consumed_minor)} · Disponível:{' '}
{money(b.available_minor)}
</p>
<p>
Reforços aprovados: {money(b.reinforced_minor)} ·
Dotação acumulada: {money(b.approved_total_minor)}
</p>
<p className="my-3">{b.reason}</p>
{!b.approved_by && b.created_by !== auth.user.id && (
<Button
@@ -175,8 +199,56 @@ export default function Budgets({
Aprovar dotação
</Button>
)}
{b.approved_by && (
<ReinforcementProposal budgetId={b.id} />
)}
</Panel>
))}
<Panel
title="Histórico de reforços"
description="Pendentes e rejeitados não alteram o saldo. O valor inicial nunca é reescrito."
>
<div className="space-y-4">
{reinforcements.data.map((r) => (
<article
key={r.id}
className="rounded border p-4 text-sm"
>
<p className="font-semibold">
{r.reference} · Dotação #{r.budget_id} ·{' '}
{money(r.amount_minor)}
</p>
<p>
{{
pending: 'Pendente',
approved: 'Aprovado',
rejected: 'Rejeitado',
}[r.status] ?? r.status}{' '}
· Proposto por #{r.created_by}
{r.reviewed_by
? ` · Decidido por #${r.reviewed_by}`
: ''}
</p>
<p className="my-2 whitespace-pre-wrap">
{r.reason}
</p>
{r.review_reason && (
<p className="whitespace-pre-wrap">
Decisão: {r.review_reason}
</p>
)}
{r.status === 'pending' &&
r.created_by !== auth.user.id && (
<ReinforcementReview id={r.id} />
)}
</article>
))}
{reinforcements.data.length === 0 && (
<p>Sem reforços nesta empresa.</p>
)}
</div>
<InventoryPagination page={reinforcements} />
</Panel>
</div>
</>
);
+33 -1
View File
@@ -121,6 +121,7 @@ const navigation = [
{ 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: 'reinforcements', title: 'Reforços orçamentais', icon: FileText },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
@@ -129,7 +130,10 @@ 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', 'payments'] },
{
title: 'Finanças',
ids: ['invoices', 'bank', 'payments', 'reinforcements'],
},
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
@@ -665,6 +669,34 @@ export default function Preview({
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'reinforcements' && (
<Panel
title="Reforço orçamental · exemplo fictício"
description="Valores ilustrativos; cada empresa define as suas dotações. Sem consultas ou gravações de negócio."
>
<div className="space-y-3 text-sm">
<p>
Dotação inicial fictícia de 1.000,00
MZN, com 300,00 MZN utilizados:
disponível de 700,00 MZN.
</p>
<p>
Um reforço proposto de 400,00 MZN fica
pendente. Só depois da aprovação por
outra pessoa a dotação acumulada passa a
1.400,00 MZN e o disponível a 1.100,00
MZN.
</p>
<p>
O valor inicial, a base de IVA, o centro
de custo e o exercício são preservados.
Repetir uma aprovação não duplica a
verba. Rejeições ficam no histórico sem
alterar o saldo.
</p>
</div>
</Panel>
)}
{screen === 'payments' && (
<Panel
title="Pagamento externo · exemplo fictício"