1023 lines
44 KiB
TypeScript
1023 lines
44 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 CoordinationRecordFilters, {
|
||
type CoordinationFilters,
|
||
} from '@/components/coordination-record-filters';
|
||
|
||
type Fields = Record<string, string | number | boolean | null>;
|
||
type Application = {
|
||
operation_id: number;
|
||
reference: string;
|
||
quantity_milli: number;
|
||
};
|
||
type RecordVersion = {
|
||
canApprove?: boolean;
|
||
canReturn?: boolean;
|
||
canSuspend?: boolean;
|
||
canEdit?: boolean;
|
||
canFulfil?: boolean;
|
||
canCancel?: boolean;
|
||
id: number;
|
||
record_key: string;
|
||
version: number;
|
||
state: string;
|
||
data: Fields & { applications?: Application[] };
|
||
reason: string;
|
||
created_at: string;
|
||
snapshot: {
|
||
effective_allocated_milli?: number;
|
||
reversed_operations?: number[];
|
||
item: { name: string; code: string; unit: string };
|
||
warehouse: { name: string };
|
||
substitute?: { name: string; code: string; unit: string };
|
||
order?: { number: string };
|
||
};
|
||
};
|
||
type Source = {
|
||
id: number;
|
||
label: string;
|
||
stock_item_id: number | null;
|
||
quantity_milli: number;
|
||
reference?: string;
|
||
needed_on?: string;
|
||
};
|
||
type Props = {
|
||
recordFilters?: CoordinationFilters;
|
||
lineSearch?: string;
|
||
lineFormKey?: string | null;
|
||
issueSearch?: string;
|
||
issueRecordId?: number | null;
|
||
focusRecordId?: number | null;
|
||
focusChanged?: boolean;
|
||
kind: 'reservation' | 'delivery' | 'alternative';
|
||
ready: boolean;
|
||
canPrepare: boolean;
|
||
canReview: boolean;
|
||
eligible: Record<string, boolean>;
|
||
pending: Record<string, number>;
|
||
reversedOperations?: number[];
|
||
records: RecordVersion[];
|
||
recordPagination?: {
|
||
current_page: number;
|
||
last_page: number;
|
||
total: number;
|
||
prev_page_url: string | null;
|
||
next_page_url: string | null;
|
||
} | null;
|
||
history: Record<string, RecordVersion[]>;
|
||
items: { id: number; name: string; code: string; unit: string }[];
|
||
warehouses: { id: number; name: string; code: string }[];
|
||
requestLines: Source[];
|
||
orderLines: Source[];
|
||
operations: {
|
||
id: number;
|
||
reference: string;
|
||
stock_item_id: number;
|
||
warehouse_id: number;
|
||
quantity_milli: number;
|
||
}[];
|
||
};
|
||
const titles = {
|
||
reservation: 'Reservas de necessidades',
|
||
delivery: 'Entregas confirmadas',
|
||
alternative: 'Alternativas técnicas',
|
||
};
|
||
const states: Record<string, string> = {
|
||
draft: 'Em análise técnica',
|
||
returned: 'Devolvida para correcção',
|
||
approved: 'Validada tecnicamente',
|
||
suspended: 'Suspensa',
|
||
active: 'Activa',
|
||
cancelled: 'Cancelada',
|
||
fulfilled: 'Satisfeita por saídas',
|
||
};
|
||
const compatibility: Record<string, string> = {
|
||
equivalent: 'Equivalente técnica',
|
||
conditional: 'Substituição condicionada',
|
||
incompatible: 'Não compatível',
|
||
pending: 'Pendente de análise',
|
||
};
|
||
const quantity = (milli: number) =>
|
||
(milli / 1000).toLocaleString('pt-MZ', { maximumFractionDigits: 3 });
|
||
|
||
function Prepare({ props, old }: { props: Props; old?: RecordVersion }) {
|
||
const [orderLines, setOrderLines] = useState(props.orderLines);
|
||
const [requestLines, setRequestLines] = useState(props.requestLines);
|
||
const lineFormKey = `${props.kind}:${old?.id ?? 'new'}`;
|
||
useEffect(() => {
|
||
if (!props.lineFormKey || props.lineFormKey === lineFormKey) {
|
||
setOrderLines(props.orderLines);
|
||
setRequestLines(props.requestLines);
|
||
}
|
||
}, [props.lineFormKey, props.orderLines, props.requestLines, lineFormKey]);
|
||
const defaults: Fields = {
|
||
stock_item_id: '',
|
||
warehouse_id: '',
|
||
...(props.kind === 'alternative'
|
||
? {
|
||
substitute_id: '',
|
||
application: '',
|
||
compatibility: 'pending',
|
||
conditions: '',
|
||
evidence: '',
|
||
factor_numerator: 1,
|
||
factor_denominator: 1,
|
||
valid_until: '',
|
||
}
|
||
: props.kind === 'reservation'
|
||
? {
|
||
requisition_item_id: '',
|
||
quantity: '',
|
||
needed_on: '',
|
||
additional: false,
|
||
reference: '',
|
||
}
|
||
: {
|
||
purchase_order_item_id: '',
|
||
quantity: '',
|
||
expected_on: '',
|
||
confirmed_on: '',
|
||
evidence: '',
|
||
}),
|
||
};
|
||
if (old) {
|
||
for (const key of Object.keys(defaults))
|
||
if (key in old.data) defaults[key] = old.data[key];
|
||
if (old.data.quantity_milli !== undefined)
|
||
defaults.quantity = Number(old.data.quantity_milli) / 1000;
|
||
}
|
||
const form = useForm({
|
||
operation_key: crypto.randomUUID(),
|
||
record_key: old?.record_key,
|
||
version: old?.version ?? 0,
|
||
action: 'save',
|
||
reason: '',
|
||
data: defaults,
|
||
});
|
||
const update = (key: string, value: Fields[string]) =>
|
||
form.setData('data', { ...form.data.data, [key]: value });
|
||
const field = (
|
||
key: string,
|
||
label: string,
|
||
type = 'text',
|
||
optional = false,
|
||
) => (
|
||
<label className="grid gap-1 text-sm">
|
||
{label}
|
||
{optional && ' (opcional)'}
|
||
<input
|
||
className={controlClass}
|
||
type={type}
|
||
required={!optional}
|
||
min={type === 'number' ? 0 : undefined}
|
||
step={type === 'number' ? '0.001' : undefined}
|
||
maxLength={type === 'text' ? 4000 : undefined}
|
||
value={String(form.data.data[key] ?? '')}
|
||
onChange={(e) => update(key, e.target.value)}
|
||
/>
|
||
</label>
|
||
);
|
||
const selector = (
|
||
key: string,
|
||
label: string,
|
||
entries: { id: number; label: string }[],
|
||
) => (
|
||
<label className="grid gap-1 text-sm">
|
||
{label}
|
||
<select
|
||
required
|
||
className={controlClass}
|
||
value={String(form.data.data[key])}
|
||
onChange={(e) => update(key, e.target.value)}
|
||
>
|
||
<option value="">Seleccionar cadastro existente</option>
|
||
{entries.map((entry) => (
|
||
<option value={entry.id} key={entry.id}>
|
||
{entry.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
const sources = props.kind === 'delivery' ? orderLines : requestLines;
|
||
const searchLines = (value: string) =>
|
||
router.get(
|
||
'/compras/fornecimento',
|
||
{
|
||
view: props.kind === 'delivery' ? 'deliveries' : 'reservations',
|
||
records_page: props.recordPagination?.current_page,
|
||
...props.recordFilters,
|
||
...(props.focusRecordId
|
||
? { record_id: props.focusRecordId }
|
||
: {}),
|
||
line_search: value,
|
||
line_form_key: lineFormKey,
|
||
selected_order_line_id:
|
||
form.data.data.purchase_order_item_id || undefined,
|
||
selected_request_line_id:
|
||
form.data.data.requisition_item_id || undefined,
|
||
},
|
||
{
|
||
only: [
|
||
'orderLines',
|
||
'requestLines',
|
||
'lineSearch',
|
||
'lineFormKey',
|
||
],
|
||
preserveState: true,
|
||
preserveScroll: true,
|
||
},
|
||
);
|
||
return (
|
||
<form
|
||
className="mt-4 space-y-4"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
form.transform((input) => {
|
||
const data = { ...input.data };
|
||
if (props.kind !== 'alternative') {
|
||
data.quantity_milli = Math.round(
|
||
Number(data.quantity) * 1000,
|
||
);
|
||
delete data.quantity;
|
||
}
|
||
return { ...input, data };
|
||
});
|
||
form.post(`/compras/fornecimento/${props.kind}`, {
|
||
preserveScroll: true,
|
||
onSuccess: () =>
|
||
form.setData('operation_key', crypto.randomUUID()),
|
||
});
|
||
}}
|
||
>
|
||
<FormErrors errors={form.errors} />
|
||
{props.kind !== 'alternative' && (
|
||
<CorrectionSourceSearch
|
||
initial={props.lineSearch}
|
||
search={searchLines}
|
||
title={
|
||
props.kind === 'delivery'
|
||
? 'Procurar encomenda emitida'
|
||
: 'Procurar requisição aprovada'
|
||
}
|
||
label="Número ou parte do número"
|
||
/>
|
||
)}
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{props.kind !== 'alternative' && (
|
||
<label className="grid gap-1 text-sm">
|
||
{props.kind === 'delivery'
|
||
? 'Linha da ordem emitida'
|
||
: 'Reutilizar necessidade aprovada (opcional)'}
|
||
<select
|
||
required={props.kind === 'delivery'}
|
||
className={controlClass}
|
||
value={String(
|
||
form.data.data[
|
||
props.kind === 'delivery'
|
||
? 'purchase_order_item_id'
|
||
: 'requisition_item_id'
|
||
],
|
||
)}
|
||
onChange={(e) => {
|
||
const source = sources.find(
|
||
(s) => s.id === Number(e.target.value),
|
||
);
|
||
form.setData('data', {
|
||
...form.data.data,
|
||
[props.kind === 'delivery'
|
||
? 'purchase_order_item_id'
|
||
: 'requisition_item_id']:
|
||
e.target.value,
|
||
...(source
|
||
? {
|
||
...(source.stock_item_id
|
||
? {
|
||
stock_item_id:
|
||
source.stock_item_id,
|
||
}
|
||
: {}),
|
||
quantity:
|
||
source.quantity_milli / 1000,
|
||
...(source.reference
|
||
? {
|
||
reference:
|
||
source.reference,
|
||
needed_on:
|
||
source.needed_on ??
|
||
'',
|
||
}
|
||
: {}),
|
||
}
|
||
: {}),
|
||
});
|
||
}}
|
||
>
|
||
<option value="">
|
||
{props.kind === 'delivery'
|
||
? 'Seleccionar ordem e linha'
|
||
: 'Necessidade nova sem requisição anterior'}
|
||
</option>
|
||
{sources.map((source) => (
|
||
<option key={source.id} value={source.id}>
|
||
{source.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{selector(
|
||
'stock_item_id',
|
||
'Artigo e unidade',
|
||
props.items.map((i) => ({
|
||
id: i.id,
|
||
label: `${i.code} · ${i.name} (${i.unit})`,
|
||
})),
|
||
)}
|
||
{selector(
|
||
'warehouse_id',
|
||
'Armazém de destino ou utilização',
|
||
props.warehouses.map((w) => ({
|
||
id: w.id,
|
||
label: `${w.code} · ${w.name}`,
|
||
})),
|
||
)}
|
||
{props.kind === 'alternative' ? (
|
||
<>
|
||
{selector(
|
||
'substitute_id',
|
||
'Artigo substituto — neste sentido',
|
||
props.items
|
||
.filter(
|
||
(i) =>
|
||
i.id !==
|
||
Number(form.data.data.stock_item_id),
|
||
)
|
||
.map((i) => ({
|
||
id: i.id,
|
||
label: `${i.code} · ${i.name} (${i.unit})`,
|
||
})),
|
||
)}
|
||
{field(
|
||
'application',
|
||
'Aplicação ou equipamento autorizado',
|
||
)}
|
||
<label className="grid gap-1 text-sm">
|
||
Resultado da análise
|
||
<select
|
||
className={controlClass}
|
||
value={String(form.data.data.compatibility)}
|
||
onChange={(e) =>
|
||
update('compatibility', e.target.value)
|
||
}
|
||
>
|
||
{Object.entries(compatibility).map(
|
||
([key, label]) => (
|
||
<option key={key} value={key}>
|
||
{key === 'equivalent'
|
||
? 'Equivalente — a validar'
|
||
: label}
|
||
</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</label>
|
||
{form.data.data.compatibility === 'conditional' &&
|
||
field(
|
||
'conditions',
|
||
'Condições obrigatórias de utilização',
|
||
)}
|
||
{field(
|
||
'evidence',
|
||
'Referência das especificações comparadas e evidência técnica',
|
||
)}
|
||
{field('valid_until', 'Validade da análise', 'date')}
|
||
</>
|
||
) : (
|
||
<>
|
||
{field(
|
||
'quantity',
|
||
props.kind === 'delivery'
|
||
? 'Quantidade acumulada confirmada na linha (inclui recepções anteriores)'
|
||
: 'Quantidade na unidade do artigo',
|
||
'number',
|
||
)}
|
||
{props.kind === 'reservation' ? (
|
||
<>
|
||
{field(
|
||
'needed_on',
|
||
'Data da necessidade',
|
||
'date',
|
||
)}
|
||
{!form.data.data.requisition_item_id &&
|
||
field(
|
||
'reference',
|
||
'Referência única da necessidade',
|
||
)}
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(
|
||
form.data.data.additional,
|
||
)}
|
||
onChange={(e) =>
|
||
update(
|
||
'additional',
|
||
e.target.checked,
|
||
)
|
||
}
|
||
/>
|
||
Procura adicional à previsão habitual
|
||
</label>
|
||
</>
|
||
) : (
|
||
<>
|
||
{field(
|
||
'expected_on',
|
||
'Disponível para utilização em',
|
||
'date',
|
||
)}
|
||
{field(
|
||
'confirmed_on',
|
||
'Data da confirmação do fornecedor',
|
||
'date',
|
||
)}
|
||
{field(
|
||
'evidence',
|
||
'Referência da confirmação recebida',
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{props.kind === 'alternative' && (
|
||
<details>
|
||
<summary className="cursor-pointer text-sm font-medium">
|
||
Conversão técnica de quantidades
|
||
</summary>
|
||
<p className="my-3 text-sm text-muted-foreground">
|
||
Quantidade do substituto = quantidade original ×
|
||
numerador ÷ denominador. O factor pertence apenas a esta
|
||
relação e necessita da validação técnica.
|
||
</p>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{field('factor_numerator', 'Numerador', 'number')}
|
||
{field('factor_denominator', 'Denominador', 'number')}
|
||
</div>
|
||
</details>
|
||
)}
|
||
<label className="grid gap-1 text-sm">
|
||
Fundamento da preparação ou alteração
|
||
<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 || !props.ready}>
|
||
Guardar{' '}
|
||
{props.kind === 'alternative'
|
||
? 'análise para validação'
|
||
: 'registo'}
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function Actions({ props, record }: { props: Props; record: RecordVersion }) {
|
||
const [action, setAction] = useState('');
|
||
const [issueSearch, setIssueSearch] = useState(props.issueSearch ?? '');
|
||
const [operationOptions, setOperationOptions] = useState(props.operations);
|
||
useEffect(() => {
|
||
if (props.issueRecordId === record.id)
|
||
setOperationOptions(props.operations);
|
||
}, [props.issueRecordId, props.operations, record.id]);
|
||
const form = useForm({
|
||
operation_key: crypto.randomUUID(),
|
||
record_key: record.record_key,
|
||
version: record.version,
|
||
action: '',
|
||
reason: '',
|
||
data: { stock_operation_id: '', quantity_milli: 0 },
|
||
quantity: '',
|
||
});
|
||
const choices =
|
||
props.kind === 'alternative'
|
||
? [
|
||
...(record.canApprove
|
||
? [['approve', 'Validar análise']]
|
||
: []),
|
||
...(record.canReturn
|
||
? [['return', 'Devolver para correcção']]
|
||
: []),
|
||
...(record.canSuspend
|
||
? [['suspend', 'Suspender aprovação']]
|
||
: []),
|
||
]
|
||
: [
|
||
...(record.canFulfil
|
||
? [['fulfil', 'Associar saída efectiva']]
|
||
: []),
|
||
...(record.canCancel
|
||
? [['cancel', 'Cancelar pendência']]
|
||
: []),
|
||
];
|
||
const searchIssues = () =>
|
||
router.get(
|
||
'/compras/fornecimento',
|
||
{
|
||
view: 'reservations',
|
||
records_page: props.recordPagination?.current_page,
|
||
...props.recordFilters,
|
||
...(props.focusRecordId
|
||
? { record_id: props.focusRecordId }
|
||
: {}),
|
||
issue_record_id: record.id,
|
||
issue_search: issueSearch,
|
||
selected_issue_id:
|
||
form.data.data.stock_operation_id || undefined,
|
||
},
|
||
{
|
||
only: ['operations', 'issueSearch', 'issueRecordId'],
|
||
preserveState: true,
|
||
preserveScroll: true,
|
||
},
|
||
);
|
||
return (
|
||
<div className="mt-4 space-y-3">
|
||
{props.canPrepare &&
|
||
(props.kind === 'alternative'
|
||
? ['draft', 'returned'].includes(record.state)
|
||
: record.canEdit) && (
|
||
<details>
|
||
<summary className="cursor-pointer text-sm font-medium">
|
||
Rever os dados desta preparação
|
||
</summary>
|
||
<Prepare props={props} old={record} />
|
||
</details>
|
||
)}
|
||
{choices.length > 0 && (
|
||
<label className="grid gap-1 text-sm">
|
||
Acção da minha responsabilidade
|
||
<select
|
||
className={controlClass}
|
||
value={action}
|
||
onChange={(e) => {
|
||
setAction(e.target.value);
|
||
form.setData('action', e.target.value);
|
||
}}
|
||
>
|
||
<option value="">Seleccionar acção</option>
|
||
{choices.map(([key, label]) => (
|
||
<option key={key} value={key}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{action && (
|
||
<form
|
||
className="space-y-3"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
form.transform((input) => ({
|
||
...input,
|
||
data: {
|
||
...input.data,
|
||
quantity_milli: Math.round(
|
||
Number(input.quantity) * 1000,
|
||
),
|
||
},
|
||
}));
|
||
form.post(`/compras/fornecimento/${props.kind}`, {
|
||
preserveScroll: true,
|
||
});
|
||
}}
|
||
>
|
||
<FormErrors errors={form.errors} />
|
||
{action === 'fulfil' && (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<details className="rounded border p-3 md:col-span-2">
|
||
<summary className="cursor-pointer text-sm">
|
||
Procurar saída pela referência
|
||
</summary>
|
||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||
<label className="grid gap-1 text-sm">
|
||
Referência ou parte da referência
|
||
<input
|
||
className={controlClass}
|
||
maxLength={100}
|
||
value={issueSearch}
|
||
onChange={(event) =>
|
||
setIssueSearch(
|
||
event.target.value,
|
||
)
|
||
}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
searchIssues();
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={searchIssues}
|
||
>
|
||
Procurar
|
||
</Button>
|
||
</div>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
A pesquisa usa o artigo e o armazém desta
|
||
necessidade e conserva os dados já
|
||
introduzidos.
|
||
</p>
|
||
</details>
|
||
<label className="grid gap-1 text-sm">
|
||
Saída já registada
|
||
<select
|
||
required
|
||
className={controlClass}
|
||
value={form.data.data.stock_operation_id}
|
||
onChange={(e) =>
|
||
form.setData('data', {
|
||
...form.data.data,
|
||
stock_operation_id: e.target.value,
|
||
})
|
||
}
|
||
>
|
||
<option value="">
|
||
Seleccionar saída existente
|
||
</option>
|
||
{operationOptions
|
||
.filter(
|
||
(o) =>
|
||
o.stock_item_id ===
|
||
Number(
|
||
record.data
|
||
.stock_item_id,
|
||
) &&
|
||
o.warehouse_id ===
|
||
Number(
|
||
record.data
|
||
.warehouse_id,
|
||
),
|
||
)
|
||
.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.reference} ·{' '}
|
||
{quantity(o.quantity_milli)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="grid gap-1 text-sm">
|
||
Quantidade satisfeita nesta saída
|
||
<input
|
||
className={controlClass}
|
||
type="number"
|
||
step="0.001"
|
||
min="0.001"
|
||
required
|
||
value={form.data.quantity}
|
||
onChange={(e) =>
|
||
form.setData('quantity', e.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<label className="grid gap-1 text-sm">
|
||
Fundamento da decisão
|
||
<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</Button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Facts({
|
||
record: r,
|
||
pending,
|
||
reversed,
|
||
}: {
|
||
record: RecordVersion;
|
||
pending?: number;
|
||
reversed?: number[];
|
||
}) {
|
||
const corrected = reversed ?? r.snapshot.reversed_operations ?? [];
|
||
const applied =
|
||
r.snapshot.effective_allocated_milli ??
|
||
r.data.applications?.reduce((sum, a) => sum + a.quantity_milli, 0) ??
|
||
0;
|
||
return (
|
||
<div className="space-y-2 text-sm">
|
||
{r.snapshot.substitute && (
|
||
<>
|
||
<p>
|
||
Substituto:{' '}
|
||
<strong>
|
||
{r.snapshot.substitute.code} ·{' '}
|
||
{r.snapshot.substitute.name}
|
||
</strong>
|
||
</p>
|
||
<p>
|
||
{compatibility[String(r.data.compatibility)]} ·
|
||
Aplicação: {String(r.data.application)}
|
||
</p>
|
||
<p>
|
||
Válida até {String(r.data.valid_until)} · Conversão:{' '}
|
||
{String(r.data.factor_numerator)} /{' '}
|
||
{String(r.data.factor_denominator)}
|
||
</p>
|
||
{r.data.conditions && (
|
||
<p>Condições: {String(r.data.conditions)}</p>
|
||
)}
|
||
<p>Evidência: {String(r.data.evidence)}</p>
|
||
</>
|
||
)}
|
||
{r.data.quantity_milli !== undefined && (
|
||
<p>
|
||
{r.snapshot.order
|
||
? 'Quantidade acumulada confirmada: '
|
||
: 'Quantidade: '}
|
||
<strong>
|
||
{quantity(Number(r.data.quantity_milli))}{' '}
|
||
{r.snapshot.item.unit}
|
||
</strong>
|
||
{r.data.applications &&
|
||
` · Pendente: ${quantity(pending ?? Number(r.data.quantity_milli) - applied)}`}
|
||
</p>
|
||
)}
|
||
{r.data.needed_on && (
|
||
<p>
|
||
Necessária em {String(r.data.needed_on)} ·{' '}
|
||
{r.data.additional
|
||
? 'Procura adicional'
|
||
: 'Incluída na previsão habitual'}{' '}
|
||
· {String(r.data.reference)}
|
||
</p>
|
||
)}
|
||
{r.data.expected_on && (
|
||
<p>
|
||
{r.snapshot.order?.number} · Disponibilidade prevista:{' '}
|
||
{String(r.data.expected_on)} · Confirmação de{' '}
|
||
{String(r.data.confirmed_on)}
|
||
</p>
|
||
)}
|
||
{r.data.applications?.map((a, i) => (
|
||
<p key={i}>
|
||
Saída {a.reference}: {quantity(a.quantity_milli)}{' '}
|
||
{r.snapshot.item.unit}
|
||
{corrected.includes(a.operation_id) &&
|
||
' · Estornada; não satisfaz a pendência actual'}
|
||
</p>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Coordination(props: Props) {
|
||
const view =
|
||
props.kind === 'reservation'
|
||
? 'reservations'
|
||
: props.kind === 'delivery'
|
||
? 'deliveries'
|
||
: 'alternatives';
|
||
const allowedStates =
|
||
props.kind === 'alternative'
|
||
? ['draft', 'returned', 'approved', 'suspended']
|
||
: props.kind === 'reservation'
|
||
? ['active', 'cancelled', 'fulfilled']
|
||
: ['active', 'cancelled'];
|
||
return (
|
||
<>
|
||
<Head title={titles[props.kind]} />
|
||
<div className="space-y-6 p-5 lg:p-8">
|
||
<PageTitle
|
||
eyebrow="Compras · Fornecimento e reposição"
|
||
title={titles[props.kind]}
|
||
description={
|
||
props.kind === 'alternative'
|
||
? 'Relações direccionais por artigo, aplicação e local. A validação num sentido não autoriza o sentido inverso.'
|
||
: props.kind === 'reservation'
|
||
? 'Necessidades conhecidas por artigo e local, satisfeitas por saídas já registadas no armazém.'
|
||
: 'Confirmações do fornecedor sobre ordens existentes. Apenas as quantidades pendentes entram no calendário.'
|
||
}
|
||
/>
|
||
{!props.ready && (
|
||
<p role="status">Instalação do fornecimento pendente.</p>
|
||
)}
|
||
<CoordinationRecordFilters
|
||
key={view}
|
||
view={view}
|
||
filters={
|
||
props.recordFilters ?? {
|
||
record_search: '',
|
||
record_state: '',
|
||
}
|
||
}
|
||
states={Object.fromEntries(
|
||
allowedStates.map((state) => [state, states[state]]),
|
||
)}
|
||
/>
|
||
<Panel title="Responsabilidade desta etapa">
|
||
<a
|
||
className="mb-3 inline-block text-sm underline"
|
||
href={`/compras/fornecimento.csv?${new URLSearchParams({ view, ...props.recordFilters, ...(props.focusRecordId ? { record_id: String(props.focusRecordId) } : {}) }).toString()}`}
|
||
>
|
||
{props.focusRecordId
|
||
? 'Exportar este processo (CSV)'
|
||
: 'Exportar resultados filtrados (CSV)'}
|
||
</a>
|
||
<p className="text-sm text-muted-foreground">
|
||
{props.kind === 'alternative'
|
||
? 'A equipa técnica prepara a análise; um validador independente decide. Relações pendentes, incompatíveis, expiradas ou suspensas ficam excluídas das alternativas elegíveis. Uma substituição condicionada exige confirmação das condições na utilização.'
|
||
: props.kind === 'reservation'
|
||
? 'O responsável acompanha a necessidade planeada e associa saídas efectivas já registadas. Esta reserva de planeamento não bloqueia stock físico nem repete saídas. A reserva física das requisições internas tem acompanhamento próprio no armazém.'
|
||
: 'Compras regista a confirmação e as alterações de prazo. Armazém regista a recepção no circuito existente. Mercadoria importada retida fica fora das entradas utilizáveis do calendário.'}
|
||
</p>
|
||
</Panel>
|
||
{props.focusRecordId && (
|
||
<div className="space-y-2 text-sm">
|
||
<Link
|
||
href={`/compras/fornecimento?view=${props.kind === 'alternative' ? 'alternatives' : props.kind === 'reservation' ? 'reservations' : 'deliveries'}`}
|
||
className="underline"
|
||
>
|
||
Ver todos os registos
|
||
</Link>
|
||
{props.focusChanged && (
|
||
<p role="status">
|
||
Este registo foi actualizado. Está a consultar a
|
||
versão actual; as versões anteriores permanecem
|
||
no histórico.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{props.canPrepare && !props.focusRecordId && (
|
||
<Panel
|
||
title={
|
||
props.kind === 'alternative'
|
||
? 'Preparar análise técnica'
|
||
: props.kind === 'reservation'
|
||
? 'Reservar uma necessidade'
|
||
: 'Confirmar uma entrega'
|
||
}
|
||
>
|
||
<details>
|
||
<summary className="cursor-pointer font-medium">
|
||
Abrir preparação
|
||
</summary>
|
||
<Prepare props={props} />
|
||
</details>
|
||
</Panel>
|
||
)}
|
||
{props.records.length === 0 && (
|
||
<Panel title="Sem registos">
|
||
<p className="text-sm text-muted-foreground">
|
||
Ainda não existem registos para a sua empresa nesta
|
||
área.
|
||
</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-record-${r.id}`} />
|
||
<Facts
|
||
record={r}
|
||
pending={
|
||
props.kind === 'reservation'
|
||
? props.pending[r.record_key]
|
||
: undefined
|
||
}
|
||
reversed={props.reversedOperations}
|
||
/>
|
||
{props.kind === 'reservation' &&
|
||
r.state === 'fulfilled' &&
|
||
(props.pending[r.record_key] ?? 0) > 0 && (
|
||
<p className="mt-2 text-sm font-medium">
|
||
Pendência reaberta por estorno de saída. A
|
||
associação anterior permanece no histórico.
|
||
</p>
|
||
)}
|
||
{props.kind === 'delivery' && r.state === 'active' && (
|
||
<p className="mt-2 text-sm font-medium">
|
||
Ainda por receber:{' '}
|
||
{quantity(props.pending[r.record_key] ?? 0)}{' '}
|
||
{r.snapshot.item.unit}
|
||
</p>
|
||
)}
|
||
{props.kind === 'alternative' && (
|
||
<p className="mt-3 text-sm font-medium">
|
||
{props.eligible[r.record_key]
|
||
? 'Equivalência vigente para esta aplicação e sentido'
|
||
: 'Excluída da utilização automática; consulte estado, condições, validade e cadastro'}
|
||
</p>
|
||
)}
|
||
<p className="mt-3 text-sm text-muted-foreground">
|
||
{r.reason}
|
||
</p>
|
||
<Actions props={props} record={r} />
|
||
<details className="mt-4">
|
||
<summary className="cursor-pointer text-sm">
|
||
Histórico preservado
|
||
</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]} ·{' '}
|
||
{new Date(
|
||
h.created_at,
|
||
).toLocaleString('pt-MZ')}
|
||
</p>
|
||
<p className="mb-2">{h.reason}</p>
|
||
<Facts record={h} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</details>
|
||
</Panel>
|
||
))}
|
||
{props.recordPagination && (
|
||
<nav
|
||
aria-label="Paginação do acompanhamento de fornecimentos"
|
||
className="flex flex-wrap items-center justify-between gap-3 text-sm"
|
||
>
|
||
<span>
|
||
{props.recordPagination.total} registos · Página{' '}
|
||
{props.recordPagination.current_page} de{' '}
|
||
{props.recordPagination.last_page}
|
||
</span>
|
||
<div className="flex gap-4">
|
||
{props.recordPagination.prev_page_url && (
|
||
<Link
|
||
className="underline"
|
||
href={props.recordPagination.prev_page_url}
|
||
>
|
||
Anterior
|
||
</Link>
|
||
)}
|
||
{props.recordPagination.next_page_url && (
|
||
<Link
|
||
className="underline"
|
||
href={props.recordPagination.next_page_url}
|
||
>
|
||
Seguinte
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</nav>
|
||
)}
|
||
<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="/stock/operacoes">
|
||
Saídas e inventários
|
||
</Link>
|
||
<Link className="underline" href="/recepcoes">
|
||
Recepções
|
||
</Link>
|
||
</p>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|