Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-10 08:54:34 +00:00
139 changed files with 8644 additions and 1557 deletions

View File

@@ -12,6 +12,7 @@ import {
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
@@ -31,7 +32,9 @@ import {
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
MapPin,
Package,
Receipt,
@@ -54,6 +57,10 @@ import {
type GlShipmentQuantities,
} from "./gl-booking-form/total";
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./gl-booking-form/container-excel";
import {
fieldStyles,
StepCard,
@@ -392,6 +399,57 @@ export default function GlCreateBookingForm() {
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
// Excel import: one row per container. All-or-nothing — a file with any bad
// row is rejected with row-numbered errors so nothing is silently dropped.
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a contracted size
// the file omits keeps whatever was already entered for it.
setContainerLines((prev) =>
containerSizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
prev.find((l) => l.containerSize === size) ?? {
containerSize: size,
units: [emptyUnit()],
}
);
}
return {
containerSize: size,
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
hazardous: r.hazardous,
reefer: r.reefer,
})),
};
}),
);
setImportErrors([]);
setShowErrors(false);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
@@ -740,6 +798,83 @@ export default function GlCreateBookingForm() {
description="Enter the quantity and per-container details for each size in the contract scope."
/>
<Stack gap={18}>
{containerSizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600}>
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below
for the sizes in the file.
</Text>
</Box>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<FileDown size={14} />}
onClick={() => downloadContainerImportTemplate(excelOpts)}
>
Download template
</Button>
<FileButton
resetRef={importResetRef}
accept=".xlsx,.xls"
onChange={handleImportFile}
>
{(props) => (
<Button
{...props}
size="xs"
radius="md"
color="edr-green"
leftSection={<FileUp size={14} />}
>
Import Excel
</Button>
)}
</FileButton>
</Group>
</Group>
{importErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Import failed — fix the file and try again"
mt="sm"
>
<Stack gap={4}>
{importErrors.slice(0, 8).map((msg, i) => (
<Text key={i} fz="xs">
{msg}
</Text>
))}
{importErrors.length > 8 && (
<Text fz="xs" c="dimmed">
and {importErrors.length - 8} more.
</Text>
)}
</Stack>
</Alert>
)}
{importSummary && (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={16} />}
mt="sm"
>
<Text fz="xs">{importSummary}</Text>
</Alert>
)}
</Paper>
)}
<ContractCapacityNotice contractId={contract.id} isContainer />
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">

View File

@@ -0,0 +1,200 @@
import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
// hazardous/reefer flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers.
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
export interface ContainerExcelOptions {
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
}
export interface ImportedContainerRow {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: string;
hazardous: boolean;
reefer: boolean;
}
export interface ContainerExcelResult {
rows: ImportedContainerRow[];
errors: string[];
}
type ColumnKey =
| "containerSize"
| "containerNumber"
| "sealNumber"
| "vgmTons"
| "hazardous"
| "reefer";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null {
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
if (!h) return null;
if (h.includes("size")) return "containerSize";
if (h.includes("seal")) return "sealNumber";
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
}
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
function normalizeSize(raw: string, allowed: string[]): string | null {
const digits = raw.replace(/[^0-9]/g, "");
if (!digits) return null;
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
}
function parseFlag(raw: string): boolean {
const v = raw.trim().toLowerCase();
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
}
/**
* Parse an uploaded workbook into one row per container. Returns either the
* full row set or the list of row-numbered problems (never both).
*/
export async function parseContainerExcel(
file: File,
opts: ContainerExcelOptions,
): Promise<ContainerExcelResult> {
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
}
if (!sheet) {
return { rows: [], errors: ["The file has no sheets."] };
}
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
// First row with a recognizable column is the header; everything above
// (titles, blank rows) is ignored.
let headerRowIdx = -1;
let columns: Array<ColumnKey | null> = [];
for (let i = 0; i < grid.length; i++) {
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
headerRowIdx = i;
columns = mapped;
break;
}
}
if (headerRowIdx < 0) {
return {
rows: [],
errors: [
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
],
};
}
if (!columns.includes("vgmTons")) {
return {
rows: [],
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
};
}
const rows: ImportedContainerRow[] = [];
const errors: string[] = [];
const numberCounts = new Map<string, number>();
for (let i = headerRowIdx + 1; i < grid.length; i++) {
const cells = grid[i] ?? [];
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: ColumnKey) => {
const idx = columns.indexOf(key);
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
};
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
if (!size) {
errors.push(
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
);
}
const containerNumber = cell("containerNumber").toUpperCase();
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
errors.push(
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
);
} else {
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
}
const vgmRaw = cell("vgmTons");
const vgm = Number(vgmRaw);
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
}
rows.push({
containerSize: size ?? "",
containerNumber,
sealNumber: cell("sealNumber"),
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),
});
}
numberCounts.forEach((count, num) => {
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
});
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no container rows below the header.");
}
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
}
/** Generate and download the simple import template with one sample row per size. */
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => {
const row: Array<string | number> = [
size,
`MSCU${String(1234567 + i).padStart(7, "0")}`,
`SL${String(482910 + i)}`,
size.startsWith("40") ? 28 : 24.5,
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
return row;
});
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
XLSX.writeFile(workbook, "container-import-template.xlsx");
}

View File

@@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
}, [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
// capacity line shows the wagons currently committed rather than a hard cap.
const maxWagons: number | null = null;
// Wagon-slot cap from the board DTO (derived from train length and the
// shortest wagon type); null on legacy rows without a computable cap.
const maxWagons: number | null = data.capacity.maxWagons ?? null;
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
@@ -431,23 +431,31 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
<Text size="xs" fw={700}>
{data.capacity.allocatedWagons} allocated ·{" "}
{capUsed} in batch
{maxWagons != null ? ` · ${maxWagons} max` : ""}
</Text>
</Group>
{/* Scale against the real wagon cap when the DTO carries one; fall back
to the in-batch total on legacy rows without a computable cap. */}
<Progress.Root size="lg" radius="xl">
<Progress.Section
value={
capUsed > 0
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
(maxWagons ?? capUsed) > 0
? Math.min(
100,
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
)
: 0
}
color="edr-green"
/>
<Progress.Section
value={
capUsed > 0
(maxWagons ?? capUsed) > 0
? Math.min(
100,
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
((capUsed - data.capacity.allocatedWagons) /
(maxWagons ?? capUsed)) *
100,
)
: 0
}

View File

@@ -16,6 +16,7 @@ type DiagramWagonInput = {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string | null;
wagonType?: { code?: string | null } | null;
wagonTypeCode?: string | null;
@@ -33,6 +34,7 @@ type NormalizedWagon = {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons: number;
wagonTypeCode: string | null;
physicalWagonNumber: string | null;
isEmpty: boolean;
@@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm
sequenceNo: w.sequenceNo,
capacityTons: Number(w.capacityTons) || 0,
assignedWeightTons: Number(w.assignedWeightTons) || 0,
tareWeightTons: Number(w.tareWeightTons) || 0,
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
physicalWagonNumber: w.physicalWagonNumber ?? null,
isEmpty: allocations.length === 0,
@@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`;
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
const blocks = wagon.containerNumbers.slice(0, 2);
@@ -523,15 +528,22 @@ export function TrainCompositionDiagram({
const assigned = normalized.filter((w) => !w.isEmpty).length;
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
// Every coupled wagon's tare is hauled — empty ones included — so the
// locomotive pull limit is measured against gross (tare + cargo), the same
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
return {
total: normalized.length,
assigned,
empty: normalized.length - assigned,
totalWeight: Math.round(totalWeight * 100) / 100,
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity,
pullUtil:
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
: null,
};
}, [normalized, locomotive]);
@@ -598,12 +610,30 @@ export function TrainCompositionDiagram({
}}
>
<Text size="xs" fw={700} c="dark.4">
{stats.totalWeight}T
{stats.totalWeight}T cargo
</Text>
<Text size="xs" c="dimmed">
of {stats.totalCapacity}T capacity
</Text>
</Group>
{stats.totalTare > 0 ? (
<Group
gap={6}
style={{
padding: "4px 12px",
borderRadius: 999,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={700} c="dark.4">
{stats.grossWeight}T gross
</Text>
<Text size="xs" c="dimmed">
incl. {stats.totalTare}T tare
</Text>
</Group>
) : null}
<Group gap="xs">
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
@@ -626,7 +656,10 @@ export function TrainCompositionDiagram({
<Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
Locomotive load ·{" "}
{stats.totalTare > 0
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>

View File

@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
});
}
onClose();

View File

@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Release paper preview failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Handover document failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);

View File

@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -150,9 +150,6 @@ interface TruckEntranceFormState {
assignedEquipmentNumber: string;
customsSealNumber: string;
declarationNumber: string;
incoterms: string;
hsCodes: string;
itemCode: string;
itemDescription: string;
packagingType: string;
unitCount: number | '';
@@ -162,7 +159,6 @@ interface TruckEntranceFormState {
volumeDimensions: string;
conditionAtReceipt: string;
damagedRejectedQuantity: number | '';
warehouseCodeLocation: string;
driverName: string;
driverPhone: string;
driverLicenseNumber: string;
@@ -205,9 +201,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
assignedEquipmentNumber: '',
customsSealNumber: '',
declarationNumber: '',
incoterms: '',
hsCodes: '',
itemCode: '',
itemDescription: '',
packagingType: '',
unitCount: '',
@@ -217,7 +210,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
volumeDimensions: '',
conditionAtReceipt: '',
damagedRejectedQuantity: '',
warehouseCodeLocation: '',
driverName: '',
driverPhone: '',
driverLicenseNumber: '',
@@ -239,9 +231,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
@@ -251,7 +240,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
driverName: form.driverName.trim(),
driverPhone: form.driverPhone.trim(),
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
@@ -296,6 +284,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
const truckType = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
);
const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers));
// Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed.
const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN;
const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : '';
const edrDigitalBookingId =
bookings.length === 1
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
@@ -321,9 +313,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
customerPhone,
edrDigitalBookingId,
assignedEquipmentNumber,
customsSealNumber,
itemDescription,
packagingType,
unitCount,
netWeightKg,
grossWeightKg: '',
truckPlateNumber,
trailerPlateNumber,
@@ -331,6 +325,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
driverPhone,
driverLicenseNumber,
truckType,
driverSignatoryName: driverName,
},
lockedFields: {
ownerName: Boolean(ownerName),
@@ -550,38 +545,19 @@ function TruckEntranceFields({
)}
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
<TextInput
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<TextInput
label="Incoterms"
value={value.incoterms}
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
/>
</Group>
<TextInput
label="HS codes"
value={value.hsCodes}
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
<Group grow>
<TextInput
label="Item code"
value={value.itemCode}
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
/>
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
<Group grow>
<Select
label="Packaging type"
@@ -626,11 +602,6 @@ function TruckEntranceFields({
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput
label="Warehouse code and location"
value={value.warehouseCodeLocation}
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
/>
<Group grow>
<TextInput
label="Driver signatory"
@@ -900,7 +871,7 @@ function EligibleTab({
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
}
setSelected(new Set());
@@ -1697,7 +1668,6 @@ function LoadedExportTab({
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Status</Table.Th>
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1739,19 +1709,6 @@ function LoadedExportTab({
{r.status}
</Badge>
</Table.Td>
{dispatchable && (
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
color="green"
loading={bulkDispatch.isPending}
onClick={() => dispatch([r.id])}
>
Dispatch
</Button>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
@@ -2245,6 +2202,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -2282,7 +2242,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}
@@ -2296,7 +2256,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
};
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
!skipWeighing &&
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
});
return;
}
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
});
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: truckType.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
{hasContainerWeights && (
<Group gap="md" align="center">
<Text size="sm" fw={600}>Weigh truck?</Text>
<SegmentedControl
size="xs"
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
value={weighTruck}
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}

View File

@@ -5,7 +5,7 @@ import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};

View File

@@ -10,7 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
// Per-booking Load and Dispatch are retired: wagon loading happens in
// the train flow and dispatch at the train level (which already
// advances inventory). Only the remaining lifecycle actions render.
const rawNextAction = getNextInventoryAction(item);
const nextAction =
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>