Implementa regras de investimento por empresa com dupla aprovacao
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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:{' '}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user