import * as XLSX from "xlsx"; // Excel import for loaded containers already sitting in a yard but never // entered in the system. One row per container. All-or-nothing — any bad row // rejects the file with row-numbered errors, so a half-registered yard cannot // happen. const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; export interface ParsedFullContainerRow { containerNumber: string; containerSize: string; companyName: string; /** ISO instant; null when the cell was empty or unreadable. */ arrivedAt: string | null; facility: string; yard: string; zone: string; sealNumber: string; weight: string; notes: string; } export interface FullContainerExcelResult { rows: ParsedFullContainerRow[]; errors: string[]; } type ColumnKey = | "containerNumber" | "containerSize" | "companyName" | "arrivedAt" | "facility" | "yard" | "zone" | "sealNumber" | "weight" | "notes"; /** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */ function headerKey(raw: string): ColumnKey | null { const h = raw.toLowerCase().replace(/[^a-z]/g, ""); if (!h) return null; if (h.includes("seal")) return "sealNumber"; if (h.includes("size") || h.includes("type")) return "containerSize"; if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName"; if (h.includes("arriv") || h.includes("date")) return "arrivedAt"; if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility"; if (h.includes("yard")) return "yard"; if (h.includes("zone")) return "zone"; if (h.includes("weight") || h.includes("vgm")) return "weight"; if (h.includes("note") || h.includes("remark")) return "notes"; if (h.includes("container") || h.includes("number")) return "containerNumber"; return null; } /** * Excel dates arrive either as a serial number (raw cells) or as text. * Returns an ISO instant, or null when the cell is empty/unparseable. */ function normalizeDate(raw: string): string | null { const v = raw.trim(); if (!v) return null; if (/^\d{1,6}(\.\d+)?$/.test(v)) { const serial = Number(v); if (serial > 20000 && serial < 80000) { return new Date(Math.round((serial - 25569) * 86400000)).toISOString(); } } const parsed = new Date(v); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } /** Parse an uploaded workbook into one row per loaded container. */ export async function parseFullContainerExcel(file: File): Promise { 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(sheet, { header: 1, raw: false, defval: "" }); let headerRowIdx = -1; let columns: Array = []; for (let i = 0; i < grid.length; i++) { const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? ""))); if (mapped.includes("containerNumber")) { headerRowIdx = i; columns = mapped; break; } } if (headerRowIdx < 0) { return { rows: [], errors: [ 'Could not find a "Container Number" column — download the template to see the expected format.', ], }; } const rows: ParsedFullContainerRow[] = []; const errors: string[] = []; const numberCounts = new Map(); const startOfTomorrow = new Date(); startOfTomorrow.setHours(24, 0, 0, 0); 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 containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, ""); if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) { errors.push( `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`, ); } else { numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1); } const arrivedRaw = cell("arrivedAt"); const arrivedAt = normalizeDate(arrivedRaw); if (arrivedRaw && !arrivedAt) { errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`); } // The whole point of a backlog is that it arrived in the past. if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) { errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`); } const weightRaw = cell("weight"); if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) { errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`); } rows.push({ containerNumber, containerSize: cell("containerSize"), companyName: cell("companyName"), arrivedAt, facility: cell("facility"), yard: cell("yard"), zone: cell("zone"), sealNumber: cell("sealNumber"), weight: weightRaw, notes: cell("notes"), }); } 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: [] }; } /** Download the import template with one filled sample row. */ export function downloadFullContainerTemplate() { const headers = [ "Container Number", "Container Size", "Company", "Arrival Date", "Facility", "Yard", "Zone", "Seal Number", "Weight (Tons)", "Notes", ]; const sample = [ "TEMU1234567", "40", "Acme Import PLC", "2026-03-14", "Gelan Multipurpose port", "Yard A", "Zone 1", "SL482910", 24.5, "Backlog — registered from yard tally sheet", ]; const sheet = XLSX.utils.aoa_to_sheet([headers, sample]); sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) })); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers"); XLSX.writeFile(workbook, "full-container-backlog-template.xlsx"); }