diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index d8de3e95a..f9c7e182f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -146,3 +146,94 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( ); }); }); + +/** + * The first clearance submission (AWAITING_DOCUMENTS) must include every + * required input document; subsequent re-uploads during review only need the + * specific files being fixed, so already-uploaded required docs stay in place. + */ +describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { + const inputSetting = { + code: 'clearance_import_container_without_customs', + fields: [ + { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, + { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, + ], + }; + + function makeService(status: string, existingCodes: string[]) { + const bookingsRepository = { + upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue({ id: 'b-3' }), + }; + const booking = { + id: 'b-3', + status, + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: { includesCustoms: false }, + }; + const bookingsService = { findById: jest.fn().mockResolvedValue(booking) }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockResolvedValue(inputSetting), + }; + const filesService = { + findByResource: jest + .fn() + .mockResolvedValue(existingCodes.map((code) => ({ code }))), + upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }), + }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, + {} as never, + {} as never, + filesService as never, + fileUploadSettingsService as never, + {} as never, + bookingsService as never, + ); + return { service, bookingsRepository, filesService }; + } + + function fakeFile(fieldname: string): Express.Multer.File { + return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File; + } + + it('rejects the first submission when a required document is missing', async () => { + const { service } = makeService('AWAITING_DOCUMENTS', []); + await expect( + service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('accepts the first submission when every required document is provided', async () => { + const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []); + await service.submitClearanceDocuments('b-3', [ + fakeFile('commercial_invoice'), + fakeFile('packing_list'), + ]); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-3', + expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }), + ); + }); + + it('allows re-uploading a single queried document during review without re-sending the rest', async () => { + // packing_list was already uploaded in the first round; the customer is now + // only re-uploading the queried commercial_invoice. + const { service, bookingsRepository } = makeService( + 'DOCUMENTS_UNDER_REVIEW', + ['packing_list'], + ); + await service.submitClearanceDocuments('b-3', [ + fakeFile('commercial_invoice'), + ]); + // Only the re-uploaded doc is touched — no full re-gate, no rework on the rest. + expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1); + expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith( + expect.objectContaining({ fileKey: 'commercial_invoice' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index b85f55206..d819ee527 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -645,6 +645,14 @@ export class BookingTransitionService { throw new BadRequestException('No documents uploaded'); } + // First submission (nothing in review yet): every required input field must + // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer + // is only fixing queried/pending docs, so the already-uploaded required docs + // stay in place and we don't re-gate on the full required set. + if (booking.status === 'AWAITING_DOCUMENTS') { + await this.assertRequiredInputsPresent(bookingId, inputCode, files); + } + for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, @@ -670,6 +678,41 @@ export class BookingTransitionService { return this.bookingsService.findById(bookingId); } + /** + * Guard for the first clearance submission: every required field of the + * booking's customer-input set must be covered, either by a file already on + * the booking or by one in this upload batch. Keeps the customer from starting + * review with required documents missing. + */ + private async assertRequiredInputsPresent( + bookingId: string, + inputCode: string, + files: Express.Multer.File[], + ): Promise { + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return; // setting not seeded — nothing to enforce + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return; + + const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const presentKeys = new Set([ + ...existing.map((f) => f.code), + ...files.map((f) => f.fieldname), + ]); + + const missing = required.filter((f) => !presentKeys.has(f.fileKey)); + if (missing.length > 0) { + const labels = missing.map((f) => f.fileLabel).join(', '); + throw new BadRequestException( + `Please upload all required documents before submitting: ${labels}`, + ); + } + } + /** GL reviews a single document: APPROVED or QUERIED (with a note). */ async reviewDocument( bookingId: string, diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index aff2e744f..70e58f77c 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -203,6 +203,19 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight }, }, + OPERATION_REQUEST_PENDING: { + stage: 3, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Operation request under review by operations", + step: "edr-blue-dot", + badgeLabel: "Op. review", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, OPERATION_REQUESTED: { stage: 3, icon: CheckCircle2, @@ -216,6 +229,45 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + OPERATION_CHANGES_REQUESTED: { + stage: 3, + icon: FilePen, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Operations requested changes · please review", + step: "edr-accent", + badgeLabel: "Revise", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Review", kind: "dark" }, + }, + OPERATION_PRICE_PENDING_CONFIRM: { + stage: 3, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price adjusted · confirm to proceed", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + ROAD_DISPATCH_PENDING: { + stage: 3, + icon: Truck, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Accepted · awaiting truck dispatch", + step: "edr-blue-dot", + badgeLabel: "Awaiting dispatch", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, PNR_GENERATED: { stage: 3, icon: FileCheck2, @@ -372,6 +424,84 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + PRICE_CHANGED_PENDING_CONFIRM: { + stage: 1, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price changed · confirm to continue", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + READY_FOR_ASSIGNMENT: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Approved · awaiting wagon assignment", + step: "edr-blue-dot", + badgeLabel: "Assigning", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + WAGON_ASSIGNED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Wagon assigned · preparing for loading", + step: "edr-green.5", + badgeLabel: "Wagon assigned", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + INVOICED: { + stage: 3, + icon: Wallet, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Invoice issued · awaiting payment", + step: "edr-blue-dot", + badgeLabel: "Invoiced", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_ACTIVE: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Contract active · accepting orders", + step: "edr-green.5", + badgeLabel: "Active", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_CLOSED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Contract closed · quantity used or window elapsed", + step: "edr-step", + badgeLabel: "Closed", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View", kind: "outline" }, + }, }; export const ACTION_PROPS: Record< diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 0d8ec8b4e..84836a900 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -59,7 +59,7 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { leftSection={} onClick={() => flow.submitDocuments()} loading={flow.uploadMutation.isPending} - disabled={!flow.hasStagedFiles} + disabled={!flow.canSubmit} > Submit documents diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 92eb042a4..67be567db 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -177,6 +177,61 @@ export const STATUS_MAP: Record< description: "Cargo has been consolidated with a partner shipment.", stage: 5, }, + AWAITING_DOCUMENTS: { + title: "Clearance documents needed", + description: + "Upload the required clearance documents so your shipment can be reviewed.", + stage: 5, + }, + DOCUMENTS_UNDER_REVIEW: { + title: "Documents under review", + description: + "Your clearance documents are being reviewed. Re-upload any queried documents to proceed.", + stage: 5, + }, + CLEARANCE_READY: { + title: "Cleared — choose a shipment day", + description: + "Clearance is complete. Pick a shipment day and proceed to operation.", + stage: 5, + }, + OPERATION_REQUESTED: { + title: "Operation requested", + description: "Operation requested. An operator will take your shipment forward.", + stage: 5, + }, + CONTRACT_ACTIVE: { + title: "Contract active", + description: "This general contract is active and accepting drawdown orders.", + stage: 5, + }, + CONTRACT_CLOSED: { + title: "Contract closed", + description: + "This general contract is closed — its reserved quantity has been used or its window has elapsed.", + stage: 7, + }, + PRICE_CHANGED_PENDING_CONFIRM: { + title: "Price changed — confirm to proceed", + description: + "The price for this booking changed. Confirm the new price to continue.", + stage: 1, + }, + READY_FOR_ASSIGNMENT: { + title: "Awaiting wagon assignment", + description: "Approved and queued for wagon assignment.", + stage: 2, + }, + WAGON_ASSIGNED: { + title: "Wagon assigned", + description: "A wagon has been assigned and your cargo is being prepared for loading.", + stage: 5, + }, + INVOICED: { + title: "Invoice issued", + description: "An invoice has been issued for this booking.", + stage: 5, + }, COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index a0e5c9d4f..23676efe8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -86,7 +86,7 @@ function BookingActionModalBody({ leftSection={} onClick={handleSubmit} loading={flow.uploadMutation.isPending} - disabled={!flow.hasStagedFiles} + disabled={!flow.canSubmit} > Submit documents diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index 5e410ce33..579fcc9d6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -90,9 +90,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { glDocs, isReady, canUpload, + isInitialUpload, status, pending, adHoc, + missingRequired, stagePending, addAdHocRow, setAdHocName, @@ -116,14 +118,23 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( } mb="md"> {clearance.includesCustoms - ? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded." - : "Our team is reviewing your documents. Queried documents below need to be re-uploaded."} + ? "Global Logistics is reviewing your documents and will clear your shipment. Only re-upload the documents flagged with a query below — approved documents stay as they are." + : "Our team is reviewing your documents. Only re-upload the documents flagged with a query below — approved documents stay as they are."} ) : ( } mb="md"> {clearance.includesCustoms - ? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here." - : "Upload all the required clearance documents below to start the review."} + ? "Upload every required document customs needs (marked *) to start the review. Global Logistics will clear your shipment and return the cleared documents here." + : "Upload every required clearance document (marked *) below to start the review."} + + )} + + {isInitialUpload && missingRequired.length > 0 && ( + + + Still required:{" "} + {missingRequired.map((d) => d.label).join(", ")} + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts index d2d74ae92..e8380c8ec 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -69,10 +69,32 @@ export function useClearanceFlow(booking: Freight.IBooking) { const isReady = status === "CLEARANCE_READY"; const canUpload = status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; + // The very first upload (nothing in review yet). Here every required document + // must be provided. Once GL has started reviewing (DOCUMENTS_UNDER_REVIEW) the + // customer is only re-uploading queried/pending docs, so we don't re-gate on + // the full required set. + const isInitialUpload = status === "AWAITING_DOCUMENTS"; const hasStagedFiles = Object.keys(pending).length > 0 || adHoc.some((r) => r.file); + // A required customer document is satisfied when it already has an uploaded + // file or the customer has just staged one for this submission. + const missingRequired = useMemo( + () => + customerDocs.filter( + (d) => d.required && !d.file && !pending[d.fileKey], + ), + [customerDocs, pending], + ); + + // Initial upload: block submit until every required field has a file (and at + // least one file is actually staged to send). Re-upload rounds only need at + // least one staged file — the customer fixes the specific queried documents. + const canSubmit = isInitialUpload + ? hasStagedFiles && missingRequired.length === 0 + : hasStagedFiles; + // --- staged-upload mutators ---------------------------------------------- const stagePending = (fileKey: string, file: File) => @@ -117,10 +139,13 @@ export function useClearanceFlow(booking: Freight.IBooking) { glDocs, isReady, canUpload, + isInitialUpload, // staged upload state pending, adHoc, hasStagedFiles, + missingRequired, + canSubmit, stagePending, addAdHocRow, setAdHocName,