Files
compras/resources/js/pages/supply/suggestions.tsx
T
2026-11-03 11:51:44 +02:00

796 lines
33 KiB
TypeScript

import { Head, Link, router, useForm } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import SupplyPagination, {
type SupplyPageInfo,
} from '@/components/supply-pagination';
type Policy = {
key: string;
version: number;
label: string;
issue: string | null;
quantity_milli?: number;
delivery_on?: string | null;
unit_price?: string | null;
tax_rate?: string | null;
unit?: string;
};
type Suggestion = {
canApprove?: boolean;
canReturn?: boolean;
canConvert?: boolean;
canCancel?: boolean;
canClose?: boolean;
id: number;
record_key: string;
version: number;
state: string;
reason: string;
created_at: string;
data: {
policy_key: string;
policy_version: number;
quantity_milli: number;
delivery_on: string;
unit_price: string;
tax_rate: string;
manual_plan: boolean;
price_source: string;
tax_source: string;
requisition_id?: number;
requisition_number?: string;
};
snapshot: {
forecast_history?: {
available: boolean;
reason?: string | null;
period_from: string;
period_until: string;
version: number;
analysis: { days: number; daily_mean_milli: string };
};
item: { code: string; name: string; unit: string };
warehouse: { name: string };
contributors: number[];
reviewed_by?: number;
actor: { name: string };
prepared_on: string;
balance: { quantity: number; version: number };
events: {
reservations: { date: string; quantity: number }[];
receipts: { date: string; quantity: number }[];
warnings: string[];
};
policy: { purpose: string; origin: string };
projection: {
lot_warning?: string;
usable_milli?: number;
available: boolean;
risk_date?: string;
request_date?: string;
order_date?: string;
series?: { date: string; balance_milli: number }[];
};
};
};
type Props = {
policySearch?: string;
policyFormKey?: string | null;
recordPagination?: SupplyPageInfo | null;
focusRecordId?: number | null;
focusChanged?: boolean;
actorId: number;
ready: boolean;
records: Suggestion[];
policies: Policy[];
canPrepare: boolean;
canReview: boolean;
canConvert: boolean;
excess: Record<string, boolean>;
costCenters: { id: number; code: string; name: string }[];
requestLinks: Record<string, { id: number; number: string }>;
history: Record<string, Suggestion[]>;
};
const states: Record<string, string> = {
draft: 'Para conferência',
returned: 'Devolvida para actualização',
approved: 'Aprovada para requisitar',
cancelled: 'Cancelada',
converted: 'Requisição criada — em acompanhamento',
closed: 'Acompanhamento encerrado',
};
const quantity = (value: number) =>
(value / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
function Prepare({ props, old }: { props: Props; old?: Suggestion }) {
const [selected, setSelected] = useState(old?.data.policy_key ?? '');
const [policies, setPolicies] = useState(props.policies);
const [policySearch, setPolicySearch] = useState(props.policySearch ?? '');
const formKey = `suggestion:${old?.id ?? 'new'}`;
useEffect(() => {
if (!props.policyFormKey || props.policyFormKey === formKey)
setPolicies(props.policies);
}, [props.policyFormKey, props.policies, formKey]);
const option = policies.find((p) => p.key === selected);
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: old?.record_key,
version: old?.version ?? 0,
action: 'save',
reason: '',
data: {
policy_key: old?.data.policy_key ?? '',
unit_price: option?.unit_price ?? old?.data.unit_price ?? '',
tax_rate: option?.tax_rate ?? old?.data.tax_rate ?? '',
quantity: old?.data.manual_plan
? String(old.data.quantity_milli / 1000)
: '',
delivery_on: old?.data.manual_plan ? old.data.delivery_on : '',
},
});
const searchPolicies = () =>
router.get(
'/compras/fornecimento',
{
view: 'suggestions',
records_page: props.recordPagination?.current_page,
...(props.focusRecordId
? { record_id: props.focusRecordId }
: {}),
policy_search: policySearch,
selected_policy_key: selected || undefined,
policy_form_key: formKey,
},
{
only: ['policies', 'policySearch', 'policyFormKey'],
preserveState: true,
preserveScroll: true,
},
);
return (
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((input) => ({
...input,
data: {
policy_key: input.data.policy_key,
unit_price: input.data.unit_price || null,
tax_rate: input.data.tax_rate || null,
quantity_milli:
input.data.quantity === ''
? null
: Math.round(
Number(input.data.quantity) * 1000,
),
delivery_on: input.data.delivery_on || null,
},
}));
form.post('/compras/fornecimento/suggestion', {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<details className="rounded border p-3">
<summary className="cursor-pointer text-sm">
Procurar política aprovada
</summary>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-sm">
Código ou descrição do artigo ou armazém
<input
className={controlClass}
value={policySearch}
maxLength={100}
onChange={(event) =>
setPolicySearch(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchPolicies();
}
}}
/>
</label>
<Button
type="button"
variant="outline"
onClick={searchPolicies}
>
Procurar
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
A selecção elegível e os dados introduzidos são conservados.
As condições da política continuam a ser verificadas antes
de preparar a compra.
</p>
</details>
<label className="grid gap-1 text-sm">
Artigo e local com política aprovada
<select
required
disabled={Boolean(old)}
className={controlClass}
value={selected}
onChange={(e) => {
const p = policies.find(
(o) => o.key === e.target.value,
);
setSelected(e.target.value);
form.setData('data', {
policy_key: e.target.value,
unit_price: p?.unit_price ?? '',
tax_rate: p?.tax_rate ?? '',
quantity: '',
delivery_on: '',
});
}}
>
<option value="">Seleccionar política existente</option>
{policies.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
{old && !option && (
<option value={old.data.policy_key}>
{old.snapshot.item.code} · política de origem
indisponível
</option>
)}
</select>
</label>
{option?.issue && (
<p role="status" className="text-sm">
{option.issue}
</p>
)}
{option && !option.issue && (
<div className="rounded-lg bg-muted p-4 text-sm">
<p>
Proposta calculada:{' '}
<strong>
{quantity(option.quantity_milli ?? 0)} {option.unit}
</strong>{' '}
· Receber em{' '}
<strong>
{option.delivery_on ?? 'data manual por definir'}
</strong>
</p>
<p className="mt-2 text-muted-foreground">
O servidor volta a conferir as fontes ao guardar. O
cálculo não cria requisição, ordem ou movimento de
stock.
</p>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1 text-sm">
Estimativa por unidade em MZN
<input
className={controlClass}
type="number"
min="0.01"
step="0.01"
required
value={form.data.data.unit_price}
onChange={(e) =>
form.setData('data', {
...form.data.data,
unit_price: e.target.value,
})
}
/>
<span className="text-xs text-muted-foreground">
{option?.unit_price
? 'Preenchida a partir do acordo; uma alteração fica fundamentada.'
: 'Indique a estimativa e a fonte no fundamento abaixo.'}
</span>
</label>
<label className="grid gap-1 text-sm">
Taxa de IVA da estimativa (%)
<input
className={controlClass}
type="number"
min="0"
max="100"
step="0.01"
required
value={form.data.data.tax_rate}
onChange={(e) =>
form.setData('data', {
...form.data.data,
tax_rate: e.target.value,
})
}
/>
<span className="text-xs text-muted-foreground">
Valor configurado da empresa quando disponível; confirme
a aplicação ao artigo.
</span>
</label>
</div>
<details>
<summary className="cursor-pointer text-sm font-medium">
Propor quantidade ou data diferentes
</summary>
<p className="my-3 text-sm text-muted-foreground">
Deixe em branco para reutilizar o cálculo actual. Alterações
ficam identificadas e exigem conferência explícita.
</p>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-1 text-sm">
Quantidade manual
<input
className={controlClass}
type="number"
min="0.001"
step="0.001"
value={form.data.data.quantity}
onChange={(e) =>
form.setData('data', {
...form.data.data,
quantity: e.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm">
Data manual de recepção
<input
className={controlClass}
type="date"
value={form.data.data.delivery_on}
onChange={(e) =>
form.setData('data', {
...form.data.data,
delivery_on: e.target.value,
})
}
/>
</label>
</div>
</details>
<label className="grid gap-1 text-sm">
Fundamento e fontes das estimativas
<textarea
className="rounded-lg border p-3"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<Button
disabled={
form.processing ||
!props.ready ||
!option ||
Boolean(option.issue)
}
>
Guardar sugestão para conferência
</Button>
</form>
);
}
function Facts({
record: r,
detailed = false,
}: {
record: Suggestion;
detailed?: boolean;
}) {
return (
<div className="space-y-3 text-sm">
<div className="grid gap-3 md:grid-cols-4">
<p>
Quantidade
<br />
<strong>
{quantity(r.data.quantity_milli)} {r.snapshot.item.unit}
</strong>
</p>
<p>
Receber em
<br />
<strong>{r.data.delivery_on}</strong>
</p>
<p>
Estimativa por unidade
<br />
<strong>{r.data.unit_price} MZN</strong>
</p>
<p>
IVA da estimativa
<br />
<strong>{r.data.tax_rate}%</strong>
</p>
</div>
<p className="text-muted-foreground">
{r.snapshot.policy.purpose === 'resale'
? 'Revenda'
: 'Uso interno'}{' '}
·{' '}
{r.snapshot.policy.origin === 'import'
? 'Importação directa'
: 'Compra local'}{' '}
· Política versão {r.data.policy_version} · Preparação de{' '}
{r.snapshot.prepared_on}
</p>
<p>
Preço:{' '}
{r.data.price_source === 'agreement'
? 'reutilizado do acordo'
: 'estimativa manual fundamentada'}{' '}
· IVA:{' '}
{r.data.tax_source === 'company_default'
? 'configuração da empresa'
: 'taxa confirmada manualmente'}
</p>
{r.snapshot.projection.lot_warning && (
<p className="font-medium">
{r.snapshot.projection.lot_warning} Utilizável no cálculo:{' '}
{quantity(r.snapshot.projection.usable_milli ?? 0)}.
</p>
)}
{r.snapshot.forecast_history && (
<p>
{r.snapshot.forecast_history.available
? `Média histórica preservada: ${r.snapshot.forecast_history.period_from} a ${r.snapshot.forecast_history.period_until}, ${r.snapshot.forecast_history.analysis.days} dias, versão ${r.snapshot.forecast_history.version}.`
: `Histórico indisponível no cálculo: ${r.snapshot.forecast_history.reason}`}
</p>
)}
{r.data.manual_plan && (
<p className="font-medium">
Quantidade/data propostas manualmente ou procura habitual
incompleta.
</p>
)}
{r.snapshot.events.warnings.map((w) => (
<p key={w} className="font-medium">
{w}
</p>
))}
{detailed && (
<details>
<summary className="cursor-pointer font-medium">
Base preservada da previsão
</summary>
<div className="mt-3 space-y-2">
<p>
Stock físico no cálculo:{' '}
{quantity(r.snapshot.balance.quantity)}{' '}
{r.snapshot.item.unit}
</p>
<p>
{r.snapshot.events.reservations.length} reservas
pendentes · {r.snapshot.events.receipts.length}{' '}
entregas utilizáveis confirmadas
</p>
<p>
Iniciar requisição até{' '}
{r.snapshot.projection.request_date ??
'data manual'}{' '}
· Emitir ordem até{' '}
{r.snapshot.projection.order_date ?? 'data manual'}
</p>
<div className="max-h-64 overflow-auto">
<table className="w-full">
<thead>
<tr>
<th className="text-left">Data</th>
<th className="text-right">
Saldo sem a nova reposição
</th>
</tr>
</thead>
<tbody>
{r.snapshot.projection.series?.map(
(day) => (
<tr key={day.date}>
<td>{day.date}</td>
<td className="text-right">
{quantity(
day.balance_milli,
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
</details>
)}
</div>
);
}
function Decisions({ props, record: r }: { props: Props; record: Suggestion }) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: r.record_key,
version: r.version,
action: '',
reason: '',
data: { cost_center_id: '', confirm_exception: false },
});
const choices: [string, string][] = [];
if (r.canApprove && !props.excess[r.record_key])
choices.push(['approve', 'Aprovar para requisitar']);
if (r.canReturn) choices.push(['return', 'Devolver para actualização']);
if (r.canClose)
choices.push(['close', 'Conferir e encerrar acompanhamento']);
if (r.canCancel) choices.push(['cancel', 'Cancelar sugestão pendente']);
if (r.canConvert)
choices.push(['convert', 'Criar requisição a partir desta aprovação']);
return (
<div className="mt-4 space-y-4">
{props.excess[r.record_key] && (
<p className="text-sm font-medium">
A quantidade excede o máximo no horizonte. Reveja
quantidade, calendário ou política antes da aprovação.
</p>
)}
{props.canPrepare && ['draft', 'returned'].includes(r.state) && (
<details>
<summary className="cursor-pointer text-sm font-medium">
Actualizar fontes e preparação
</summary>
<Prepare props={props} old={r} />
</details>
)}
{choices.length > 0 && (
<label className="grid gap-1 text-sm">
Acção da minha responsabilidade
<select
className={controlClass}
value={form.data.action}
onChange={(e) => form.setData('action', e.target.value)}
>
<option value="">Seleccionar acção</option>
{choices.map(([key, label]) => (
<option value={key} key={key}>
{label}
</option>
))}
</select>
</label>
)}
{form.data.action && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.post('/compras/fornecimento/suggestion', {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
{form.data.action === 'approve' &&
(r.data.manual_plan ||
r.snapshot.events.warnings.length > 0) && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
required
checked={form.data.data.confirm_exception}
onChange={(e) =>
form.setData('data', {
...form.data.data,
confirm_exception: e.target.checked,
})
}
/>
Conferi a proposta manual ou as pendências e
fundamento a decisão abaixo.
</label>
)}
{form.data.action === 'convert' && (
<>
<label className="grid gap-1 text-sm">
Centro de custo do meu departamento
<select
className={controlClass}
required
value={form.data.data.cost_center_id}
onChange={(e) =>
form.setData('data', {
...form.data.data,
cost_center_id: e.target.value,
})
}
>
<option value="">
Seleccionar centro existente
</option>
{props.costCenters.map((center) => (
<option
value={center.id}
key={center.id}
>
{center.code} · {center.name}
</option>
))}
</select>
</label>
<p className="text-sm text-muted-foreground">
Será criado um único rascunho com o artigo do
catálogo, quantidade, data e estimativas
aprovadas. A requisição mantém as aprovações,
controlo orçamental e etapas de compra
habituais.
</p>
</>
)}
<label className="grid gap-1 text-sm">
{form.data.action === 'convert'
? 'Justificação da requisição'
: 'Fundamento da decisão'}
<textarea
className="rounded-lg border p-3"
required
minLength={10}
maxLength={4000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
<Button disabled={form.processing}>Confirmar</Button>
</form>
)}
</div>
);
}
export default function Suggestions(props: Props) {
return (
<>
<Head title="Sugestões de reposição" />
<div className="space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow="Compras · Reposição"
title="Sugestões de reposição"
description="Preparar, conferir e requisitar a partir dos dados já registados. Cada sugestão conserva o cálculo e as fontes utilizados."
/>
<Panel title="Responsabilidades">
<p className="text-sm text-muted-foreground">
O preparador reúne a proposta; outra pessoa confere. O
solicitante autorizado converte a aprovação numa
requisição do seu departamento. A pessoa que conferiu
não se torna autora dessa requisição. Uma necessidade em
acompanhamento mantém a ligação ao documento existente.
</p>
</Panel>
{!props.ready && (
<p role="status">Instalação do fornecimento pendente.</p>
)}
{props.focusRecordId && (
<div className="space-y-2 text-sm">
<Link
href="/compras/fornecimento?view=suggestions"
className="underline"
>
Ver todas as sugestões
</Link>
{props.focusChanged && (
<p role="status">
Esta sugestão foi actualizada. Está a consultar
a versão actual; as versões anteriores
permanecem no histórico.
</p>
)}
</div>
)}
{props.canPrepare && !props.focusRecordId && (
<Panel title="Preparar sugestão">
<details>
<summary className="cursor-pointer font-medium">
Seleccionar artigo e política
</summary>
<Prepare props={props} />
</details>
</Panel>
)}
{props.records.length === 0 && (
<Panel title="Sem sugestões">
<p className="text-sm text-muted-foreground">
O cálculo do calendário não cria documentos
automaticamente. Um preparador autorizado pode
guardar uma sugestão para conferência.
</p>
</Panel>
)}
{props.records.map((r) => (
<Panel
key={r.id}
title={`${r.snapshot.item.code} · ${r.snapshot.item.name}`}
description={`${r.snapshot.warehouse.name} · ${states[r.state]} · Versão ${r.version}`}
>
<div id={`supply-suggestion-${r.id}`} />
<Facts record={r} detailed />
<p className="mt-3 text-sm">{r.reason}</p>
{r.data.requisition_id && (
<p className="mt-3 text-sm font-medium">
{props.requestLinks[
String(r.data.requisition_id)
] ? (
<Link
className="underline"
href={`/requisicoes/${r.data.requisition_id}`}
>
Abrir {r.data.requisition_number}
</Link>
) : (
`Documento criado: ${r.data.requisition_number}. A consulta segue as permissões da requisição.`
)}
</p>
)}
<Decisions props={props} record={r} />
<details className="mt-4">
<summary className="cursor-pointer text-sm">
Histórico de preparação e decisões
</summary>
<ol className="mt-3 space-y-4">
{props.history[r.record_key]?.map((h) => (
<li
className="border-l-2 pl-4 text-sm"
key={h.id}
>
<p className="font-medium">
Versão {h.version} ·{' '}
{states[h.state]} ·{' '}
{h.snapshot.actor.name} ·{' '}
{new Date(
h.created_at,
).toLocaleString('pt-MZ')}
</p>
<p className="my-2">{h.reason}</p>
<Facts record={h} />
</li>
))}
</ol>
</details>
</Panel>
))}
<SupplyPagination page={props.recordPagination} />
<p className="flex flex-wrap gap-5 text-sm">
<Link
className="underline"
href="/compras/fornecimento?view=calendar"
>
Reposição e calendário
</Link>
<Link className="underline" href="/requisicoes">
Requisições
</Link>
<Link
className="underline"
href="/administracao/parametrizacao"
>
Perfis e responsabilidades
</Link>
</p>
</div>
</>
);
}