Merge pull request #587 from Tria-plc/freight_feature/usermanagement

add Excel import functionality for container bookings
This commit is contained in:
marshal
2026-07-09 21:12:19 +03:00
committed by GitHub
14 changed files with 1074 additions and 50 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");
}