diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 4752b004f..e038c7bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -51,6 +51,11 @@ export class BookingRequestService { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); this.assertGeneralCustoms(contract); + if (contract.status === 'CONTRACT_CLOSED') { + throw new ConflictException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts new file mode 100644 index 000000000..fb57be209 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractBookingService } from './contract-booking.service'; +import { Contract } from './entities/contract.entity'; + +/** + * Contract auto-completion by quantity cap. Once a GENERAL contract's capped + * scope is fully consumed (e.g. a split remainder rebooked), the contract moves + * to CONTRACT_CLOSED even inside its validity window, and further bookings are + * blocked — including while a booking window is open. Released capacity + * (cancelled/expired booking) reopens the contract on the next attempt. + */ +describe('ContractBookingService — quantity-cap completion', () => { + function makeService() { + const contractsRepository = { + findByIdWithRelations: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new ContractBookingService( + contractsRepository as never, + {} as never, // bookingsRepository + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // dataSource + {} as never, // trainSchedulingService + ); + return { service, contractsRepository }; + } + + type WithPrivate = { + maybeCompleteContract: (c: Contract) => Promise; + }; + + const generalContract = (status: string): Contract => + ({ + id: 'c-1', + reference: 'CTR-1', + contractKind: 'GENERAL', + status, + }) as Contract; + + it('closes a GENERAL contract when every capped line is exhausted', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 4, remaining: 0 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('absorbs bulk-ton float dust when judging exhaustion', async () => { + const { service, contractsRepository } = makeService(); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('FULLY_EXECUTED'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('keeps the contract open while any capped line has capacity left', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 3, remaining: 1 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes an uncapped contract', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => { + const { service, contractsRepository } = makeService(); + const spy = jest.spyOn(service, 'computeCapacity'); + + await (service as never as WithPrivate).maybeCompleteContract({ + id: 'c-1', + contractKind: 'ONE_TIME', + status: 'FULLY_EXECUTED', + } as Contract); + + expect(spy).not.toHaveBeenCalled(); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('rejects a new booking on a completed contract even inside an open window', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]); + + await expect( + service.createUnderContract('c-1', {} as never, null, null), + ).rejects.toThrow(BadRequestException); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('reopens a completed contract when capacity was released', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]); + + // The create path continues past the gate and dies later on the bare mocks — + // only the reopen transition is under test here. + await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_ACTIVE', + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 46fb61db1..70083a2ae 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -83,6 +83,24 @@ export class ContractBookingService { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + // A contract whose quantity cap was fully booked is completed — no further + // bookings, even while contract validity and a booking window are still + // open. Capacity released after closure (a cancelled/expired booking) + // reopens the contract on the next booking attempt. + if (contract.status === 'CONTRACT_CLOSED') { + const capacity = await this.computeCapacity(contract); + const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); + if (!hasRoom) { + throw new BadRequestException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_ACTIVE', + } as never); + contract.status = 'CONTRACT_ACTIVE'; + } + // GL Ethiopia is identified by the dedicated contract create-booking permission // (granted to the edr_gl_ethiopia preset). const isGlActor = @@ -291,6 +309,9 @@ export class ContractBookingService { if (!parked.paired) { // Waiting for a partner — stop here. The booking sits in // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. + // A parked booking still holds contract capacity, so the cap may + // already be exhausted by it. + await this.maybeCompleteContract(contract); const pendingResult = await this.bookingsRepository.findByIdWithFiles( booking.id, ); @@ -304,6 +325,8 @@ export class ContractBookingService { generalCustoms, ); + await this.maybeCompleteContract(contract); + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: result ?? booking, warnings }; } @@ -606,6 +629,44 @@ export class ContractBookingService { }); } + /** + * Complete the contract once its quantity cap is fully consumed. Runs after + * every booking created under a GENERAL contract (including a split remainder + * being rebooked): when no capped scope line has capacity left, the contract + * moves to CONTRACT_CLOSED even though its validity window is still open — + * blocking further bookings and shipment requests, including inside an open + * booking window. Never throws: a status hiccup must not undo the booking + * that was just created. + */ + private async maybeCompleteContract(contract: Contract): Promise { + try { + // ONE_TIME contracts are governed by the single-active-booking slot (and + // are promoted to GENERAL on split), so only GENERAL completes by cap. + if (contract.contractKind !== 'GENERAL') return; + if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped — completes only by expiry + // 0.001 tolerance absorbs bulk-ton float rounding (split weights round to + // 3 decimals); container caps are integers and unaffected. + const exhausted = capacity.every( + (c) => c.remaining != null && c.remaining <= 0.001, + ); + if (!exhausted) return; + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_CLOSED', + } as never); + this.logger.log( + `Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`, + ); + } catch (err) { + this.logger.error( + `Could not evaluate completion for contract ${contract.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + /** * Quantities already booked under a contract that still hold capacity. Excludes * bookings that never shipped (CANCELLED / REJECTED / EXPIRED). diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 095857959..517d818bf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -265,10 +265,11 @@ export class ContractsService { } } - // Attach the company profile's onboarding / business-license documents to the - // contract by reference. The separate "Documents" intake step was removed — - // the profile documents are simply carried onto every contract automatically. - await this.attachProfileDocuments(contract.id, companyProfileId); + // Attach the company's onboarding documents (TIN, licenses, IDs) and the + // profile's business-license documents to the contract by reference. The + // separate "Documents" intake step was removed — the profile documents are + // simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId); return { contract: await this.findById(contract.id), warnings }; } @@ -316,51 +317,95 @@ export class ContractsService { } /** - * Copy a company profile's stored business-license / onboarding documents onto - * a contract by reference (no byte re-upload). Codes are slugged from each - * document name so they group under "Profile documents" on the contract detail - * page. No-op when the contract has no profile or the profile has no documents. + * Copy the company's onboarding documents (TIN certificate, commercial / + * investment license, national ID, passport — resource "companies", coded by + * the upload-setting fileKey) and the company profile's business-license + * documents (resource "company_profiles") onto a contract by reference (no + * byte re-upload). Idempotent: codes already present on the contract — user + * uploads or an earlier carry — are never duplicated or overwritten, so it is + * safe to run on every create and update. No-op when there is nothing to copy. */ private async attachProfileDocuments( contractId: string, + companyId: string | null, companyProfileId: string | null, ): Promise { - if (!companyProfileId) return; - // Business-license files are FileRecords (resource "company_profiles"); carry - // the live ones by reference. Staged/pending uploads are excluded by code. - const records = await this.filesService.findByResource( - companyProfileId, - 'company_profiles', + if (!companyId && !companyProfileId) return; + + const existingCodes = new Set( + (await this.filesService.findByResource(contractId, 'contracts')).map( + (r) => r.code, + ), ); - const docs = records - .filter((r) => r.code === 'business_license') - .map((r) => ({ - name: r.name, - url: r.url, - size: r.size, - mimeType: r.mimeType, - })); + const docs: Array<{ + code: string; + name: string; + url: string; + size: number; + mimeType?: string; + }> = []; + + if (companyId) { + // Company onboarding documents keep their fileKey codes (tin_certificate, + // commercial_license, …) so the portal can match them against the + // onboarding upload-setting fields. Re-uploads append rows, so keep only + // the newest record per code. + const companyRecords = await this.filesService.findByResource( + companyId, + 'companies', + ); + const latestByCode = new Map(); + for (const r of companyRecords) { + const prev = latestByCode.get(r.code); + if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r); + } + for (const r of latestByCode.values()) { + if (existingCodes.has(r.code)) continue; + docs.push({ + code: r.code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + } + } + + if (companyProfileId) { + // Business-license files are FileRecords (resource "company_profiles"); + // carry the live ones by reference. Staged/pending uploads are excluded by + // code. Codes are slugged from each document name so they group under + // "Profile documents" on the contract detail page. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const slug = (name: string) => + name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'profile_document'; + + records + .filter((r) => r.code === 'business_license') + .forEach((r, i) => { + const code = `${slug(r.name)}_${i + 1}`; + if (existingCodes.has(code)) return; + docs.push({ + code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + }); + } + if (docs.length === 0) return; - const slug = (name: string) => - name - .toLowerCase() - .replace(/\.[a-z0-9]+$/, '') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'profile_document'; - try { - await this.filesService.attachExistingFiles( - contractId, - 'contracts', - docs.map((d, i) => ({ - code: `${slug(d.name)}_${i + 1}`, - name: d.name, - url: d.url, - size: d.size, - mimeType: d.mimeType, - })), - ); + await this.filesService.attachExistingFiles(contractId, 'contracts', docs); } catch { // Non-fatal — the contract is still valid without the carried documents. } @@ -481,6 +526,15 @@ export class ContractsService { await this.filesService.uploadMany(id, 'contracts', files); } + // Re-carry any company/profile document that is still missing from the + // contract (runs after the upload so fresh replacements keep their slot). + // Backfills contracts created before profile documents were carried over. + await this.attachProfileDocuments( + id, + existing.companyId ?? null, + existing.companyProfileId ?? null, + ); + return { contract: await this.findById(id), warnings }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 104db4545..ad0967cf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -36,6 +36,9 @@ export interface SizedOffer { * rows, so reducing the lines releases it automatically) and can be rebooked in * any later window within contract validity. A ONE_TIME contract is promoted to * GENERAL on split (see applySplit) so its remainder is actually rebookable. + * Once the remainder is rebooked and the cap hits zero, ContractBookingService + * completes the contract (CONTRACT_CLOSED): no further bookings or shipment + * requests, even while validity and a booking window are still open. */ @Injectable() export class BookingSplitService { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index b89807b72..140bc1d01 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -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": { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index a744453c0..33254d3ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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([]); + const [importSummary, setImportSummary] = useState(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(() => { if (!isContainer) return []; const numberCounts = new Map(); @@ -740,6 +798,83 @@ export default function GlCreateBookingForm() { description="Enter the quantity and per-container details for each size in the contract scope." /> + {containerSizes.length > 0 && ( + + + + + Import containers from Excel + + + One row per container. Importing fills the lines below + for the sizes in the file. + + + + + + {(props) => ( + + )} + + + + {importErrors.length > 0 && ( + } + title="Import failed — fix the file and try again" + mt="sm" + > + + {importErrors.slice(0, 8).map((msg, i) => ( + + {msg} + + ))} + {importErrors.length > 8 && ( + + …and {importErrors.length - 8} more. + + )} + + + )} + {importSummary && ( + } + mt="sm" + > + {importSummary} + + )} + + )} {containerLines.length === 0 ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts new file mode 100644 index 000000000..bbd3acf90 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -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 { + 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: "", + }); + + // First row with a recognizable column is the header; everything above + // (titles, blank rows) is ignored. + 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") && 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(); + + 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 = [ + 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"); +} diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 433b83c1a..2cfd488d6 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -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" }, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 9b24d3b8b..33a89e3e1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -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"]), ) : {} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 9410fbc7b..4a073a59a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -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([]); + const [importSummary, setImportSummary] = useState(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 ( @@ -940,6 +1001,83 @@ function CargoStep({ description="Enter the quantity and per-container details for each size in your contract scope." /> + {sizes.length > 0 && ( + + + + + Import containers from Excel + + + One row per container. Importing fills the lines below for + the sizes in your file. + + + + + + {(props) => ( + + )} + + + + {importErrors.length > 0 && ( + } + title="Import failed — fix the file and try again" + mt="sm" + > + + {importErrors.slice(0, 8).map((msg, i) => ( + + {msg} + + ))} + {importErrors.length > 8 && ( + + …and {importErrors.length - 8} more. + + )} + + + )} + {importSummary && ( + } + mt="sm" + > + {importSummary} + + )} + + )} {lines.map((line, index) => ( 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(); + 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 ( @@ -115,12 +165,21 @@ export function ContractDocsEditor({ - - - - On file - - + {file.fromProfile ? ( + + + + From profile + + + ) : ( + + + + On file + + + )}