Files
compras/resources/js/pages/budgets/index.tsx
T

288 lines
12 KiB
TypeScript

import { Head, router, useForm, usePage } from '@inertiajs/react';
import {
Panel,
PageTitle,
FormErrors,
controlClass,
} from '@/components/procurement';
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;
cost_center_id: number;
year: number;
amount_minor: number;
reserved_minor: number;
committed_minor: number;
consumed_minor: number;
available_minor: number;
created_by: number;
approved_by: number | null;
reason: string;
includes_tax: boolean;
reinforced_minor: number;
approved_total_minor: number;
kind: string;
};
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({
cost_center_id: '',
year: new Date().getFullYear(),
amount: '',
reason: '',
includes_tax: '',
kind: '',
});
return (
<>
<Head title="Orçamentos" />
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Finanças"
title="Orçamentos"
description="Dotação anual por empresa e centro de custo, em MZN. A empresa define a base de IVA."
/>
<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 validadas
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"
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"
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
includes_tax: data.includes_tax === '1',
}));
form.post('/orcamentos');
}}
>
<FormErrors errors={form.errors} />
<label>
Classificação da dotação
<select
required
className={controlClass}
value={form.data.kind}
onChange={(e) =>
form.setData('kind', e.target.value)
}
>
<option value="">Decisão da empresa</option>
<option value="operating">
Despesa corrente
</option>
<option value="investment">
Investimento (processos com equipamento)
</option>
</select>
</label>
<p className="text-sm">
Cada centro/exercício admite uma dotação. Para
separar despesa corrente de investimento no mesmo
exercício, use centros distintos.
</p>
<label>
Base de controlo
<select
required
className={controlClass}
value={form.data.includes_tax}
onChange={(e) =>
form.setData('includes_tax', e.target.value)
}
>
<option value="">Decisão da empresa</option>
<option value="1">Com IVA</option>
<option value="0">Sem IVA</option>
</select>
</label>
<label>
Centro de custo
<select
required
className={controlClass}
value={form.data.cost_center_id}
onChange={(e) =>
form.setData(
'cost_center_id',
e.target.value,
)
}
>
<option value="">Seleccionar</option>
{centers.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</label>
<label>
Exercício
<Input
required
type="number"
min="2026"
max="2100"
value={form.data.year}
onChange={(e) =>
form.setData('year', Number(e.target.value))
}
/>
</label>
<label>
Dotação na base seleccionada (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}>
Guardar para aprovação independente
</Button>
</form>
</Panel>
{budgets.map((b) => (
<Panel
key={b.id}
title={`${centers.find((c) => c.id === b.cost_center_id)?.code ?? b.cost_center_id} · ${b.year}`}
>
<p>
{b.approved_by
? 'Aprovado'
: 'Pendente de aprovação'}{' '}
· Dotação: {money(b.amount_minor)} · Base:{' '}
{b.includes_tax ? 'com IVA' : 'sem IVA'}
</p>
<p>
Classificação:{' '}
{b.kind === 'investment'
? 'Investimento'
: 'Despesa corrente'}
</p>
<p>
Reservado: {money(b.reserved_minor)} · Comprometido:{' '}
{money(b.committed_minor)} · Consumido:{' '}
{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
onClick={() =>
router.post(`/orcamentos/${b.id}/aprovar`)
}
>
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>
</>
);
}