1450 lines
62 KiB
TypeScript
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>
|
|
);
|
|
}
|