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

@@ -39,6 +39,7 @@
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
"tinymce": "^8.6.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.0"
},
"devDependencies": {

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

@@ -36,6 +36,7 @@
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.6.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3",
"zustand": "^5.0.0"
},

View File

@@ -59,6 +59,7 @@ import {
ContractDocsEditor,
documentSettingCode,
missingRequiredDocKeys,
useCompanyDocuments,
} from "./new-contract-form/ContractDocsEditor";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import type { ProfileTypeValue } from "@/services/companies.service";
@@ -116,6 +117,9 @@ export default function NewContractPage({
}),
enabled: isEdit,
});
// Profile documents (TIN, licenses, IDs) satisfy requirements too — the API
// carries them onto the contract on save.
const companyDocs = useCompanyDocuments();
// Contract creation is gated on profile approval, same as bookings.
if (!auth.isPending && auth.company && !auth.canBook) {
@@ -526,6 +530,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
@@ -643,6 +648,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
@@ -846,6 +852,7 @@ export default function NewContractPage({
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
).map((k) => [k, "Required"]),
)
: {}

View File

@@ -9,6 +9,7 @@ import {
Button,
Center,
Divider,
FileButton,
Group,
Loader,
Modal,
@@ -26,6 +27,8 @@ import {
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileUp,
MapPin,
Package,
Receipt,
@@ -51,6 +54,10 @@ import {
initialShipmentFormValues,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "./new-shipment-form/container-excel";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -931,6 +938,60 @@ function CargoStep({
const lines = form.watch("containers") ?? [];
// 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: sizes,
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.
const current = form.getValues("containers") ?? [];
const next = sizes.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
current.find((l) => l.containerSize === size) ?? {
containerSize: size,
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
}
);
}
return {
containerSize: size,
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
})),
};
});
form.setValue("containers", next, { shouldValidate: true, shouldDirty: true });
setImportErrors([]);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
if (isContainer) {
return (
<StepCard>
@@ -940,6 +1001,83 @@ function CargoStep({
description="Enter the quantity and per-container details for each size in your contract scope."
/>
<Stack gap={18}>
{sizes.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} c="#10202F">
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below for
the sizes in your 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 />
{lines.map((line, index) => (
<ContainerLineEditor

View File

@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CompanyDocument } from "@/services/companies.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "../contract-ui";
@@ -38,6 +39,38 @@ function dedupeLatestByCode(files: ContractFile[]): ContractFile[] {
return order.map((c) => latest.get(c)!);
}
/**
* The company's onboarding documents (TIN certificate, commercial license,
* national ID, …) from the profile. Contracts carry these automatically on
* save; the edit page also shows them directly so they are always visible even
* on contracts created before the carry-over existed.
*/
export function useCompanyDocuments(): CompanyDocument[] {
const auth = useAuth();
const companyId = auth.company?.company?.id as string | undefined;
const query = useQuery({
...api.companies.documents.queryOptions({
input: { companyId: companyId ?? "" },
}),
enabled: Boolean(companyId),
});
return query.data ?? [];
}
/** Latest company document per code, excluding codes already on the contract. */
function profileFallbackDocs(
companyDocs: CompanyDocument[],
contractFiles: ContractFile[],
): CompanyDocument[] {
const onContract = new Set(contractFiles.map((f) => f.code));
const latest = new Map<string, CompanyDocument>();
for (const d of companyDocs) {
const prev = latest.get(d.code);
if (!prev || d.uploadedAt > prev.uploadedAt) latest.set(d.code, d);
}
return [...latest.values()].filter((d) => !onContract.has(d.code));
}
/**
* Document replace/upload block for an existing contract. Lists the documents
* already on file (latest upload per code) and renders the onboarding-driven
@@ -72,8 +105,25 @@ export function ContractDocsEditor({
}),
);
const companyDocs = useCompanyDocuments();
const files = contract.files ?? [];
const onFile = useMemo(() => dedupeLatestByCode(files), [files]);
const onFile = useMemo(() => {
const contractRows = dedupeLatestByCode(files).map((f) => ({
id: f.id,
code: f.code,
name: f.name,
fromProfile: false,
}));
// Profile documents not yet carried onto the contract still show — they are
// attached automatically on the next save.
const profileRows = profileFallbackDocs(companyDocs, files).map((d) => ({
id: d.id,
code: d.code,
name: d.name,
fromProfile: true,
}));
return [...contractRows, ...profileRows];
}, [files, companyDocs]);
return (
<Stack gap="md">
@@ -115,12 +165,21 @@ export function ContractDocsEditor({
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
{file.fromProfile ? (
<Group gap={5} style={{ color: "#2E5B96" }}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} style={{ color: "#2E5B96" }}>
From profile
</Text>
</Group>
) : (
<Group gap={5} c={GREEN}>
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c={GREEN}>
On file
</Text>
</Group>
)}
<Button
component="a"
href={fileViewUrl(file.id, true)}
@@ -170,16 +229,21 @@ export function ContractDocsEditor({
/**
* Keys of the documents the onboarding setting marks required that are neither
* already on the contract nor freshly attached in `documents`. Empty means the
* customer may resubmit.
* already on the contract, nor on the company profile (the API carries those
* onto the contract on save), nor freshly attached in `documents`. Empty means
* the customer may resubmit.
*/
export function missingRequiredDocKeys(
setting: Freight.IFileUploadSetting | undefined,
contract: Freight.IContract,
documents: DocumentsValue,
companyDocs: CompanyDocument[] = [],
): string[] {
const fields = setting?.fields ?? [];
const existingCodes = new Set((contract.files ?? []).map((f) => f.code));
const existingCodes = new Set([
...(contract.files ?? []).map((f) => f.code),
...companyDocs.map((d) => d.code),
]);
return fields
.filter((f) => f.isRequired)
.filter(

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");
}