288 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|