714 lines
30 KiB
TypeScript
714 lines
30 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import {
|
|
FormErrors,
|
|
PageTitle,
|
|
Panel,
|
|
controlClass,
|
|
} from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
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 = {
|
|
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 = {
|
|
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 option = props.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 : '',
|
|
},
|
|
});
|
|
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} />
|
|
<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 = props.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>
|
|
{props.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 independent = !r.snapshot.contributors.includes(props.actorId);
|
|
const choices: [string, string][] = [];
|
|
if (props.canReview && independent) {
|
|
if (
|
|
['draft', 'returned'].includes(r.state) &&
|
|
!props.excess[r.record_key]
|
|
)
|
|
choices.push(['approve', 'Aprovar para requisitar']);
|
|
if (['draft', 'approved'].includes(r.state))
|
|
choices.push(['return', 'Devolver para actualização']);
|
|
if (r.state === 'converted')
|
|
choices.push(['close', 'Conferir e encerrar acompanhamento']);
|
|
}
|
|
if (props.canPrepare && ['draft', 'returned'].includes(r.state))
|
|
choices.push(['cancel', 'Cancelar sugestão pendente']);
|
|
if (
|
|
props.canConvert &&
|
|
r.state === 'approved' &&
|
|
r.snapshot.reviewed_by !== props.actorId
|
|
)
|
|
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.canPrepare && (
|
|
<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}`}
|
|
>
|
|
<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>
|
|
))}
|
|
<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>
|
|
</>
|
|
);
|
|
}
|