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

1450 lines
62 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 CorrectionSourceSearch from '@/components/correction-source-search';
import SupplyPagination, {
type SupplyPageInfo,
} from '@/components/supply-pagination';
type Data = Record<string, string | number | boolean | null>;
type SupplyRecord = {
canApprove?: boolean;
canReturn?: boolean;
canSuspend?: boolean;
id: number;
record_key: string;
version: number;
state: string;
data: Data;
reason: string;
created_at: string;
snapshot: {
item?: { code: string; name: string; unit: string };
warehouse?: { name: string };
supplier?: { name: string };
order?: { id: number; number: string };
};
};
type Option = { id: number; code?: string; name?: string; number?: string };
type Projection = {
lead_time_issue?: string;
shortage_date?: string | null;
available: boolean;
reason?: string;
risk_date?: string;
request_date?: string | null;
order_date?: string | null;
quantity_milli?: number;
excess_milli?: number;
future_excess_milli?: number;
convention?: string;
demand_complete?: boolean;
lot_warning?: string;
usable_milli?: number;
forecast_history?: {
unit: string;
available: boolean;
reason?: string | null;
period_from: string;
period_until: string;
analysis: {
total_milli: number;
days: number;
daily_mean_milli: string;
};
} | null;
events?: {
warnings: string[];
reservations: { date: string; quantity: number }[];
receipts: { date: string; quantity: number }[];
};
series?: { date: string; balance_milli: number }[];
};
type Props = {
agreementSearch?: string;
agreementFormKey?: string | null;
orderSearch?: string;
historyFormKey?: string | null;
recordPagination?: SupplyPageInfo | null;
focusRecordId?: number | null;
focusChanged?: boolean;
asOf: string;
view: string;
kind: string;
ready: boolean;
records: SupplyRecord[];
history: Record<string, SupplyRecord[]>;
projections: Record<string, Projection>;
canPrepare: boolean;
canReview: boolean;
canCustoms: boolean;
items: Option[];
warehouses: Option[];
suppliers: Option[];
orders: Option[];
histories: {
key: string;
version: number;
stock_item_id: number;
warehouse_id: number;
purpose: string;
period_from: string;
period_until: string;
quality: string;
pattern: string;
days: number;
daily_mean_milli: string;
}[];
agreements: (SupplyRecord & {
stock_item_id: number;
warehouse_id: number;
})[];
};
const states: Record<string, string> = {
draft: 'Em preparação',
returned: 'Devolvido',
approved: 'Aprovado',
suspended: 'Suspenso',
transit: 'Em trânsito',
arrived: 'Chegada registada',
held: 'Retido',
released: 'Libertação comprovada',
};
const titles: Record<string, string> = {
agreements: 'Fornecimento habitual',
calendar: 'Reposição e calendário',
imports: 'Importações',
};
const quantity = (value: number | undefined) =>
value === undefined
? '—'
: (value / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
const labels: Record<string, string> = {
forecast_method: 'Método de procura',
history_min_days: 'Mínimo de dias históricos',
history_max_age_days: 'Idade máxima do período',
history_version: 'Versão da análise histórica',
purpose: 'Finalidade',
origin: 'Origem do fornecimento',
safety_milli: 'Stock de segurança',
minimum_stock_milli: 'Stock mínimo',
target_milli: 'Stock alvo',
maximum_milli: 'Stock máximo',
daily_milli: 'Procura diária',
lead_days: 'Prazo em dias corridos',
approval_days: 'Antecedência de aprovação',
horizon_days: 'Horizonte em dias',
minimum_milli: 'Mínimo de compra',
pack_milli: 'Múltiplo de embalagem',
basis: 'Fonte dos parâmetros',
habitual: 'Fornecedor habitual',
valid_from: 'Vigente desde',
valid_until: 'Vigente até',
currency: 'Moeda',
price_minor: 'Preço por unidade',
reference: 'Referência documental',
jurisdiction: 'Jurisdição',
origin_country: 'País de origem',
dispatch_country: 'País de expedição',
incoterm: 'Incoterm, local e edição',
regime: 'Regime',
expected_usable_on: 'Recepção utilizável prevista',
};
const values: Record<string, string> = {
manual: 'Procura manual ou contratual',
historical: 'Média histórica conferida',
resale: 'Revenda',
internal: 'Uso interno e reposição',
local: 'Compra local',
import: 'Importação directa',
};
function PreservedData({ record }: { record: SupplyRecord }) {
return (
<dl className="mt-3 grid gap-2 text-sm md:grid-cols-2">
{Object.entries(record.data)
.filter(([key]) => key in labels)
.map(([key, value]) => (
<div key={key}>
<dt className="font-medium">{labels[key]}</dt>
<dd>
{value === null || value === ''
? 'Por recolher'
: key.endsWith('_milli')
? quantity(Number(value))
: key === 'price_minor'
? (Number(value) / 100).toFixed(2)
: typeof value === 'boolean'
? value
? 'Sim'
: 'Não'
: (values[String(value)] ??
String(value))}
</dd>
</div>
))}
</dl>
);
}
function Preparation({
props,
record,
copy = false,
}: {
props: Props;
record?: SupplyRecord;
copy?: boolean;
}) {
const defaults: Data =
props.kind === 'policy'
? {
stock_item_id: '',
warehouse_id: '',
purpose: 'internal',
origin: 'local',
safety: '',
minimum_stock: '',
target: '',
maximum: '',
daily: '',
forecast_method: 'manual',
history_key: '',
history_min_days: 30,
history_max_age_days: 90,
lead_days: '',
approval_days: '',
horizon_days: 90,
minimum: '0',
pack: '1',
basis: '',
}
: props.kind === 'agreement'
? {
stock_item_id: '',
warehouse_id: '',
supplier_id: '',
habitual: true,
origin: 'local',
valid_from: '',
valid_until: '',
currency: 'MZN',
price: '',
lead_days: '',
reference: '',
}
: {
purchase_order_id: '',
purpose: 'internal',
jurisdiction: '',
origin_country: '',
dispatch_country: '',
incoterm: '',
regime: '',
expected_usable_on: '',
};
if (record) {
Object.assign(defaults, record.data);
for (const key of [
'safety',
'minimum_stock',
'target',
'maximum',
'daily',
'minimum',
'pack',
]) {
if (`${key}_milli` in record.data)
defaults[key] =
record.data[`${key}_milli`] === null
? ''
: Number(record.data[`${key}_milli`]) / 1000;
}
if ('price_minor' in record.data)
defaults.price =
record.data.price_minor === null
? ''
: Number(record.data.price_minor) / 100;
}
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: copy ? undefined : record?.record_key,
version: copy ? 0 : (record?.version ?? 0),
action: 'save',
reason: '',
data: defaults,
});
const [orderSearch, setOrderSearch] = useState(props.orderSearch ?? '');
const [historyOptions, setHistoryOptions] = useState(props.histories);
const [periodFrom, setPeriodFrom] = useState('');
const [periodUntil, setPeriodUntil] = useState('');
const historyFormKey = `${copy ? 'copy' : 'prepare'}:${record?.id ?? 'new'}`;
const [agreementOptions, setAgreementOptions] = useState(props.agreements);
useEffect(() => {
if (
!props.agreementFormKey ||
props.agreementFormKey === historyFormKey
)
setAgreementOptions(props.agreements);
}, [props.agreementFormKey, props.agreements, historyFormKey]);
const searchAgreements = (search: string) =>
router.get(
'/compras/fornecimento',
{
view: 'calendar',
records_page: props.recordPagination?.current_page,
...(props.focusRecordId
? { record_id: props.focusRecordId }
: {}),
agreement_search: search,
agreement_stock_item_id: form.data.data.stock_item_id,
agreement_warehouse_id: form.data.data.warehouse_id,
selected_agreement_key:
form.data.data.agreement_key || undefined,
agreement_form_key: historyFormKey,
},
{
only: ['agreements', 'agreementSearch', 'agreementFormKey'],
preserveState: true,
preserveScroll: true,
},
);
useEffect(() => {
if (!props.historyFormKey || props.historyFormKey === historyFormKey)
setHistoryOptions(props.histories);
}, [props.historyFormKey, props.histories, historyFormKey]);
const searchHistories = () =>
router.get(
'/compras/fornecimento',
{
view: 'calendar',
records_page: props.recordPagination?.current_page,
...(props.focusRecordId
? { record_id: props.focusRecordId }
: {}),
history_stock_item_id: form.data.data.stock_item_id,
history_warehouse_id: form.data.data.warehouse_id,
history_purpose: form.data.data.purpose,
history_period_from: periodFrom || undefined,
history_period_until: periodUntil || undefined,
selected_history_key: form.data.data.history_key || undefined,
history_form_key: historyFormKey,
},
{
only: ['histories', 'historyFormKey'],
preserveState: true,
preserveScroll: true,
},
);
const searchOrders = () =>
router.get(
'/compras/fornecimento',
{
view: 'imports',
records_page: props.recordPagination?.current_page,
order_search: orderSearch,
selected_order_id:
form.data.data.purchase_order_id || undefined,
...(props.focusRecordId
? { record_id: props.focusRecordId }
: {}),
},
{
only: ['orders', 'orderSearch'],
preserveState: true,
preserveScroll: true,
},
);
const field = (
key: string,
label: string,
type = 'text',
optional = false,
) => (
<label className="grid gap-1 text-sm" key={key}>
{label}
{optional && ' (opcional)'}
<input
className={controlClass}
type={type}
required={!optional}
step={type === 'number' ? '0.001' : undefined}
min={type === 'number' ? 0 : undefined}
value={String(form.data.data[key] ?? '')}
onChange={(e) =>
form.setData('data', {
...form.data.data,
[key]: e.target.value,
})
}
/>
</label>
);
const select = (key: string, label: string, options: Option[]) => (
<label className="grid gap-1 text-sm">
{label}
<select
className={controlClass}
required
value={String(form.data.data[key] ?? '')}
onChange={(e) =>
form.setData('data', {
...form.data.data,
[key]: e.target.value,
})
}
>
<option value="">Seleccionar cadastro existente</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.code ? `${o.code} · ` : ''}
{o.name ?? o.number}
</option>
))}
</select>
</label>
);
const choice = (
key: string,
label: string,
options: [string, string][],
) => (
<label className="grid gap-1 text-sm">
{label}
<select
className={controlClass}
value={String(form.data.data[key])}
onChange={(e) =>
form.setData('data', {
...form.data.data,
[key]: e.target.value,
})
}
>
{options.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
);
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.transform((payload) => {
const data: Data = {
...payload.data,
...(copy && record
? {
predecessor_key: record.record_key,
predecessor_version: record.version,
}
: {}),
};
if (props.kind === 'policy')
for (const key of [
'safety',
'minimum_stock',
'target',
'maximum',
'daily',
'minimum',
'pack',
]) {
data[`${key}_milli`] =
data[key] === ''
? null
: Math.round(Number(data[key]) * 1000);
delete data[key];
}
if (props.kind === 'agreement') {
data.price_minor =
data.price === ''
? null
: Math.round(Number(data.price) * 100);
delete data.price;
}
return { ...payload, data };
});
form.post(`/compras/fornecimento/${props.kind}`, {
preserveScroll: true,
onSuccess: () =>
form.setData('operation_key', crypto.randomUUID()),
});
}}
>
<FormErrors errors={form.errors} />
{props.kind === 'import' && (
<details className="rounded border p-3">
<summary className="cursor-pointer text-sm">
Procurar ordem pelo número
</summary>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-sm">
Número ou parte do número
<input
className={controlClass}
value={orderSearch}
maxLength={100}
onChange={(event) =>
setOrderSearch(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchOrders();
}
}}
/>
</label>
<Button
type="button"
variant="outline"
onClick={searchOrders}
>
Procurar
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Seleccione a ordem nos resultados abaixo. A ordem já
escolhida e os dados do formulário são conservados
durante a pesquisa.
</p>
</details>
)}
<div className="grid gap-4 md:grid-cols-2">
{props.kind === 'import' ? (
select('purchase_order_id', 'Ordem de compra', props.orders)
) : (
<>
{select(
'stock_item_id',
'Artigo e unidade',
props.items,
)}
{select('warehouse_id', 'Armazém', props.warehouses)}
</>
)}
{props.kind !== 'agreement' &&
choice('purpose', 'Finalidade', [
['internal', 'Uso interno e reposição'],
['resale', 'Revenda'],
])}
{props.kind !== 'import' &&
choice('origin', 'Origem do fornecimento', [
['local', 'Compra local'],
['import', 'Importação directa'],
])}
{props.kind === 'agreement' && (
<>
{select(
'supplier_id',
'Fornecedor validado',
props.suppliers,
)}
{field('reference', 'Referência do acordo ou oferta')}
{field('valid_from', 'Vigente desde', 'date')}
{field('valid_until', 'Vigente até', 'date')}
{field('currency', 'Moeda')}
{field(
'price',
'Preço por unidade do artigo',
'number',
true,
)}
{field(
'lead_days',
'Prazo de entrega em dias corridos',
'number',
true,
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(form.data.data.habitual)}
onChange={(e) =>
form.setData('data', {
...form.data.data,
habitual: e.target.checked,
})
}
/>
Fornecedor habitual para este artigo/local
</label>
</>
)}
{props.kind === 'policy' && (
<>
{choice(
'forecast_method',
'Procura usada no calendário',
[
[
'manual',
'Procura diária manual ou contratual',
],
[
'historical',
'Média de um período histórico conferido',
],
],
)}
{form.data.data.forecast_method === 'historical' && (
<div className="space-y-3 md:col-span-2">
<details className="rounded border p-3">
<summary className="cursor-pointer text-sm">
Procurar análises aprovadas por período
</summary>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-sm">
Período a partir de
<input
className={controlClass}
type="date"
value={periodFrom}
onChange={(event) =>
setPeriodFrom(
event.target.value,
)
}
onKeyDown={(event) => {
if (event.key === 'Enter')
event.preventDefault();
}}
/>
</label>
<label className="grid gap-1 text-sm">
Período até
<input
className={controlClass}
type="date"
value={periodUntil}
onChange={(event) =>
setPeriodUntil(
event.target.value,
)
}
onKeyDown={(event) => {
if (event.key === 'Enter')
event.preventDefault();
}}
/>
</label>
<Button
type="button"
variant="outline"
disabled={
!form.data.data.stock_item_id ||
!form.data.data.warehouse_id ||
!form.data.data.purpose
}
onClick={searchHistories}
>
Procurar
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
A pesquisa usa o artigo, armazém e
finalidade escolhidos. A selecção
elegível e os dados introduzidos são
conservados.
</p>
</details>
<label className="grid gap-1 text-sm">
Período já aprovado para este artigo, local
e finalidade
<select
required
className={controlClass}
value={String(
form.data.data.history_key ?? '',
)}
onChange={(e) =>
form.setData('data', {
...form.data.data,
history_key: e.target.value,
})
}
>
<option value="">
Seleccionar análise existente
</option>
{historyOptions
.filter(
(h) =>
h.stock_item_id ===
Number(
form.data.data
.stock_item_id,
) &&
h.warehouse_id ===
Number(
form.data.data
.warehouse_id,
) &&
h.purpose ===
form.data.data.purpose,
)
.map((h) => (
<option
key={h.key}
value={h.key}
disabled={
h.quality !==
'complete' ||
h.pattern !== 'regular'
}
>
{h.period_from} a{' '}
{h.period_until} · {h.days}{' '}
dias · versão {h.version}
{h.quality !== 'complete' ||
h.pattern !== 'regular'
? ' · requer outro método/cobertura'
: ''}
</option>
))}
</select>
</label>
<details>
<summary className="cursor-pointer text-sm font-medium">
Cobertura mínima e validade do histórico
</summary>
<p className="my-2 text-sm text-muted-foreground">
Parâmetros propostos para esta política;
tornam-se aplicáveis após conferência
independente.
</p>
<div className="grid gap-4 md:grid-cols-2">
{field(
'history_min_days',
'Mínimo de dias do período',
'number',
)}
{field(
'history_max_age_days',
'Idade máxima do fim do período em dias',
'number',
)}
</div>
</details>
</div>
)}
<CorrectionSourceSearch
initial={props.agreementSearch}
search={searchAgreements}
disabled={
!form.data.data.stock_item_id ||
!form.data.data.warehouse_id
}
title="Procurar acordos aprovados deste artigo e local"
label="Referência do acordo ou nome do fornecedor"
/>
<label className="grid gap-1 text-sm">
Prazo e origem a partir de acordo existente
<select
className={controlClass}
value={String(
form.data.data.agreement_key ?? '',
)}
onChange={(e) => {
const agreement = agreementOptions.find(
(a) => a.record_key === e.target.value,
);
form.setData('data', {
...form.data.data,
agreement_key:
agreement?.record_key ?? '',
...(agreement
? {
lead_days:
agreement.data.lead_days,
origin: agreement.data.origin,
}
: {}),
});
}}
>
<option value="">
Definir prazo manual com fonte documentada
</option>
{agreementOptions
.filter(
(a) =>
a.stock_item_id ===
Number(
form.data.data
.stock_item_id,
) &&
a.warehouse_id ===
Number(
form.data.data.warehouse_id,
) &&
a.data.lead_days !== null,
)
.map((a) => (
<option
key={a.record_key}
value={a.record_key}
>
{a.snapshot.supplier?.name} ·{' '}
{String(a.data.lead_days)} dias
</option>
))}
</select>
</label>
{field('safety', 'Stock de segurança', 'number')}
{field('minimum_stock', 'Stock mínimo', 'number')}
{field('target', 'Stock alvo', 'number')}
{field('maximum', 'Stock máximo', 'number')}
{form.data.data.forecast_method !== 'historical' &&
field(
'daily',
'Procura diária manual validada',
'number',
true,
)}
{!form.data.data.agreement_key &&
field(
'lead_days',
'Prazo até material utilizável em dias corridos',
'number',
)}
{field(
'approval_days',
'Antecedência de aprovação interna em dias corridos',
'number',
)}
{field('basis', 'Fonte da procura e do prazo')}
</>
)}
{props.kind === 'import' && (
<>
{field('jurisdiction', 'Jurisdição aduaneira')}
{field('origin_country', 'País de origem')}
{field('dispatch_country', 'País de expedição')}
{field('regime', 'Regime aduaneiro')}
{field(
'incoterm',
'Incoterm, local e edição',
'text',
true,
)}
{field(
'expected_usable_on',
'Previsão de recepção utilizável',
'date',
true,
)}
</>
)}
</div>
{props.kind === 'policy' && (
<details>
<summary className="cursor-pointer text-sm font-medium">
Embalagem e horizonte do calendário
</summary>
<div className="mt-3 grid gap-4 md:grid-cols-3">
{field('minimum', 'Mínimo de compra', 'number')}
{field('pack', 'Múltiplo de embalagem', 'number')}
{field(
'horizon_days',
'Horizonte em dias corridos',
'number',
)}
</div>
</details>
)}
<label className="grid gap-1 text-sm">
Fundamento da preparaçã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 || !props.ready}>
Guardar preparação
</Button>
</form>
);
}
function RecordActions({
record,
props,
}: {
record: SupplyRecord;
props: Props;
}) {
const [action, setAction] = useState('');
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: record.record_key,
version: record.version,
action: '',
reason: '',
data: { evidence: '', event_date: '' },
});
const imports = props.kind === 'import';
const choices: [string, string][] = imports
? [
...(props.canPrepare && record.state === 'draft'
? [['transit', 'Registar embarque'] as [string, string]]
: []),
...(props.canPrepare && record.state === 'transit'
? [['arrive', 'Registar chegada'] as [string, string]]
: []),
...(props.canCustoms
? ([
['hold', 'Registar retenção'],
['release', 'Comprovar libertação'],
] as [string, string][])
: []),
]
: [
...(record.canApprove
? [['approve', 'Aprovar'] as [string, string]]
: []),
...(record.canReturn
? [['return', 'Devolver'] as [string, string]]
: []),
...(record.canSuspend
? [['suspend', 'Suspender'] as [string, string]]
: []),
];
return (
<div className="mt-4 space-y-3">
{props.kind === 'policy' &&
props.canPrepare &&
['approved', 'suspended'].includes(record.state) && (
<details>
<summary className="cursor-pointer text-sm font-medium">
Propor nova política reutilizando estes parâmetros
</summary>
<p className="my-3 text-sm text-muted-foreground">
A versão anterior permanece preservada. Altere
apenas os parâmetros necessários e seleccione o novo
histórico ou acordo. A substituição exige
conferência e apenas uma política pode ficar activa
neste artigo/local.
</p>
<Preparation props={props} record={record} copy />
</details>
)}
{props.canPrepare &&
['draft', 'returned'].includes(record.state) && (
<details>
<summary className="cursor-pointer font-medium">
Completar preparação
</summary>
<div className="mt-4">
<Preparation props={props} record={record} />
</div>
</details>
)}
{choices.length > 0 && (
<select
aria-label="Acção sobre o registo"
className={controlClass}
value={action}
onChange={(e) => {
setAction(e.target.value);
form.setData('action', e.target.value);
}}
>
<option value="">
Escolher acção da minha responsabilidade
</option>
{choices.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
)}
{action && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/compras/fornecimento/${props.kind}`, {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
{imports && (
<>
<label className="grid gap-1 text-sm">
Data comprovada do marco
<input
type="date"
required
className={controlClass}
value={form.data.data.event_date}
onChange={(e) =>
form.setData('data', {
...form.data.data,
event_date: e.target.value,
})
}
/>
</label>
<label className="grid gap-1 text-sm">
Referência da evidência
<input
required
className={controlClass}
value={form.data.data.evidence}
onChange={(e) =>
form.setData('data', {
...form.data.data,
evidence: e.target.value,
})
}
/>
</label>
</>
)}
<label className="grid gap-1 text-sm">
Fundamento
<textarea
className="rounded-lg border p-3"
required
minLength={10}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
<Button disabled={form.processing}>Confirmar acção</Button>
</form>
)}
</div>
);
}
function CalendarAlerts({ props }: { props: Props }) {
const policies = props.records.filter(
(record) => record.state === 'approved',
);
const alerts = policies.flatMap((record) => {
const p = props.projections[record.record_key];
if (!p) return [];
const label = `${record.snapshot.item?.code} · ${record.snapshot.item?.name} · ${record.snapshot.warehouse?.name}`;
const messages: string[] = [];
if (!p.available)
messages.push(p.reason ?? 'Previsão indisponível; rever a base.');
if (p.lead_time_issue) messages.push(p.lead_time_issue);
if (p.demand_complete === false)
messages.push(
'Procura habitual desconhecida: o cálculo cobre apenas necessidades conhecidas.',
);
if (p.request_date && p.request_date <= props.asOf)
messages.push(
`Prazo de pedir atingido: ${p.request_date}. Risco previsto em ${p.risk_date ?? 'data por determinar'}.`,
);
else if (p.risk_date)
messages.push(
`Repor antes do risco previsto de ${p.risk_date}; pedir até ${p.request_date ?? 'data a reconfirmar'}.`,
);
if (p.shortage_date)
messages.push(
`Saldo projectado insuficiente em ${p.shortage_date}.`,
);
if ((p.future_excess_milli ?? p.excess_milli ?? 0) > 0)
messages.push(
'Quantidade mínima ou embalagem ultrapassa o máximo: rever parâmetros ou fundamentar excepção.',
);
if (p.lot_warning) messages.push(p.lot_warning);
messages.push(...(p.events?.warnings ?? []));
return messages.length === 0
? []
: [
{
key: record.record_key,
label,
messages: [...new Set(messages)],
},
];
});
return (
<Panel
title="Atenções do calendário"
description={`Conferência em ${props.asOf}. Indicadores locais, sem envio de mensagens ou criação automática de compras.`}
>
{policies.length === 0 ? (
<p className="text-sm">
Ainda não há políticas aprovadas para calcular estes
alertas.
</p>
) : alerts.length === 0 ? (
<p className="text-sm">
Não há alertas nas políticas visíveis calculadas nesta data.
</p>
) : (
<div className="space-y-4">
{alerts.map((alert) => (
<div
key={alert.key}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">{alert.label}</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{alert.messages.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</div>
))}
</div>
)}
</Panel>
);
}
export default function SupplyIndex(props: Props) {
return (
<>
<Head title={titles[props.view]} />
<div className="space-y-6 p-5 lg:p-8">
<PageTitle
eyebrow="Compras · Fornecimento e reposição"
title={titles[props.view]}
description="Seleccione os cadastros existentes e registe apenas os dados novos desta etapa. A finalidade e a origem são independentes."
/>
{!props.ready && (
<p role="status">
A instalação deste incremento ainda está pendente.
</p>
)}
<Panel title="Responsabilidades">
<p className="text-sm text-muted-foreground">
Compras prepara os acordos e a reposição; o conferente
decide de forma independente. Logística acompanha
embarque e chegada; o responsável aduaneiro comprova a
libertação. Armazém confirma a recepção efectiva e
Finanças mantém a conferência de facturas e pagamentos
nos circuitos existentes.
</p>
</Panel>
{props.view === 'calendar' && props.ready && (
<CalendarAlerts props={props} />
)}
{props.focusRecordId != null && (
<Link
className="underline"
href={`/compras/fornecimento?view=${props.view}`}
>
Ver todos os registos desta área
</Link>
)}
{props.focusChanged && (
<p className="rounded border p-3 text-sm">
O registo mudou desde o encaminhamento. Confira a versão
actual; as versões anteriores permanecem no histórico.
</p>
)}
{props.canPrepare && props.focusRecordId == null && (
<Panel
title={
props.kind === 'policy'
? 'Nova política por artigo e local'
: props.kind === 'import'
? 'Acompanhar uma ordem importada'
: 'Novo acordo de fornecimento'
}
>
<details>
<summary className="cursor-pointer font-medium">
Abrir preparação
</summary>
<div className="mt-5">
<Preparation props={props} />
</div>
</details>
</Panel>
)}
{props.kind === 'policy' && (
<p className="text-sm text-muted-foreground">
Calendário em dias corridos, sujeito a revisão: usa o
saldo do razão de stock e a procura diária manual ou
histórica conferida, as reservas pendentes e as entregas
confirmadas. Âmbitos com lotes usam apenas quantidade
utilizável e retiram o remanescente que perde validade.
Uma sugestão não cria automaticamente uma requisição ou
ordem.
</p>
)}
{props.kind === 'import' && (
<p className="text-sm text-muted-foreground">
A chegada e o pagamento são factos distintos da
libertação e da recepção. Este acompanhamento não
calcula tributos nem comunica com a JUE.
</p>
)}
{props.records.length === 0 && (
<Panel title="Sem registos">
<p className="text-sm text-muted-foreground">
Ainda não existem registos nesta área para a sua
empresa.
</p>
</Panel>
)}
{props.records.map((record) => (
<div
key={`${record.record_key}-${record.version}`}
id={`supply-record-${record.id}`}
>
<Panel
key={`${record.record_key}-${record.version}`}
title={
record.snapshot.item
? `${record.snapshot.item.code} · ${record.snapshot.item.name}`
: (record.snapshot.order?.number ??
'Processo de fornecimento')
}
description={`${states[record.state]} · Versão ${record.version} · ${record.snapshot.warehouse?.name ?? record.data.jurisdiction ?? ''}`}
>
<div className="space-y-2 text-sm">
<p>
{record.snapshot.supplier?.name ??
(record.data.purpose === 'resale'
? 'Revenda'
: 'Uso interno')}
{record.data.origin &&
` · ${record.data.origin === 'import' ? 'Importação directa' : 'Compra local'}`}
</p>
<p>{record.reason}</p>
</div>
{props.projections[record.record_key] && (
<ProjectionView
projection={
props.projections[record.record_key]
}
/>
)}
<RecordActions record={record} props={props} />
<details className="mt-4">
<summary className="cursor-pointer text-sm">
Dados preservados desta versão
</summary>
<PreservedData record={record} />
</details>
<details className="mt-4">
<summary className="cursor-pointer text-sm">
Histórico de versões e decisões
</summary>
<ol className="mt-3 space-y-4">
{props.history[record.record_key]?.map(
(version) => (
<li
className="border-l-2 pl-4 text-sm"
key={version.id}
>
<p className="font-medium">
Versão {version.version} ·{' '}
{states[version.state]} ·{' '}
{new Date(
version.created_at,
).toLocaleString('pt-MZ')}
</p>
<p>{version.reason}</p>
{version.data.event && (
<p>
Marco:{' '}
{String(
(
version.data
.event as unknown as {
event_date: string;
}
).event_date,
)}{' '}
· Evidência:{' '}
{String(
(
version.data
.event as unknown as {
evidence: string;
}
).evidence,
)}
</p>
)}
<details>
<summary className="cursor-pointer">
Consultar dados desta
versão
</summary>
<PreservedData
record={version}
/>
</details>
</li>
),
)}
</ol>
</details>
</Panel>
</div>
))}
<SupplyPagination page={props.recordPagination} />
<p className="text-sm">
<Link
className="underline"
href="/administracao/parametrizacao"
>
Parametrização de perfis e responsabilidades
</Link>
</p>
</div>
</>
);
}
function ProjectionView({ projection: p }: { projection: Projection }) {
if (!p.available)
return (
<div className="mt-4 space-y-2 text-sm">
<ForecastOrigin source={p.forecast_history} />
{p.lead_time_issue && <p>{p.lead_time_issue}</p>}
<p>{p.reason}</p>
{p.events?.warnings.map((w) => (
<p key={w}>{w}</p>
))}
</div>
);
return (
<div className="mt-4 rounded-lg bg-muted p-4 text-sm">
{p.lot_warning && (
<p className="mb-3 font-medium">
{p.lot_warning} Quantidade utilizável:{' '}
{Number(p.usable_milli ?? 0) / 1000}.
</p>
)}
<ForecastOrigin source={p.forecast_history} />
{p.lead_time_issue && (
<p className="mb-3 font-medium">{p.lead_time_issue}</p>
)}
{p.demand_complete === false && (
<p className="mb-3 font-medium">
Apenas necessidades conhecidas: procura habitual
indisponível. A projecção não representa toda a procura
futura.
</p>
)}
{p.events?.warnings.map((w) => (
<p className="mb-3 font-medium" key={w}>
{w}
</p>
))}
{p.events && (
<p className="mb-3">
{p.events.reservations.length} reservas pendentes ·{' '}
{p.events.receipts.length} entradas utilizáveis confirmadas
</p>
)}
<div className="grid gap-3 md:grid-cols-4">
<p>
Iniciar requisição
<br />
<strong>
{p.request_date ?? 'Sem necessidade no horizonte'}
</strong>
</p>
<p>
Emitir ordem até
<br />
<strong>{p.order_date ?? '—'}</strong>
</p>
<p>
Receber até
<br />
<strong>{p.risk_date ?? '—'}</strong>
</p>
<p>
Quantidade indicativa
<br />
<strong>{quantity(p.quantity_milli)}</strong>
</p>
</div>
{Number(p.excess_milli) > 0 && (
<p className="mt-3 font-medium">
Excesso de {quantity(p.excess_milli)} sobre o máximo. Rever
embalagem, fraccionamento ou fornecedor.
</p>
)}
{Number(p.future_excess_milli) > Number(p.excess_milli ?? 0) && (
<p className="mt-3 font-medium">
Com a nova reposição e as restantes entregas, poderá haver
excesso de {quantity(p.future_excess_milli)} no horizonte.
Reveja o calendário antes de decidir.
</p>
)}
<details className="mt-4">
<summary className="cursor-pointer">
Ver projecção diária e convenção
</summary>
<p className="my-3">{p.convention}</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 projectado</th>
</tr>
</thead>
<tbody>
{p.series?.map((day) => (
<tr key={day.date}>
<td>{day.date}</td>
<td className="text-right">
{quantity(day.balance_milli)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
);
}
function ForecastOrigin({
source,
}: {
source: Projection['forecast_history'];
}) {
if (!source) return null;
if (!source.available)
return (
<p className="mb-3 font-medium">
Histórico indisponível para a média: {source.reason} A procura
habitual permanece desconhecida.
</p>
);
return (
<p className="mb-3">
{source.analysis.total_milli === 0
? 'Consumo zero confirmado no período'
: `Média histórica: ${(Number(source.analysis.daily_mean_milli) / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 9 })} ${source.unit} por dia`}{' '}
· {source.period_from} a {source.period_until} ·{' '}
{source.analysis.days} dias, incluindo dias sem saídas.
</p>
);
}