Implementa regras de investimento por empresa com dupla aprovacao

This commit is contained in:
2026-10-28 16:57:45 +02:00
parent 153ba2c1af
commit 95dd2cc1ed
37 changed files with 1418 additions and 28 deletions
+14
View File
@@ -44,6 +44,15 @@ export function AppSidebar() {
].includes(auth.user.purchasing_role ?? '');
const purchasingItems: NavItem[] = [
{ title: 'Requisições', href: '/requisicoes', icon: ClipboardList },
...(auth.user.purchasing_role === 'procurement'
? [
{
title: 'Regras de investimento',
href: '/regras-investimento',
icon: FileCheck2,
},
]
: []),
...(canReadPurchases
? [
{ title: 'Fornecedores', href: '/fornecedores', icon: Users },
@@ -136,6 +145,11 @@ export function AppSidebar() {
href: '/orcamentos',
icon: FileCheck2,
},
{
title: 'Regras de investimento',
href: '/regras-investimento',
icon: FileCheck2,
},
{
title: 'Facturas',
href: '/facturas',
+1
View File
@@ -13,5 +13,6 @@ export const previewPaths: Record<string, string> = {
bank: '/consulta/dados-bancarios',
payments: '/consulta/pagamentos',
reinforcements: '/consulta/reforcos',
investment: '/consulta/investimento',
appearance: '/consulta/aparencia',
};
+32
View File
@@ -30,6 +30,7 @@ type Budget = {
includes_tax: boolean;
reinforced_minor: number;
approved_total_minor: number;
kind: string;
};
export default function Budgets({
centers,
@@ -57,6 +58,7 @@ export default function Budgets({
amount: '',
reason: '',
includes_tax: '',
kind: '',
});
return (
<>
@@ -91,6 +93,30 @@ export default function Budgets({
}}
>
<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
@@ -179,6 +205,12 @@ export default function Budgets({
· 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:{' '}
+4 -4
View File
@@ -151,10 +151,10 @@ export default function EquipmentPage({
O responsável da área (chefe do departamento), distinto
do solicitante, valida tecnicamente a versão do pedido
de equipamento. Finanças é obrigatória para qualquer
valor. A adjudicação e emissão de equipamento permanecem
bloqueadas: o limite próprio de investimento e a sua
aplicação exigem decisão da empresa. Uma aprovação
técnica não resolve essa pendência.
valor. A adjudicação e emissão de equipamento exigem
regra de investimento vigente, com duas aprovações
administrativas, e reserva em dotação de investimento. A
validação técnica não substitui estes controlos.
</p>
<Link
className="mt-3 inline-block text-sm font-semibold text-teal-700"
+300
View File
@@ -0,0 +1,300 @@
import { Head, useForm, usePage } from '@inertiajs/react';
import {
FormErrors,
Panel,
PageTitle,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { money } from '@/types/procurement';
type Policy = {
id: number;
created_by: number;
revision: number;
status: string;
mode: string;
scope: string;
currency: string;
ceiling_minor: number;
includes_tax: boolean;
valid_from: string;
valid_until: string;
reason: string;
};
type Review = {
id: number;
investment_policy_id: number;
reviewed_by: number;
decision: string;
reason: string;
};
function Proposal({ previousId }: { previousId: number }) {
const form = useForm({
previous_id: previousId,
mode: '',
scope: '',
currency: '',
ceiling: '',
includes_tax: '',
valid_from: '',
valid_until: '',
reason: '',
});
return (
<Panel
title="Propor nova versão"
description="São necessárias duas aprovações de administradores distintos do proponente. Valores e vigência são decisões da empresa."
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((d) => ({
...d,
includes_tax: d.includes_tax === '1',
}));
form.post('/regras-investimento');
}}
>
<FormErrors errors={form.errors} />
<label>
Significado do limite
<select
required
className={controlClass}
value={form.data.mode}
onChange={(e) => form.setData('mode', e.target.value)}
>
<option value="">Decisão explícita</option>
<option value="ceiling">
Tecto absoluto: acima do limite, bloquear
</option>
<option disabled>
Aprovação adicional: circuito ainda por definir
</option>
</select>
</label>
<label>
Âmbito
<select
required
className={controlClass}
value={form.data.scope}
onChange={(e) => form.setData('scope', e.target.value)}
>
<option value="">Decisão explícita</option>
<option value="process_total">
Total do processo que inclua equipamento
</option>
<option disabled>
Agregação por projecto: ainda não suportada
</option>
</select>
</label>
<label>
Moeda
<select
required
className={controlClass}
value={form.data.currency}
onChange={(e) =>
form.setData('currency', e.target.value)
}
>
<option value="">Seleccionar</option>
<option value="MZN">MZN</option>
</select>
</label>
<label>
Tecto (MZN)
<Input
required
inputMode="decimal"
value={form.data.ceiling}
onChange={(e) =>
form.setData('ceiling', e.target.value)
}
/>
</label>
<label>
Base de IVA
<select
required
className={controlClass}
value={form.data.includes_tax}
onChange={(e) =>
form.setData('includes_tax', e.target.value)
}
>
<option value="">Decisão explícita</option>
<option value="1">Com IVA</option>
<option value="0">Sem IVA</option>
</select>
</label>
<label>
Início da vigência
<Input
required
type="date"
value={form.data.valid_from}
onChange={(e) =>
form.setData('valid_from', e.target.value)
}
/>
</label>
<label>
Fim da vigência
<Input
required
type="date"
value={form.data.valid_until}
onChange={(e) =>
form.setData('valid_until', 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}>
Propor para duas aprovações
</Button>
</form>
</Panel>
);
}
function Decision({ id }: { id: number }) {
const form = useForm({ decision: 'approved', reason: '' });
return (
<form
className="mt-4 grid gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/regras-investimento/${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 esta versão</option>
<option value="rejected">Rejeitar esta versão</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>
<Button disabled={form.processing}>Guardar decisão imutável</Button>
</form>
);
}
export default function Investment({
policies,
reviews,
currentPolicyId,
canManage,
}: {
policies: Policy[];
reviews: Review[];
currentPolicyId: number | null;
canManage: boolean;
}) {
const { auth } = usePage().props;
return (
<>
<Head title="Regras de investimento" />
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
<PageTitle
eyebrow="SGC · Regras da empresa"
title="Investimento em equipamentos"
description="Tecto explícito e versionado, com duas aprovações administrativas. Não substitui validação técnica, Finanças ou disponibilidade orçamental."
/>
<p className="rounded border border-amber-300 p-4 text-sm">
Sem regra vigente, a adjudicação e emissão ficam bloqueadas.
Não há limites automáticos, dispensa de cotações ou
autorização para fraccionar aquisições. Os documentos
continuam de desenvolvimento.
</p>
<p className="text-sm">
Uma proposta pendente não substitui a regra aprovada. Uma
nova regra aprovada aplica-se a partir do início da sua
vigência. Quando uma regra mais recente entra em vigor, não
se regressa a versões anteriores se ela expirar.
Adjudicações guardam a versão aplicável; mudanças
posteriores exigem revisão do processo.
</p>
{canManage && (
<Proposal
key={policies[0]?.id ?? 0}
previousId={policies[0]?.id ?? 0}
/>
)}
{policies.map((p) => (
<Panel
key={p.id}
title={`Versão ${p.revision} · ${p.status === 'approved' ? 'Aprovada' : p.status === 'rejected' ? 'Rejeitada' : 'Pendente'}`}
>
{currentPolicyId === p.id && (
<p className="mb-3 font-semibold text-teal-700">
Regra vigente seleccionada
</p>
)}
<p>
Tecto {money(p.ceiling_minor)} ·{' '}
{p.includes_tax ? 'com IVA' : 'sem IVA'} · total do
processo com equipamento
</p>
<p>
Vigência: {p.valid_from} a {p.valid_until}
</p>
<p className="my-3 whitespace-pre-wrap">{p.reason}</p>
{reviews
.filter((r) => r.investment_policy_id === p.id)
.map((r) => (
<p
key={r.id}
className="my-2 rounded border p-3 text-sm"
>
{r.decision === 'approved'
? 'Aprovação'
: 'Rejeição'}{' '}
· Administrador #{r.reviewed_by} ·{' '}
{r.reason}
</p>
))}
{canManage &&
p.status === 'pending' &&
p.id === policies[0]?.id &&
p.created_by !== auth.user.id &&
!reviews.some(
(r) =>
r.investment_policy_id === p.id &&
r.reviewed_by === auth.user.id,
) && <Decision id={p.id} />}
</Panel>
))}
{policies.length === 0 && (
<p>Sem regras propostas nesta empresa.</p>
)}
</div>
</>
);
}
+37 -1
View File
@@ -122,6 +122,7 @@ const navigation = [
{ 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: 'investment', title: 'Regras de investimento', icon: ShieldCheck },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
@@ -132,7 +133,7 @@ const navigationGroups = [
{ title: 'Controlo', ids: ['conflicts'] },
{
title: 'Finanças',
ids: ['invoices', 'bank', 'payments', 'reinforcements'],
ids: ['invoices', 'bank', 'payments', 'reinforcements', 'investment'],
},
{ title: 'Apresentação', ids: ['appearance'] },
];
@@ -669,6 +670,41 @@ export default function Preview({
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'investment' && (
<Panel
title="Regra de investimento · exemplo de circuito"
description="Sem limites predefinidos, consultas ou gravações de negócio."
>
<div className="space-y-3 text-sm">
<p>
A empresa escolhe o tecto por processo,
a base com/sem IVA e as datas. Dois
administradores distintos do proponente
aprovam a versão.
</p>
<p>
Equipamento exige também validação
técnica, Finanças para qualquer valor e
reserva em dotação de investimento. O
tecto aplica-se ao total do processo que
contenha equipamento.
</p>
<p>
Sem regra vigente, ou acima do tecto, a
adjudicação fica bloqueada. A ordem
conserva a versão aplicada; alterações
posteriores não reescrevem documentos
emitidos.
</p>
<p>
Aprovação adicional, agregação por
projecto e detecção automática de
fraccionamento não fazem parte desta
etapa.
</p>
</div>
</Panel>
)}
{screen === 'reinforcements' && (
<Panel
title="Reforço orçamental · exemplo fictício"
+23
View File
@@ -63,6 +63,29 @@ export default function OrderShow({
' Regras de aprovação provisórias.'}
</div>
<div className="grid gap-6 md:grid-cols-2">
{snapshot.investment_rules && (
<Panel title="Regra de investimento congelada">
<p>
Versão {snapshot.investment_rules.revision} ·
Tecto{' '}
{money(snapshot.investment_rules.ceiling_minor)}{' '}
·{' '}
{snapshot.investment_rules.includes_tax
? 'com IVA'
: 'sem IVA'}
</p>
<p>
Vigência: {snapshot.investment_rules.valid_from}{' '}
a {snapshot.investment_rules.valid_until}
</p>
{snapshot.equipment_requests?.map((line) => (
<p key={line.id} className="mt-2">
{line.description} · Área:{' '}
{line.production_area}
</p>
))}
</Panel>
)}
<Panel title="Fornecedor">
<dl className="space-y-3 text-sm">
<div>
+3 -3
View File
@@ -295,9 +295,9 @@ export default function RequisitionForm({
}
>
<option value="equipment">
Equipamento (emissão
bloqueada até definição da
regra de investimento)
Equipamento (exige regra e
dotação de investimento
aprovadas)
</option>
<option value="spare_part">
Peça sobressalente
+6 -5
View File
@@ -77,11 +77,12 @@ export default function Show({
<p className="mb-4 text-sm">
Responsável da área, distinto do solicitante.
Finanças é obrigatória para qualquer valor. A
adjudicação e a emissão permanecem bloqueadas
enquanto o limite próprio de investimento e a sua
regra de aplicação não forem definidos. O orçamento
ainda não é validado; documentos de desenvolvimento
não autorizam compras reais.
adjudicação e a emissão exigem regra de investimento
vigente, com duas aprovações administrativas, e
reserva em dotação de investimento. Sem estas
condições permanecem bloqueadas. O orçamento ainda
não é validado; documentos de desenvolvimento não
autorizam compras reais.
</p>
{technical?.reviews.map((t) => (
<p
+14
View File
@@ -66,6 +66,20 @@ export type PurchaseOrder = {
total_minor: number;
items: OrderItem[];
snapshot: {
investment_rules?: {
id: number;
revision: number;
ceiling_minor: number;
includes_tax: boolean;
valid_from: string;
valid_until: string;
reviewer_ids: number[];
} | null;
equipment_requests?: {
id: number;
description: string;
production_area: string;
}[];
company: { name: string };
department: { name: string };
cost_center: { code: string; name: string };