From 9ed473c309aa739ea290c0da6be2fa7d744c104a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 18:56:39 +0000 Subject: [PATCH 1/6] Implement clearance-first booking flow and completion process for customs contracts --- .../bookings/booking-transition.service.ts | 9 ++ .../bookings/bookings.repository.spec.ts | 3 + .../contracts/booking-request.service.ts | 14 +- .../contracts/clearance-milestone.service.ts | 25 ++++ .../contract-booking.consolidation.spec.ts | 11 +- .../contracts/contract-booking.service.ts | 128 ++++++++++++++++-- .../modules/contracts/contracts.controller.ts | 10 +- apps/edr-freight-web/backoffice/src/App.tsx | 12 ++ .../contracts/GlCreateBookingForm.tsx | 64 +++++++-- .../backoffice/src/constants/URLS.ts | 2 + .../src/hooks/contracts/useContracts.ts | 22 +++ .../bookings/DocumentClearanceDetailPage.tsx | 33 ++++- .../pages/contracts/ShipmentRequestsPage.tsx | 2 +- .../src/services/contracts.service.ts | 25 ++++ .../bookings/clearance/BookingActionModal.tsx | 5 +- .../bookings/clearance/ClearanceFlow.tsx | 11 +- .../bookings/clearance/useClearanceFlow.ts | 17 ++- .../contracts/NewShipmentRequestPage.tsx | 15 +- 18 files changed, 369 insertions(+), 39 deletions(-) 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 8b6e8a2f8..7b1522446 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 @@ -988,6 +988,15 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A bare initiated instance (clearance-first flow) carries no cargo or + // price — it must go through the contract completion endpoint, which + // persists cargo, prices, invoices and only then lands here itself. + if (booking.contractId && !(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + "This booking must be completed (cargo and shipment day) before requesting operation.", + ); + } + const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException("A valid schedule date is required"); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index 7d4ff199c..b2937e98d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -7,6 +7,7 @@ function mockQueryBuilder() { const qb = { leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoin: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), @@ -15,6 +16,8 @@ function mockQueryBuilder() { take: jest.fn().mockReturnThis(), getMany: jest.fn(), getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + getCount: jest.fn().mockResolvedValue(0), + getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }), }; return qb; } 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 17270c738..1005409e0 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 @@ -113,6 +113,17 @@ export class BookingRequestService { }, }; + // Clearance-first flow: the request immediately initiates a BARE booking + // instance (no cargo, no date, no price) that enters per-booking phased + // customs clearance. GL no longer screens the request up front — it + // reviews the documents in the clearance queue and completes the booking + // (container numbers, VGM, shipment day) once clearance is ready. The + // instance is created first so a failure leaves no half-linked request. + const booking = await this.contractBookingService.initiateForShipmentRequest( + contract, + { contractRouteId: dto.contractRouteId, userId }, + ); + const reference = await this.generateReference(); const request = await this.repo.create({ reference, @@ -120,7 +131,8 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: 'PENDING', + status: 'ACCEPTED', + createdBookingId: booking.id, requestedLines, notes: dto.notes ?? null, } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 81ed305e4..ed3597d57 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -58,6 +58,31 @@ export class ClearanceMilestoneService { await this.seed(postBooking, { bookingId }); } + /** + * Seed whichever pre/post-booking milestones the booking is still missing, + * keyed by milestoneCode. Plain seeding is a blind insert, so paths that can + * run more than once (completing an initiated instance whose pre-booking + * milestones were seeded at initiation, or a consolidation pairing replay) + * must go through this instead — a duplicate timeline breaks the phase + * derivation. + */ + async ensureBookingMilestones( + bookingId: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.find({ where: { bookingId } }); + const have = new Set(existing.map((m) => m.milestoneCode)); + const { preBooking, postBooking } = splitMilestones(tradeDirection); + await this.seed( + preBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + await this.seed( + postBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + } + private async seed( defs: MilestoneDef[], scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fa851b409..7225d6e3f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { const milestoneService = { seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), + ensureBookingMilestones: jest.fn().mockResolvedValue(undefined), ...overrides.milestoneService, }; const contractsRepository = { @@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => { await service.onConsolidationPaired({ bookingIds: ['b-1'] }); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); - // GENERAL customs → per-booking pre + post milestones. - expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled(); - expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled(); + // GENERAL customs → per-booking milestones, via the idempotent ensure so a + // pairing replay (or an initiated instance's pre-seeded timeline) never + // duplicates rows. + expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith( + 'b-1', + 'EXPORT', + ); }); it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { 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 74d9cb1ea..a0da43784 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 @@ -426,17 +426,100 @@ export class ContractBookingService { } /** - * Complete a bare initiated booking after Operations finalized its per-booking - * clearance (CLEARANCE_READY) or returned it for changes + * Initiate a BARE booking instance for a GENERAL + customs shipment request + * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it + * validated the contract (general customs, active, capacity) — the request + * itself carries the quantities; the instance carries none. Pre-booking + * customs milestones are seeded immediately so the instance enters the same + * phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking. + * GL completes the booking (cargo + day) via {@link completeUnderContract} + * once the clearance reaches CLEARANCE_READY. + */ + async initiateForShipmentRequest( + contract: Contract, + opts: { contractRouteId?: string; userId?: string | null }, + ): Promise { + const generalCustoms = + contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + if (!generalCustoms) { + throw new BadRequestException( + 'Shipment-request initiation applies only to general customs contracts.', + ); + } + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const route = await this.resolveRoute(contract, opts.contractRouteId); + + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + status: 'AWAITING_DOCUMENTS', + bookingType: 'ONE_TIME', + contractId: contract.id, + contractRouteId: route?.id ?? null, + contractKind: contract.contractKind, + createdByRole: 'CUSTOMER', + createdByUserId: opts.userId ?? null, + scheduledDate: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: 'NEW', + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? null, + equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: this.resolveCargoTypeId(contract, {}), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + cargoTotalWeightVgm: 0, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + firstMilePickupLat: contract.firstMilePickupLat ?? null, + firstMilePickupLng: contract.firstMilePickupLng ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, + } as never), + ); + + // Pre-booking phase only — the post-booking milestones (loading, transit) + // are seeded when GL completes the booking, mirroring the ONE_TIME flow + // where GL's booking creation seeds them. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); + + return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + } + + /** + * Complete a bare initiated booking after its per-booking clearance is + * finalized (CLEARANCE_READY) or operations returned it for changes * (OPERATION_CHANGES_REQUESTED). This is the deferred half of * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking * window + open-departure checks, pricing, consolidation and invoicing all run * here — the same gates a one-time shipment passes at creation. + * + * Actor rules mirror {@link assertGate}: a customs (Path B) instance is + * completed by GL Ethiopia only; a non-customs (Path A) instance by the + * customer (or staff). */ async completeUnderContract( contractId: string, bookingId: string, dto: CreateBookingUnderContractDto, + actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); @@ -450,6 +533,18 @@ export class ContractBookingService { 'Clearance must be finalized before the booking can be completed.', ); } + // Path B: only GL Ethiopia completes a customs instance — the customer + // never enters shipment data on a customs contract. + if (contract.customsClearingEnabled) { + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + if (!isGlActor) { + throw new ForbiddenException( + 'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.', + ); + } + } if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } @@ -457,6 +552,15 @@ export class ContractBookingService { throw new BadRequestException('Contract validity has expired — no new bookings.'); } + // Completion is booking time: the route's booking window must be open — + // the same config-driven gate a direct one-time booking passes at create. + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + scheduledDate: dto.scheduledDate, + direction: contract.tradeDirection ?? null, + }); + const freightType = contract.freightType; const hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -541,8 +645,13 @@ export class ContractBookingService { } } - // Invoice the now-priced booking (idempotent, non-blocking). - await this.finalizeContractBooking(booking.id, contract, false); + // Invoice the now-priced booking and, for a customs instance, seed the + // post-booking milestones (pre-booking ones exist since initiation — + // ensure* fills only what is missing). Idempotent, non-blocking. + const generalCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); } @@ -627,12 +736,11 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed full milestone timeline on the booking. - await this.milestoneService.seedPreBookingMilestonesOnBooking( - bookingId, - contract.tradeDirection, - ); - await this.milestoneService.seedPostBookingMilestones( + // Per-booking clearance: seed the full milestone timeline on the booking. + // ensure* skips codes that already exist — an initiated instance carries + // its pre-booking milestones from initiation, and a consolidation pairing + // replay must not duplicate the timeline. + await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index d5dc9793e..95b734c60 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -826,8 +826,16 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.contractBookingService.completeUnderContract(id, bookingId, dto); + // Customs (Path B) instances may only be completed by GL Ethiopia — the + // service checks the actor's contracts:create_booking permission. + return this.contractBookingService.completeUnderContract( + id, + bookingId, + dto, + user, + ); } @Post(':id/validate-shipment') diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a94f7516a..d529c2817 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -865,6 +865,18 @@ const App = () => { } /> + {/* Completion of an initiated (bare) instance after per-booking + clearance — same form, submits to the complete endpoint. */} + + + + } + /> } 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 32ec2c511..bb94fea08 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -145,13 +145,37 @@ function bulkUnitOfMeasure( } export default function GlCreateBookingForm() { - const { id } = useParams<{ id: string }>(); + // With `bookingId` the form runs in COMPLETION mode: the bare instance + // (auto-initiated by the customer's shipment request) already finished its + // per-booking customs clearance, and this form supplies the deferred cargo + // (container numbers, VGM) + binding shipment day. Same window gate, same + // validation and price confirmation — the submit completes the existing + // booking instead of creating a new one. + const { id, bookingId: completeBookingId } = useParams<{ + id: string; + bookingId?: string; + }>(); const [searchParams] = useSearchParams(); - const requestId = searchParams.get("requestId"); + const requestIdParam = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); + // Completion mode without an explicit ?requestId=: find the shipment request + // that initiated this instance so the quantities still prefill. + const { data: contractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", id], + queryFn: () => contractsService.listBookingRequests(id!), + enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam, + }); + const requestId = + requestIdParam ?? + (completeBookingId + ? (contractRequests?.find( + (r) => r.createdBookingId === completeBookingId, + )?.id ?? null) + : null); + const { data: bookingRequest } = useQuery({ queryKey: ["shipment-request", requestId], queryFn: () => contractsService.getBookingRequest(requestId!), @@ -636,6 +660,18 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + if (completeBookingId) { + // Completion mode: cargo + day land on the already-cleared instance — + // the request was linked and accepted at submission time. + mutations.completeBooking.mutate( + { bookingId: completeBookingId, payload }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -685,10 +721,12 @@ export default function GlCreateBookingForm() { - New Shipment Booking + {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} - Book a shipment on behalf of the customer for contract {contract.reference}. + {completeBookingId + ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` + : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} @@ -1421,7 +1466,10 @@ export default function GlCreateBookingForm() { color="edr-green" radius="md" leftSection={} - loading={mutations.createBooking.isPending} + loading={ + mutations.createBooking.isPending || + mutations.completeBooking.isPending + } disabled={ validateShipmentMutation.isPending || pairingErrors.length > 0 || @@ -1429,7 +1477,7 @@ export default function GlCreateBookingForm() { } onClick={handleSubmit} > - Confirm & book + {completeBookingId ? "Confirm & complete" : "Confirm & book"} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c6f98c8a2..ca9f6342d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -211,6 +211,8 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + BOOKINGS_COMPLETE: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/complete`, VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 69e4e1739..076316114 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) { onError: () => toast.error("Failed to create booking"), }); + const completeBooking = useMutation({ + mutationFn: ({ + bookingId, + payload, + }: { + bookingId: string; + payload: Freight.CreateBookingUnderContractDto; + }) => + contractsService.completeBookingUnderContract( + contractId, + bookingId, + payload, + ), + onSuccess: () => { + toast.success("Booking completed"); + void invalidateContractDetail(qc, contractId); + }, + onError: (e: Error) => + toast.error(e.message || "Failed to complete booking"), + }); + const isPending = staffAccept.isPending || requestChanges.isPending || @@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) { generateContract, signContract, createBooking, + completeBooking, isPending, }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 2cb5404e7..efe9692dc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -1,10 +1,11 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { Alert, Badge, Box, + Button, Grid, Group, Loader, @@ -21,6 +22,7 @@ import { CheckCircle2, Clock, PackageCheck, + PackagePlus, ShieldCheck, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -38,10 +40,14 @@ import { useBookingMilestones } from "@/hooks/contracts/useContracts"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail } from "@/hooks/bookings/useBookings"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; export default function DocumentClearanceDetailPage() { const params = useParams<{ id?: string; bookingId?: string }>(); const id = params.id ?? params.bookingId; + const navigate = useNavigate(); + const { user } = useAuth(); const { view, viewer } = useFileViewer(); const { data: booking } = useBookingDetail(id); @@ -76,6 +82,15 @@ export default function DocumentClearanceDetailPage() { booking?.contractKind === "GENERAL" && Boolean(clearance?.phase); + // Bare initiated instance whose clearance is done: GL completes the booking + // (container numbers, VGM, shipment day) via the completion form. + const canCompleteBooking = + booking?.status === "CLEARANCE_READY" && + Boolean(booking?.contractId) && + Boolean(booking?.customsClearingEnabled) && + !(Number(booking?.totalAmount ?? 0) > 0) && + hasPermission(user, FREIGHT_PERMS.contracts.createBooking); + const docsPhaseComplete = clearance?.milestones?.some( (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", @@ -146,6 +161,22 @@ export default function DocumentClearanceDetailPage() { ) } + action={ + canCompleteBooking ? ( + + ) : undefined + } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx index 7f2bf52d8..4eb93cafd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx @@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() { => { + const result = await postContract<{ + booking?: { id: string; reference: string }; + id?: string; + reference?: string; + warnings?: string[]; + }>(C.BOOKINGS_COMPLETE(id, bookingId), payload); + const booking = result.booking ?? result; + return { + id: booking.id ?? "", + reference: booking.reference ?? "", + warnings: result.warnings, + }; + }, + /** * Pre-create validation + authoritative price preview: the same * BookingPricingService pass that prices the booking on create (rail + 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 32ea4f654..cbb163509 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 @@ -106,7 +106,10 @@ function BookingActionModalBody({ Complete booking ) : ( - flow.isReady && ( + // Customs bare instances await GL completion — no customer + // proceed button (the server rejects it anyway). + flow.isReady && + !flow.awaitingGlCompletion && ( + <> + { + if (!mutation.isPending) setConfirmOpen(false); + }} + centered + radius="lg" + size="md" + closeOnClickOutside={!mutation.isPending} + closeOnEscape={!mutation.isPending} + withCloseButton={!mutation.isPending} + title={ + + + + + Initiate a new booking? + + } + > + + This creates a new shipment booking under contract{" "} + + {contract.reference} + + . You'll upload the clearance documents next, and the shipment + quantity is drawn down from your contract's reserved capacity. + + + + + + + + ); } @@ -191,7 +252,12 @@ export function ContractCustomerActionCell({ return ( {docButton} - + ); } 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 5bbece55e..dcc455ada 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 @@ -1,6 +1,13 @@ import { useState } from "react"; import { Alert, Button, Group, Text } from "@mantine/core"; -import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react"; +import { + CheckCircle2, + ClipboardList, + Clock, + PackagePlus, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout"; /** * Customer-facing clearance section on the booking detail page: a compact - * status summary with a single action button. The document grid, re-uploads, - * and the shipment-day picker all live in the shared {@link BookingActionModal} - * (the same modal the My Shipments list uses), so the flow behaves identically - * from both entry points. + * status summary with a single action button. The document grid and re-uploads + * live in the shared {@link BookingActionModal} (the same modal the My + * Shipments list uses); a finished bare instance instead shows a "Book" button + * that navigates to the booking form. */ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [modalOpen, setModalOpen] = useState(false); + const navigate = useNavigate(); const status = booking.status as string; const action = getBookingNextAction(booking); + // BOOK: clearance finished on a bare instance — go straight to the booking + // form (cargo + shipment day + window check) instead of opening the modal. + const isBookAction = action?.kind === "BOOK" && Boolean(action.to); if (status === "OPERATION_REQUESTED") { return ( @@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const summary = status === "CLEARANCE_READY" ? ( }> - Clearance is complete. Pick a shipment day and proceed to operation. + {isBookAction + ? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window." + : "Clearance is complete. Pick a shipment day and proceed to operation."} ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( }> @@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { @@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { {summary} - Use “{action?.label ?? "the action button"}” to manage your clearance - documents. + {isBookAction + ? "Use “Book” to enter the cargo details and schedule your shipment." + : `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`} - setModalOpen(false)} - /> + {!isBookAction && ( + setModalOpen(false)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 82c692a90..ba8ca3db7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,6 +66,10 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); + // Contract-drawdown instance in the clearance gate: it was INITIATED with one + // click (no cargo/date yet), not submitted through the wizard. + const isInitiatedInstance = + status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId); // Legacy bookings never reach the ARRIVED status — they light up the Arrival // stage from the train's ARRIVED state while staying IN_TRANSIT, so the // headline is overridden here. Bookings with a per-booking journey carry the @@ -78,7 +82,14 @@ export function StatusHero({ "Your shipment reached its destination yard and is being unloaded and prepared for release.", stage, } - : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); + : isInitiatedInstance + ? { + title: "Booking initiated — clearance documents needed", + description: + "Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -123,6 +134,11 @@ export function StatusHero({ current={stage} tone={draft ? "ink" : "green"} negative={negative} + // Contract drawdowns are initiated with one click, not submitted + // through the wizard — relabel the stage for them. + labelOverrides={ + booking.contractId ? { 1: "Initiated" } : undefined + } /> )} @@ -132,10 +148,13 @@ export function StatusHero({ function ProgressTracker({ current, tone = "green", + labelOverrides, }: { current: number; tone?: "green" | "ink"; negative?: boolean; + /** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */ + labelOverrides?: Record; }) { const last = PROGRESS_STAGES.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; @@ -227,7 +246,7 @@ function ProgressTracker({ ta="center" c={state === "idle" ? "#9AA8B5" : "#10202F"} > - {stage.label} + {labelOverrides?.[idx] ?? stage.label} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index 03dfa6ac8..ec9b8c509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -1,6 +1,13 @@ import { Box, Button } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + PackagePlus, + PencilLine, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -19,6 +26,7 @@ const ICON_BY_KIND: Record< UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, + BOOK: PackagePlus, }; interface BookingActionButtonProps { @@ -39,6 +47,7 @@ export function BookingActionButton({ size = "sm", }: BookingActionButtonProps) { const [opened, { open, close }] = useDisclosure(false); + const navigate = useNavigate(); // Staff returned the booking for changes — let the customer update the docs // they submitted and resubmit, in place. @@ -49,6 +58,9 @@ export function BookingActionButton({ const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; + // BOOK navigates to the booking form (cargo + day + window check) — the + // same page a one-time booking uses — instead of opening the modal. + const navigateTo = action?.kind === "BOOK" ? action.to : undefined; return ( // Mantine modals portal to , but React events still bubble through @@ -65,7 +77,8 @@ export function BookingActionButton({ leftSection={} onClick={(e) => { e.stopPropagation(); - open(); + if (navigateTo) navigate(navigateTo); + else open(); }} > {label} @@ -77,7 +90,7 @@ export function BookingActionButton({ opened={opened} onClose={close} /> - ) : ( + ) : navigateTo ? null : ( )} 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 cbb163509..8f93e040c 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 @@ -48,24 +48,25 @@ function BookingActionModalBody({ const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); return ( + // Sized and styled to match the contract clearance modal + // (ContractClearanceAction) so both flows read as the same surface. - - {action?.title ?? "Booking"} + + {action?.title ?? "Clearance documents"} {reference} } - overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} styles={{ body: { paddingTop: 8 } }} > {flow.isLoading || !flow.clearance ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 5f88349dc..1ebb047a6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -9,7 +9,8 @@ import type { Freight } from "@edr/types"; export type BookingActionKind = | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them - | "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation + | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed + | "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form export interface BookingNextAction { kind: BookingActionKind; @@ -17,6 +18,8 @@ export interface BookingNextAction { label: string; /** Modal title. */ title: string; + /** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */ + to?: string; } const ACTION_BY_STATUS: Record = { @@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record = { }, }; +type ActionBooking = Pick< + Freight.IBooking, + "id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled" +>; + +/** Initiated instance still carrying no cargo/price (clearance-first flow). */ +function isBareInstance(booking: ActionBooking): boolean { + return ( + Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0) + ); +} + /** * Resolve the customer's next clearance/operation action for a booking, or * `null` when there's nothing for them to do at this stage. Pure + cheap so it @@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record = { * "under review" state when nothing is actually queried. */ export function getBookingNextAction( - booking: Pick, + booking: ActionBooking, ): BookingNextAction | null { + if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) { + // Customs (Path B): GL completes the booking — the customer can only view + // the finished clearance in the modal. + if (booking.customsClearingEnabled) { + return { + kind: "SCHEDULE_OPERATION", + label: "View clearance", + title: "Clearance complete", + }; + } + // Non-customs (Path A): straight to the booking form — cargo + shipment + // day + window check, the same page a one-time booking uses. + return { + kind: "BOOK", + label: "Book", + title: "Book your shipment", + to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`, + }; + } return ACTION_BY_STATUS[booking.status as string] ?? null; } @@ -58,9 +92,7 @@ export function getBookingNextAction( * booking that needs documents updated and resubmitting. Used to decide whether * to render {@link BookingActionButton}. */ -export function bookingHasInlineAction( - booking: Pick, -): boolean { +export function bookingHasInlineAction(booking: ActionBooking): boolean { return ( booking.status === "CHANGES_REQUESTED" || getBookingNextAction(booking) !== null diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts index b7a1e8c60..ab25284ba 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean { /** * The next upcoming (not-yet-open) window the customer should come back for — - * the one whose train dispatches soonest, so it lines up with the departure-date - * ordering of the cards. Returns `null` when nothing upcoming carries an opening - * time. (`windowOpensAt` is still required so the banner can name a come-back time.) + * the one that OPENS soonest from now. Two guards matter here: + * - only openings strictly in the future qualify. A train mid-cycle + * (doc-review/payment) still reports the window that already opened and + * closed; showing that past time as "next" told customers to come back for + * a window that was over. + * - ordered by opening time, not departure date — "next window" is the next + * moment booking opens, which may belong to a later-departing train. + * Returns `null` when nothing upcoming carries a future opening time. */ export function soonestUpcomingWindow( windows: MyBookingWindow[], ): MyBookingWindow | null { + const now = Date.now(); const upcoming = windows - .filter((w) => !w.isOpenNow && w.windowOpensAt) - .sort((a, b) => { - const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; - const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; - if (da !== db) return da - db; - return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(); - }); + .filter( + (w) => + !w.isOpenNow && + w.windowOpensAt && + new Date(w.windowOpensAt).getTime() > now, + ) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), + ); return upcoming[0] ?? null; } From 5d8658b5d613c1a7935b4b85a9e7f9c60d928c32 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 22:27:58 +0000 Subject: [PATCH 3/6] fix contrat,wagon allocation wehn a booking is split --- .../src/contracts/contract-article.util.ts | 60 +++++- .../contract-dynamic-template.spec.ts | 34 ++++ .../templates/_partials/dynamic_articles.hbs | 3 +- .../contracts/templates/_partials/styles.hbs | 19 +- .../ContractTemplateEditorPage.tsx | 186 ++++++++++++++++-- .../portal/src/pages/MyPortalPage/hooks.ts | 12 ++ .../components/StatusHero.tsx | 53 +++-- .../bookings/BookingDetailPage/constants.ts | 119 +++++++++++ .../bookings/BookingDetailPage/index.tsx | 24 ++- .../pages/bookings/BookingDetailPage/utils.ts | 21 ++ .../contracts/new-contract-form/schema.ts | 6 - .../new-contract-form/step3-cargo-scope.tsx | 17 -- 12 files changed, 475 insertions(+), 79 deletions(-) diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 5f0c86a6c..6aacab16c 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -3,6 +3,10 @@ import Handlebars from 'handlebars'; /** One numbered clause of a dynamic article, with optional nested bullets. */ export interface RenderedClause { text: string; + /** Computed outline number, e.g. "3" or "2.1.4". */ + number: string; + /** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */ + depth: number; bullets: string[]; } @@ -16,10 +20,26 @@ export interface RenderedArticle { } /** - * Parse a template article body into clauses. Format: one clause per line; - * lines prefixed with "- " become bullets nested under the preceding clause. - * A body that reduces to a single clause without bullets renders as a plain - * paragraph rather than a numbered list of one. + * Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." … + * The token's segment count sets the clause depth; its digits are ignored — + * numbering is recomputed sequentially so stale numbers self-heal. + * A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x" + * is clause ten); multi-segment tokens ("1.1") may omit it. A token may also + * end the line — that is an empty clause still being typed in the editor. + */ +const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/; + +/** Deepest supported sub-clause level (1.1.1.1.1.1). */ +const MAX_CLAUSE_DEPTH = 6; + +/** + * Parse a template article body into clauses. Format: one clause per line. + * A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a + * sub-clause at that depth — the typed digits are stripped and renumbered + * sequentially, so editing order never leaves stale numbers in the document. + * Lines prefixed with "- " become bullets nested under the preceding clause. + * A body that reduces to a single un-numbered clause without bullets renders + * as a plain paragraph rather than a numbered list of one. */ export function parseArticleBody(body: string): Pick { const lines = (body ?? '') @@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick line.length > 0); const clauses: RenderedClause[] = []; + // counters[i] = current number at depth i+1; truncated when a shallower + // clause arrives so deeper numbering restarts at 1. + const counters: number[] = []; + let sawNumberToken = false; + for (const line of lines) { if (line.startsWith('- ')) { const bullet = line.slice(2).trim(); if (clauses.length === 0) { - clauses.push({ text: bullet, bullets: [] }); + counters.splice(0, counters.length, 1); + clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] }); } else { clauses[clauses.length - 1].bullets.push(bullet); } - } else { - clauses.push({ text: line, bullets: [] }); + continue; } + + const match = CLAUSE_NUMBER_RE.exec(line); + const token = match ? (match[1] ?? match[2]) : null; + let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1; + // A sub-clause can only sit directly under an existing parent — "1.1.1" + // typed as the first line clamps to whatever level is actually open. + depth = Math.min(depth, counters.length + 1); + if (match) sawNumberToken = true; + + counters.splice(depth); + while (counters.length < depth) counters.push(0); + counters[depth - 1] += 1; + + clauses.push({ + text: match ? line.slice(match[0].length).trim() : line, + number: counters.slice(0, depth).join('.'), + depth, + bullets: [], + }); } - if (clauses.length === 1 && clauses[0].bullets.length === 0) { + if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) { return { paragraph: clauses[0].text, clauses: [] }; } return { clauses }; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 032030243..49fb3416a 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -26,6 +26,40 @@ describe('parseArticleBody', () => { expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.'); expect(parsed.clauses).toEqual([]); }); + + it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => { + const parsed = parseArticleBody( + '1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment', + ); + expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([ + ['1', 1, 'Scope'], + ['1.1', 2, 'Rail transport'], + ['1.1.1', 3, 'Wagon supply'], + ['2', 1, 'Payment'], + ]); + }); + + it('clamps a sub-clause with no open parent to the next available level', () => { + const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.'); + expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([ + ['1', 1], + ['2', 1], + ]); + }); + + it('leaves prose that merely starts with a number un-tokenized', () => { + const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.'); + expect(parsed.clauses.map((c) => c.text)).toEqual([ + '10 tons is the minimum load.', + 'Payment in advance.', + ]); + }); + + it('keeps a single explicitly numbered line as a clause, not a paragraph', () => { + const parsed = parseArticleBody('1. Only clause.'); + expect(parsed.paragraph).toBeUndefined(); + expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]); + }); }); describe('interpolateTemplateText', () => { diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index 717fbde8f..bec620655 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -6,7 +6,8 @@ {{else}}
    {{#each clauses}} -
  1. +
  2. + {{number}}. {{text}} {{#if bullets.length}}
      diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 0207ff9fb..118606065 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -282,28 +282,27 @@ .article-name { color: #0e5b45; } .article-paragraph { margin: 4px 0 0; } ol.clauses { - counter-reset: clause; list-style: none; margin: 6px 0 0; padding-left: 0; } - ol.clauses > li { - counter-increment: clause; + ol.clauses > li.clause { margin-bottom: 6px; - padding-left: 24px; - position: relative; text-align: justify; } - ol.clauses > li::before { + ol.clauses .clause-no { color: #0e5b45; - content: counter(clause) "."; font-family: Arial, sans-serif; font-size: 9.5pt; font-weight: 700; - left: 0; - position: absolute; - top: 1px; + margin-right: 6px; } + /* Sub-clause indentation: each outline level steps in. */ + ol.clauses > li.depth-2 { padding-left: 20px; } + ol.clauses > li.depth-3 { padding-left: 40px; } + ol.clauses > li.depth-4 { padding-left: 60px; } + ol.clauses > li.depth-5 { padding-left: 80px; } + ol.clauses > li.depth-6 { padding-left: 100px; } ul.clause-bullets { margin: 5px 0 2px; padding-left: 16px; diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 2d635fe58..6f63d68f2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -35,6 +35,7 @@ import { Hash, ListOrdered, ListPlus, + ListTree, Mail, MapPin, Package, @@ -60,7 +61,7 @@ import { import type { ContractTemplateArticle } from "@/services/contract-templates.service"; const BODY_HINT = - 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.'; + 'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.'; interface ArticleDraft { id?: string; @@ -253,6 +254,10 @@ function unknownTokens(text: string): string[] { interface ParsedClause { text: string; + /** Computed outline number, e.g. "3" or "2.1.4". */ + number: string; + /** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */ + depth: number; bullets: string[]; } @@ -262,28 +267,93 @@ interface ParsedBody { clauses: ParsedClause[]; } +/** + * Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its + * segment count sets the depth; the digits themselves are recomputed. Single + * segment requires "."/")" so prose like "10 tons…" is untouched; a token may + * end the line (empty clause still being typed). + */ +const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/; + +/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */ +function matchDepth(match: RegExpExecArray | null): number | null { + if (!match) return null; + const token = match[1] ?? match[2]; + return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH); +} + +/** Deepest supported sub-clause level. */ +const MAX_CLAUSE_DEPTH = 6; + /** * Mirror of the API renderer's rules (contract-article.util.ts): one clause per - * line, "- " nests a bullet under the previous clause, and a single bullet-less - * clause renders as a plain paragraph instead of a numbered list of one. + * line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause + * at that depth and is renumbered sequentially; "- " nests a bullet under the + * previous clause; a single un-numbered bullet-less clause renders as a plain + * paragraph instead of a numbered list of one. */ function parseArticleBody(body: string): ParsedBody { const clauses: ParsedClause[] = []; + const counters: number[] = []; + let sawNumberToken = false; for (const raw of body.split("\n")) { const line = raw.trim(); if (!line) continue; if (line.startsWith("- ") && clauses.length > 0) { clauses[clauses.length - 1].bullets.push(line.slice(2).trim()); - } else { - clauses.push({ text: line.replace(/^- /, ""), bullets: [] }); + continue; } + const cleaned = line.replace(/^- /, ""); + const match = CLAUSE_NUMBER_RE.exec(cleaned); + let depth = matchDepth(match) ?? 1; + // A sub-clause can only sit directly under an existing parent. + depth = Math.min(depth, counters.length + 1); + if (match) sawNumberToken = true; + counters.splice(depth); + while (counters.length < depth) counters.push(0); + counters[depth - 1] += 1; + clauses.push({ + text: match ? cleaned.slice(match[0].length).trim() : cleaned, + number: counters.slice(0, depth).join("."), + depth, + bullets: [], + }); } - if (clauses.length === 1 && clauses[0].bullets.length === 0) { + if ( + clauses.length === 1 && + clauses[0].bullets.length === 0 && + !sawNumberToken + ) { return { paragraph: clauses[0].text, clauses: [] }; } return { clauses }; } +/** + * Rewrite the leading outline tokens in a body so every numbered clause line + * carries its computed sequential number (stale numbers self-heal). Lines + * without a number token and bullet lines pass through untouched. + */ +function renumberBody(body: string): string { + const counters: number[] = []; + return body + .split("\n") + .map((raw) => { + const line = raw.trim(); + if (!line || line.startsWith("- ")) return raw; + const match = CLAUSE_NUMBER_RE.exec(line); + let depth = matchDepth(match) ?? 1; + depth = Math.min(depth, counters.length + 1); + counters.splice(depth); + while (counters.length < depth) counters.push(0); + counters[depth - 1] += 1; + if (!match) return raw; + const number = counters.slice(0, depth).join("."); + return `${number}. ${line.slice(match[0].length).trim()}`; + }) + .join("\n"); +} + /** Render clause text with {{placeholders}} highlighted as green chips. */ function HighlightedText({ text }: { text: string }) { const parts = text.split(/(\{\{[^{}]+\}\})/g); @@ -632,13 +702,57 @@ function ArticleEditorModal({ }); }; - const insertLinePrefix = (prefix: string) => { + /** + * Insert a structured line (clause / sub-clause / bullet) on a fresh line + * below the one the caret is on. Clause lines get their outline number typed + * in automatically ("3. ", "3.1. ", …) and every numbered line in the body is + * renumbered so the text always matches the preview. + */ + const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => { const el = bodyRef.current; - const start = el?.selectionStart ?? body.length; - // Start the snippet on its own line unless the caret already is. - const needsNewline = start > 0 && body[start - 1] !== "\n"; lastFocused.current = "body"; - insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`); + const caret = el?.selectionStart ?? body.length; + // Structured lines never split a sentence — insert after the caret's line. + const lineEnd = body.indexOf("\n", caret); + const insertAt = lineEnd === -1 ? body.length : lineEnd; + const before = body.slice(0, insertAt); + const after = body.slice(insertAt); // "" or starts with "\n" + + let prefix: string; + if (kind === "bullet") { + prefix = "- "; + } else { + // Sub-clause nests one level under the clause the caret is on/above; + // New clause always starts a fresh top-level number. + const above = parseArticleBody(before); + const lastDepth = above.paragraph + ? 1 + : (above.clauses[above.clauses.length - 1]?.depth ?? 0); + const depth = + kind === "sub" ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH) : 1; + // Digits are placeholders — renumberBody assigns the real value. + prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `; + } + + const beforeLines = before.length > 0 ? before.split("\n") : []; + const afterLines = + after.length > 0 ? after.slice(1).split("\n") : []; + const insertedIdx = beforeLines.length; + const joined = [...beforeLines, prefix, ...afterLines].join("\n"); + const next = kind === "bullet" ? joined : renumberBody(joined); + setBody(next); + + // Caret lands at the end of the inserted line, ready for typing. + const caretTarget = next + .split("\n") + .slice(0, insertedIdx + 1) + .join("\n").length; + requestAnimationFrame(() => { + const field = bodyRef.current; + if (!field) return; + field.focus(); + field.setSelectionRange(caretTarget, caretTarget); + }); }; const parsed = useMemo(() => parseArticleBody(body), [body]); @@ -724,26 +838,60 @@ function ArticleEditorModal({ ))} - + + + + + + Add structure + + + - + + + + @@ -804,10 +952,10 @@ function ArticleEditorModal({ )} {parsed.clauses.map((clause, i) => ( - + - {i + 1}.{" "} + {clause.number}.{" "} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 5e051cce9..eaec70ed3 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -3,6 +3,7 @@ import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; +import { isBookingLive } from "@/pages/bookings/BookingDetailPage/utils"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { @@ -24,6 +25,17 @@ export function useMyPortalData(selectedProfileId?: string) { sortOrder: "DESC", companyProfileId: selectedProfileId, }, + // The home tiles read each booking's status directly, but staff/system + // transitions (operations accepting an order, batch selection, clearance + // review) never push here. Poll while any booking is still live so those + // changes surface — e.g. an accepted order leaving "Operation request + // under review" — and stop once everything has settled. + refetchInterval: (query) => { + const items = + (query.state.data as { items?: Freight.IBooking[] } | undefined) + ?.items ?? []; + return items.some((b) => isBookingLive(b.status)) ? 30_000 : false; + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index ba8ca3db7..16d412703 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -3,7 +3,15 @@ import { Check, MoveRight } from "lucide-react"; import type { Freight } from "@edr/types"; -import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants"; +import { + ARRIVAL_STAGE, + CONTRACT_ARRIVAL_STAGE, + CONTRACT_PROGRESS_STAGES, + PROGRESS_STAGES, + STATUS_MAP, + resolveContractStage, + resolveStage, +} from "../constants"; import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -65,7 +73,19 @@ export function StatusHero({ children?: React.ReactNode; }) { const status = booking.status; - const stage = resolveStage(booking); + // Contract-drawdown bookings (initiated under a contract) follow a dedicated + // wizard — initiated → submitted → accepted → payment → … — instead of the + // direct booking's Request/Approval/Contract stages. + const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status); + const stages = isContractDrawdown + ? CONTRACT_PROGRESS_STAGES + : PROGRESS_STAGES; + const arrivalStage = isContractDrawdown + ? CONTRACT_ARRIVAL_STAGE + : ARRIVAL_STAGE; + const stage = isContractDrawdown + ? resolveContractStage(booking) + : resolveStage(booking); // Contract-drawdown instance in the clearance gate: it was INITIATED with one // click (no cargo/date yet), not submitted through the wizard. const isInitiatedInstance = @@ -75,7 +95,7 @@ export function StatusHero({ // headline is overridden here. Bookings with a per-booking journey carry the // ARRIVED status themselves and use its own STATUS_MAP copy. const cfg = - stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE + stage === arrivalStage && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE ? { title: "Train arrived at destination", description: @@ -89,7 +109,14 @@ export function StatusHero({ "Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.", stage, } - : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); + : isContractDrawdown && status === "FULLY_EXECUTED" + ? { + title: "Accepted by operations", + description: + "Operations accepted your order. Complete payment once your train is selected to secure the slot.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -132,13 +159,9 @@ export function StatusHero({ {children ?? ( )} @@ -147,16 +170,16 @@ export function StatusHero({ function ProgressTracker({ current, + stages = PROGRESS_STAGES, tone = "green", - labelOverrides, }: { current: number; + /** Which stage set to render — direct or contract-drawdown. */ + stages?: typeof PROGRESS_STAGES; tone?: "green" | "ink"; negative?: boolean; - /** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */ - labelOverrides?: Record; }) { - const last = PROGRESS_STAGES.length - 1; + const last = stages.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; @@ -172,7 +195,7 @@ function ProgressTracker({ } >
      - {PROGRESS_STAGES.map((stage, idx) => { + {stages.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; const Icon = stage.icon; @@ -246,7 +269,7 @@ function ProgressTracker({ ta="center" c={state === "idle" ? "#9AA8B5" : "#10202F"} > - {labelOverrides?.[idx] ?? stage.label} + {stage.label}
      ); 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 e4fac9ca7..ae3d20e4e 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 @@ -95,6 +95,125 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( (s) => s.label === "Arrival", ); +/** + * Progress stages for a CONTRACT-DRAWDOWN booking (created under a contract via + * initiate → clearance → book). These bookings never pass through the direct + * wizard's Request/Approval/Contract stages — the contract is already executed. + * Their journey is: initiated (bare instance in clearance) → submitted (booking + * completed, sent to operations) → accepted (operations accepted) → payment → + * loading → transit → arrival → unloading → complete. + */ +export const CONTRACT_PROGRESS_STAGES = [ + { + // The instance was initiated with one click and is going through per-booking + // clearance (upload → review → ready). One-time drawdowns without clearance + // start here too until they are booked. + label: "Initiated", + icon: FileText, + statuses: [ + "DRAFT", + "CHANGES_REQUESTED", + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + ], + }, + { + // The customer (or GL) completed the booking — cargo + shipment day — and it + // is submitted to operations for acceptance. + label: "Submitted", + icon: ClipboardCheck, + statuses: [ + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", + "OPERATION_REQUESTED", + ], + }, + { + // Operations accepted the order — it now sits in the batch holding pool + // awaiting a train and its pay window. + label: "Accepted", + icon: ShieldCheck, + statuses: ["FULLY_EXECUTED", "READY_FOR_ASSIGNMENT"], + }, + { + label: "Payment", + icon: ShieldCheck, + statuses: [ + "SELECTED_FOR_BATCH", + "PAYMENT_VERIFICATION_IN_PROGRESS", + "EXPIRED", + "PRICE_CHANGED_PENDING_CONFIRM", + ], + }, + { + label: "Loading", + icon: Ship, + statuses: [ + "PAID", + "PNR_GENERATED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", + "WAGON_ASSIGNED", + "INVOICED", + "ROAD_DISPATCH_PENDING", + ], + }, + { + label: "In Transit", + icon: Train, + statuses: ["IN_TRANSIT"], + }, + { + // Lights up from the assigned train's ARRIVED state while the booking is + // still IN_TRANSIT — no status of its own (see resolveContractStage). + label: "Arrival", + icon: MapPin, + statuses: [], + }, + { + label: "Unloading", + icon: PackageOpen, + statuses: ["ARRIVED"], + }, + { + label: "Complete", + icon: PackageCheck, + statuses: ["COMPLETED", "DELIVERED"], + }, +]; + +/** Contract-drawdown Arrival stage index. */ +export const CONTRACT_ARRIVAL_STAGE = CONTRACT_PROGRESS_STAGES.findIndex( + (s) => s.label === "Arrival", +); + +/** status → contract-drawdown stage index, derived from the stage array. */ +const CONTRACT_STAGE_BY_STATUS: Record = {}; +CONTRACT_PROGRESS_STAGES.forEach((stage, index) => { + stage.statuses.forEach((status) => { + CONTRACT_STAGE_BY_STATUS[status] = index; + }); +}); + +/** + * Contract-drawdown stage for a booking, factoring in the assigned train's + * status the same way {@link resolveStage} does for direct bookings. + */ +export function resolveContractStage(booking: { + status: string; + trainScheduleStatus?: string | null; +}): number { + if ( + booking.status === "IN_TRANSIT" && + booking.trainScheduleStatus === "ARRIVED" + ) { + return CONTRACT_ARRIVAL_STAGE; + } + return CONTRACT_STAGE_BY_STATUS[booking.status] ?? 0; +} + /** * Stage for a booking, factoring in the assigned train's operational status: * a booking with per-booking journey data reaches ARRIVED (the Unloading diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx index a06a89ca0..9cb059503 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -2,6 +2,7 @@ import { Box, Center, Loader, Stack, Text } from "@mantine/core"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle } from "lucide-react"; import { useParams } from "react-router-dom"; +import type { Freight } from "@edr/types"; import { api } from "@/services/api"; @@ -9,7 +10,7 @@ import { ChangesRequestedView } from "./ChangesRequestedView"; import { DraftBookingView } from "./DraftBookingView"; import { PageShell, SectionCard } from "./components/layout"; import { ReadonlyBookingView } from "./ReadonlyBookingView"; -import { isDraftLike } from "./utils"; +import { isBookingLive, isDraftLike } from "./utils"; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); @@ -21,7 +22,22 @@ export default function BookingDetailPage() { isError, error, } = useQuery( - api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }), + api.bookings.get.queryOptions({ + input: { id: id! }, + enabled: !!id, + // Staff/system transitions (operations accepting an order, batch + // selection, clearance review, transit) happen without any customer + // action and can't push to this open page. Poll while the booking is + // still live so those changes surface — e.g. an accepted operation + // request leaving the "under review" state — and stop once it settles. + refetchInterval: (query) => + isBookingLive( + (query.state.data as Freight.IBooking | undefined)?.status, + ) + ? 20_000 + : false, + refetchOnWindowFocus: true, + }), ); const refetchBooking = () => { @@ -94,5 +110,7 @@ export default function BookingDetailPage() { ); } - return ; + return ( + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 031a2d38c..432dcf8f5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -6,6 +6,27 @@ export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; export const isDraftLike = (s: string) => s === "DRAFT" || s === "CHANGES_REQUESTED"; +/** + * Terminal booking statuses — nothing changes server-side once a booking lands + * here, so the customer view has no reason to keep polling. + */ +const SETTLED_STATUSES = new Set([ + "COMPLETED", + "DELIVERED", + "CANCELLED", + "REJECTED", +]); + +/** + * True while a booking can still change from a staff/system action the customer + * did not trigger (operations accepting an order, batch selection, clearance + * review, transit progress). Used to poll the customer-facing booking queries so + * those transitions surface without a manual reload — e.g. an accepted operation + * request flipping out of "under review". + */ +export const isBookingLive = (s?: string | null) => + !!s && !SETTLED_STATUSES.has(s); + export function fmtDate(value?: string | null) { if (!value) return "—"; const d = new Date(value); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 6ffa29b8c..4a57c120f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -254,12 +254,6 @@ export const contractFormSchema = z path: ["cargoTypePath"], message: "Select a commodity.", }); - } else if (!data.cargoFreeText?.trim()) { - ctx.addIssue({ - code: "custom", - path: ["cargoFreeText"], - message: "Describe the cargo.", - }); } } // GENERAL contracts are uncapped: no quantity cap is collected, so the diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 51b9bcc93..26b455258 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -10,7 +10,6 @@ import { Stack, Switch, Text, - TextInput, } from "@mantine/core"; import type { Freight } from "@edr/types"; import { @@ -202,22 +201,6 @@ export function Step3CargoScope({ /> )} - {parentId && commodityOptions.length > 0 && ( - ( - - )} - /> - )} )} From dd87c2a7223b2656328248c9494176ac142ac52f Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:29:28 +0000 Subject: [PATCH 4/6] wagon work space, container validation --- .../contracts/contract-booking.service.ts | 144 ++++++ .../wagons/dto/bulk-set-wagon-status.dto.ts | 12 + .../wagons/dto/bulk-transfer-wagons.dto.ts | 11 + .../src/modules/wagons/wagons.controller.ts | 18 + .../src/modules/wagons/wagons.service.ts | 100 +++- .../wagons/WagonYardWorkspaceModal.tsx | 441 ++++++++++++++++++ .../bookings/DocumentClearanceDetailPage.tsx | 4 + .../ContractTemplateEditorPage.tsx | 27 +- .../pages/contracts/GlClearanceDetailPage.tsx | 57 ++- .../src/pages/fleet/FleetResourcePage.tsx | 81 ++-- .../backoffice/src/services/api.ts | 24 + .../backoffice/src/services/wagon.service.ts | 6 + 12 files changed, 878 insertions(+), 47 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx 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 a0da43784..c09f2ccb0 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 @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Inject, Injectable, @@ -192,6 +193,11 @@ export class ContractBookingService { // their only chance to hard-block an unbalanceable set. Entry order is // irrelevant (the check sorts by weight before pairing). await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable(dto, { + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + }); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -575,6 +581,15 @@ export class ContractBookingService { if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable( + dto, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); await this.persistContainers(booking.id, contract, dto); } await this.bookingsRepository.update(booking.id, { @@ -653,6 +668,10 @@ export class ContractBookingService { Boolean(contract.customsClearingEnabled); await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); + } else if (freightType === 'CONTAINER') { + // Resubmit only re-picks the shipment day — the persisted container + // numbers must be free on the newly chosen train day too. + await this.assertPersistedContainersAvailable(booking, dto.scheduledDate); } // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING @@ -1344,6 +1363,131 @@ export class ContractBookingService { * balanced onto wagons (pair diff over the global cap). Same rule the * shipment-form preview reports as `pairingErrors`, enforced server-side. */ + /** + * A physical container rides one train only. Reject the submission when a + * container number is entered twice in the same booking (the portal checks + * this client-side, the API must not trust it) or already sits on another + * customer's active booking for the same train — same shipment day AND same + * route (origin/destination yards). + */ + private async assertContainerNumbersAvailable( + dto: CreateBookingUnderContractDto, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const numbers = (dto.containers ?? []).flatMap((line) => + (line.units ?? []) + .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) + .filter((n) => n.length > 0), + ); + if (!numbers.length) return; + + const seen = new Set(); + const withinBooking = new Set(); + for (const n of numbers) { + if (seen.has(n)) withinBooking.add(n); + seen.add(n); + } + if (withinBooking.size) { + throw new BadRequestException( + `Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`, + ); + } + + // Intercity bookings have no shipment day yet — nothing to clash with. + if (!dto.scheduledDate) return; + + await this.assertNumbersFreeOnTrain( + numbers, + dto.scheduledDate, + route, + excludeBookingId, + ); + } + + /** + * Same train guard for a booking whose containers are already persisted + * (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its + * stored numbers must be free on the newly chosen day for its route. + */ + private async assertPersistedContainersAvailable( + booking: Booking, + scheduledDate: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .select('unit.container_number', 'containerNumber') + .where('line.booking_id = :bookingId', { bookingId: booking.id }) + .getRawMany(); + const numbers = rows.map((r) => r.containerNumber).filter(Boolean); + if (!numbers.length) return; + await this.assertNumbersFreeOnTrain( + numbers, + scheduledDate, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); + } + + /** + * Reject when any of `numbers` sits on another active booking of the same + * train — same day and same route. Bookings without route yards (legacy + * rows) are matched on the day alone rather than let through. + */ + private async assertNumbersFreeOnTrain( + numbers: string[], + scheduledDate: string, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const qb = this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .innerJoin(Booking, 'b', 'b.id = line.booking_id') + .select('unit.container_number', 'containerNumber') + .addSelect('b.reference', 'reference') + .where('unit.container_number IN (:...numbers)', { numbers }) + .andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .andWhere('b.deleted_at IS NULL'); + if (route.originYardId && route.destinationYardId) { + // Same train = same day + same corridor. A clashing booking whose yards + // were never denormalized still blocks (NULL yards match any route). + qb.andWhere( + '(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)', + { originYardId: route.originYardId }, + ).andWhere( + '(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)', + { destinationYardId: route.destinationYardId }, + ); + } + if (excludeBookingId) { + qb.andWhere('b.id != :excludeBookingId', { excludeBookingId }); + } + const clashes: Array<{ containerNumber: string; reference: string }> = + await qb.getRawMany(); + + if (clashes.length) { + const detail = [ + ...new Map(clashes.map((c) => [c.containerNumber, c])).values(), + ] + .map((c) => `${c.containerNumber} (booking ${c.reference})`) + .join(', '); + throw new ConflictException( + `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + + 'A container can only be on one booking per train — remove it or pick another shipment day.', + ); + } + } + private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts new file mode 100644 index 000000000..6f28418aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts @@ -0,0 +1,12 @@ +import { WagonStatus } from '@edr/types'; +import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class BulkSetWagonStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsEnum(WagonStatus) + status!: WagonStatus; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts new file mode 100644 index 000000000..bf1f7f783 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts @@ -0,0 +1,11 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class BulkTransferWagonsDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsUUID() + toYardId!: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 1d5287dba..556907cde 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -10,12 +10,16 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; +import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @ApiTags('wagons') @@ -78,6 +82,20 @@ export class WagonsController { unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); } + + @Post('bulk-transfer') + @FleetManage() + @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) + bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.bulkTransfer(dto, user?.id); + } + + @Post('bulk-status') + @FleetManage() + @ApiOperation({ summary: 'Set the status of multiple wagons' }) + bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { + return this.wagonsService.bulkSetStatus(dto); + } } // Separate controller for train‑specific reorder (registered in module) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index b010c0351..ad39fbf7a 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,15 +1,18 @@ import { WagonMovementKind, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; +import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; +import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; @Injectable() export class WagonsService { @@ -168,6 +171,101 @@ export class WagonsService { return this.wagonRepo.save(wagon); } + /** + * Relocate many wagons to one destination yard in a single transaction. Each + * wagon whose yard actually changes gets a `wagon_movements` ledger row (kind + * `Manual`) so the yard history stays auditable — mirrors the single-wagon + * `update` path. Wagons already in the destination yard are skipped. + */ + async bulkTransfer( + dto: BulkTransferWagonsDto, + userId?: string | null, + ): Promise<{ moved: number }> { + const { wagonIds, toYardId } = dto; + if (!wagonIds.length) return { moved: 0 }; + + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: toYardId } }); + if (!yard) throw new NotFoundException('Destination yard not found'); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + let moved = 0; + for (const wagon of wagons) { + const previousYardId = wagon.currentYardId ?? null; + if (previousYardId === toYardId) continue; + wagon.currentYardId = toYardId; + // Drop the eager relation so the scalar FK wins on save (see `update`). + wagon.currentYard = null; + await queryRunner.manager.save(Wagon, wagon); + await queryRunner.manager.save( + queryRunner.manager.create(WagonMovement, { + wagonId: wagon.id, + fromYardId: previousYardId, + toYardId, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + moved++; + } + + await queryRunner.commitTransaction(); + return { moved }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * Set the same status on many wagons in one transaction (e.g. flip a batch + * from Available to Assigned in the yard workspace). Only the `status` column + * is touched — train assignment is managed through the assign/unassign flow. + */ + async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> { + const { wagonIds, status } = dto; + if (!wagonIds.length) return { updated: 0 }; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + for (const wagon of wagons) { + wagon.status = status; + } + await queryRunner.manager.save(Wagon, wagons); + + await queryRunner.commitTransaction(); + return { updated: wagons.length }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx new file mode 100644 index 000000000..ef9621376 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -0,0 +1,441 @@ +import { Freight } from "@edr/types"; +import { + Badge, + Box, + Button, + Card, + Divider, + Grid, + Group, + Loader, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { Wagon } from "@/services/wagon.service"; + +export interface WagonYardWorkspaceModalProps { + opened: boolean; + onClose: () => void; +} + +const numberOrZero = (v: number | string): number => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +}; + +/** + * Yard workspace: pick a yard + wagon type (the two selects filter each other + * to only in-inventory combinations), see how many wagons of that type sit in + * that yard and how they split Available / Assigned, then bulk-transfer a + * quantity to another yard or flip a quantity between Available and Assigned. + */ +const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { + const { toast } = useToast(); + + const { data: wagons = [], isLoading: wagonsLoading } = useQuery( + api.wagons.list.queryOptions({ input: {} }), + ); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); + const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); + + const [yardId, setYardId] = useState(null); + const [typeId, setTypeId] = useState(null); + + const [transferYardId, setTransferYardId] = useState(null); + const [transferQty, setTransferQty] = useState(1); + const [toAssignedQty, setToAssignedQty] = useState(1); + const [toAvailableQty, setToAvailableQty] = useState(1); + + const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); + const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); + + const yardLabel = useMemo(() => { + const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + return (id: string) => byId.get(id) ?? id; + }, [yards]); + + const typeLabel = useMemo(() => { + const byId = new Map( + wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]), + ); + return (id: string) => byId.get(id) ?? id; + }, [wagonTypes]); + + // Only wagons that currently sit in a yard participate in the workspace. + const yardWagons = useMemo( + () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), + [wagons], + ); + + // Each select is constrained by the other's current value so only real + // (yard, type) combinations that hold stock can be picked. + const yardOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (typeId && w.wagonTypeId !== typeId) continue; + ids.add(w.currentYardId); + } + return [...ids] + .map((id) => ({ value: id, label: yardLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, typeId, yardLabel]); + + const typeOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (yardId && w.currentYardId !== yardId) continue; + ids.add(w.wagonTypeId); + } + return [...ids] + .map((id) => ({ value: id, label: typeLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, yardId, typeLabel]); + + const matching = useMemo(() => { + if (!yardId || !typeId) return [] as Wagon[]; + return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId); + }, [yardWagons, yardId, typeId]); + + const availableWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Available), + [matching], + ); + const assignedWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), + [matching], + ); + // Available first so a partial transfer moves idle wagons before assigned ones. + const transferPool = useMemo(() => { + const rest = matching.filter( + (w) => + w.status !== Freight.WagonStatus.Available && + w.status !== Freight.WagonStatus.Assigned, + ); + return [...availableWagons, ...assignedWagons, ...rest]; + }, [matching, availableWagons, assignedWagons]); + + const total = matching.length; + const availableCount = availableWagons.length; + const assignedCount = assignedWagons.length; + + const destinationYardOptions = useMemo( + () => + yards + .filter((y) => y.id !== yardId) + .map((y) => ({ value: y.id, label: y.label || y.code || y.id })) + .sort((a, b) => a.label.localeCompare(b.label)), + [yards, yardId], + ); + + const bothSelected = Boolean(yardId && typeId); + + // Reset the action inputs whenever the yard/type selection changes. + useEffect(() => { + setTransferYardId(null); + setTransferQty(1); + setToAssignedQty(1); + setToAvailableQty(1); + }, [yardId, typeId]); + + // Reset the whole workspace when it is reopened. + useEffect(() => { + if (!opened) { + setYardId(null); + setTypeId(null); + } + }, [opened]); + + const showError = (err: unknown, fallback: string) => { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; + toast({ title: fallback, description: String(message), variant: "destructive" }); + }; + + const handleTransfer = async () => { + const n = numberOrZero(transferQty); + if (!transferYardId || n < 1) return; + const ids = transferPool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId }); + toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` }); + setTransferQty(1); + setTransferYardId(null); + } catch (err) { + showError(err, "Transfer failed"); + } + }; + + const handleFlip = async ( + pool: Wagon[], + qty: number | string, + status: Freight.WagonStatus, + label: string, + reset: () => void, + ) => { + const n = numberOrZero(qty); + if (n < 1) return; + const ids = pool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await setStatus.mutateAsync({ wagonIds: ids, status }); + toast({ title: `${res.updated} wagon(s) set to ${label}` }); + reset(); + } catch (err) { + showError(err, "Status update failed"); + } + }; + + const busy = transfer.isPending || setStatus.isPending; + + return ( + + + + +
      + Wagon Yard Workspace + + Move and re-status wagons by yard and type + +
      +
      + } + > + + {/* ---- Selectors ---- */} + + + + + + + {wagonsLoading ? ( + + + + ) : !bothSelected ? ( + + + Select a yard and a wagon type to see how many wagons are there and act on them. + + + ) : ( + <> + {/* ---- Counts ---- */} + + + + + + + + + + {/* ---- Transfer ---- */} + + + + + + + Transfer to another yard + + + + { + setListFilterValues((prev) => ({ + ...prev, + [filter.key]: value ?? "ALL", + })); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + size="sm" + radius="lg" + w={200} + searchable={filter.data.length > 8} + comboboxProps={{ withinPortal: true }} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> ))} ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( @@ -586,6 +598,13 @@ const FleetResourcePage = () => { + {slug === "wagons" ? ( + setWagonWorkspaceOpen(false)} + /> + ) : null} + {slug === "wagons" ? ( [["wagons"]], ), + + bulkTransfer: endpoint< + { wagonIds: string[]; toYardId: string }, + { moved: number } + >( + "wagons", + "bulkTransfer", + ({ wagonIds, toYardId }) => + wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + bulkSetStatus: endpoint< + { wagonIds: string[]; status: Wagon["status"] }, + { updated: number } + >( + "wagons", + "bulkSetStatus", + ({ wagonIds, status }) => + wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data), + undefined, + () => [["wagons"]], + ), }, trains: { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index e30320988..e096a3126 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -83,4 +83,10 @@ export const wagonService = { create: (data: Partial) => apiClient.post('/wagons', data), update: (id: string, data: Partial) => apiClient.patch(`/wagons/${id}`, data), delete: (id: string) => apiClient.delete(`/wagons/${id}`), + /** Relocate many wagons to one yard in a single call (writes movement ledger). */ + bulkTransfer: (wagonIds: string[], toYardId: string) => + apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }), + /** Set the same status on many wagons in a single call. */ + bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) => + apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }), }; From 3f03581d8c9f88be03b677fd27454333f4eda8f2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:50:35 +0000 Subject: [PATCH 5/6] wagon work space, container validation --- .../wagons/WagonYardWorkspaceModal.tsx | 546 +++++++++++------- .../src/features/clearance/requestedCargo.tsx | 89 +++ .../bookings/DocumentClearanceDetailPage.tsx | 38 +- .../contracts/ContractClearanceListPage.tsx | 39 +- 4 files changed, 497 insertions(+), 215 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/requestedCargo.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index ef9621376..67506caed 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -10,15 +10,16 @@ import { Loader, Modal, NumberInput, + Progress, Select, - SimpleGrid, + Slider, Stack, + Switch, Text, ThemeIcon, - Title, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; @@ -30,23 +31,93 @@ export interface WagonYardWorkspaceModalProps { onClose: () => void; } -const numberOrZero = (v: number | string): number => { +const AVAILABLE = Freight.WagonStatus.Available; +const ASSIGNED = Freight.WagonStatus.Assigned; + +const clampInt = (v: number | string, max: number): number => { const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(Math.floor(n), max); }; +/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */ +const QuantityField = ({ + value, + onChange, + max, + disabled, +}: { + value: number; + onChange: (n: number) => void; + max: number; + disabled?: boolean; +}) => { + const set = (v: number | string) => onChange(clampInt(v, max)); + const off = disabled || max === 0; + return ( + + + + `${v}`} + color="edr-green" + /> + + + + + {value > 0 ? ( + + ) : null} + + + ); +}; + +const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => ( + + + + {label} + + + {value} + + +); + /** - * Yard workspace: pick a yard + wagon type (the two selects filter each other - * to only in-inventory combinations), see how many wagons of that type sit in - * that yard and how they split Available / Assigned, then bulk-transfer a - * quantity to another yard or flip a quantity between Available and Assigned. + * Bulk yard operations. Pick a yard + wagon type (the two selects filter each + * other to combinations that actually hold stock), read the live Available / + * Assigned split, then move a quantity to another yard or flip a quantity + * between Available and Assigned — replacing one-wagon-at-a-time edits. */ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { const { toast } = useToast(); - const { data: wagons = [], isLoading: wagonsLoading } = useQuery( - api.wagons.list.queryOptions({ input: {} }), - ); + const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} })); const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); @@ -54,33 +125,35 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const [typeId, setTypeId] = useState(null); const [transferYardId, setTransferYardId] = useState(null); - const [transferQty, setTransferQty] = useState(1); - const [toAssignedQty, setToAssignedQty] = useState(1); - const [toAvailableQty, setToAvailableQty] = useState(1); + const [transferQty, setTransferQty] = useState(0); + const [freeAfterMove, setFreeAfterMove] = useState(false); + const [toAssignedQty, setToAssignedQty] = useState(0); + const [toAvailableQty, setToAvailableQty] = useState(0); const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); - const yardLabel = useMemo(() => { + const yardName = useMemo(() => { const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); return (id: string) => byId.get(id) ?? id; }, [yards]); - const typeLabel = useMemo(() => { - const byId = new Map( - wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]), - ); - return (id: string) => byId.get(id) ?? id; + const typeInfo = useMemo(() => { + const byId = new Map(wagonTypes.map((t) => [t.id, t])); + return { + label: (id: string) => { + const t = byId.get(id); + return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id; + }, + code: (id: string) => byId.get(id)?.code ?? id, + }; }, [wagonTypes]); - // Only wagons that currently sit in a yard participate in the workspace. const yardWagons = useMemo( () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), [wagons], ); - // Each select is constrained by the other's current value so only real - // (yard, type) combinations that hold stock can be picked. const yardOptions = useMemo(() => { const ids = new Set(); for (const w of yardWagons) { @@ -88,9 +161,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.currentYardId); } return [...ids] - .map((id) => ({ value: id, label: yardLabel(id) })) + .map((id) => ({ value: id, label: yardName(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, typeId, yardLabel]); + }, [yardWagons, typeId, yardName]); const typeOptions = useMemo(() => { const ids = new Set(); @@ -99,36 +172,32 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.wagonTypeId); } return [...ids] - .map((id) => ({ value: id, label: typeLabel(id) })) + .map((id) => ({ value: id, label: typeInfo.label(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, yardId, typeLabel]); + }, [yardWagons, yardId, typeInfo]); const matching = useMemo(() => { if (!yardId || !typeId) return [] as Wagon[]; return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId); }, [yardWagons, yardId, typeId]); - const availableWagons = useMemo( - () => matching.filter((w) => w.status === Freight.WagonStatus.Available), + const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]); + const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]); + const otherWagons = useMemo( + () => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED), [matching], ); - const assignedWagons = useMemo( - () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), - [matching], + // Available first, then assigned, then the rest — a partial move relocates + // idle wagons before touching assigned ones. + const transferPool = useMemo( + () => [...availableWagons, ...assignedWagons, ...otherWagons], + [availableWagons, assignedWagons, otherWagons], ); - // Available first so a partial transfer moves idle wagons before assigned ones. - const transferPool = useMemo(() => { - const rest = matching.filter( - (w) => - w.status !== Freight.WagonStatus.Available && - w.status !== Freight.WagonStatus.Assigned, - ); - return [...availableWagons, ...assignedWagons, ...rest]; - }, [matching, availableWagons, assignedWagons]); const total = matching.length; const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; + const otherCount = otherWagons.length; const destinationYardOptions = useMemo( () => @@ -141,15 +210,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const bothSelected = Boolean(yardId && typeId); - // Reset the action inputs whenever the yard/type selection changes. + // Reset action inputs when the selection changes. useEffect(() => { setTransferYardId(null); - setTransferQty(1); - setToAssignedQty(1); - setToAvailableQty(1); + setTransferQty(0); + setFreeAfterMove(false); + setToAssignedQty(0); + setToAvailableQty(0); }, [yardId, typeId]); - // Reset the whole workspace when it is reopened. + // Reset the whole workspace when closed. useEffect(() => { if (!opened) { setYardId(null); @@ -157,6 +227,11 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro } }, [opened]); + // Keep quantities within bounds as counts shift after each action. + useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]); + useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]); + useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]); + const showError = (err: unknown, fallback: string) => { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; @@ -164,15 +239,22 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const handleTransfer = async () => { - const n = numberOrZero(transferQty); - if (!transferYardId || n < 1) return; - const ids = transferPool.slice(0, n).map((w) => w.id); + if (!transferYardId || transferQty < 1) return; + const ids = transferPool.slice(0, transferQty).map((w) => w.id); if (!ids.length) return; try { const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId }); - toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` }); - setTransferQty(1); + if (freeAfterMove) { + await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE }); + } + toast({ + title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${ + freeAfterMove ? " · set Available" : "" + }`, + }); + setTransferQty(0); setTransferYardId(null); + setFreeAfterMove(false); } catch (err) { showError(err, "Transfer failed"); } @@ -180,14 +262,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const handleFlip = async ( pool: Wagon[], - qty: number | string, + qty: number, status: Freight.WagonStatus, label: string, reset: () => void, ) => { - const n = numberOrZero(qty); - if (n < 1) return; - const ids = pool.slice(0, n).map((w) => w.id); + if (qty < 1) return; + const ids = pool.slice(0, qty).map((w) => w.id); if (!ids.length) return; try { const res = await setStatus.mutateAsync({ wagonIds: ids, status }); @@ -199,100 +280,141 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const busy = transfer.isPending || setStatus.isPending; + const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0); return (
      - Wagon Yard Workspace + Wagon Yard Operations - Move and re-status wagons by yard and type + Move and re-status wagons in bulk — no one-by-one edits
      } > - {/* ---- Selectors ---- */} - - - - - + {/* ---- Selection ---- */} + + + + } + nothingFoundMessage="No wagon types here" + radius="md" + /> + + + - {wagonsLoading ? ( + {isLoading ? ( ) : !bothSelected ? ( - - - Select a yard and a wagon type to see how many wagons are there and act on them. - + + + + + + Pick a yard and a wagon type + + You'll see how many wagons of that type sit in that yard, how many are available + vs assigned, and can move or re-status them all at once. + + ) : ( <> - {/* ---- Counts ---- */} - - - - - + {/* ---- Overview hero ---- */} + + + +
      + + {total} + +
      +
      + + {typeInfo.code(typeId!)} wagons + + + + {yardName(yardId!)} + +
      +
      + + + + {otherCount > 0 ? : null} + +
      - + + + {availableCount > 0 ? {availableCount} : null} + + + {assignedCount > 0 ? {assignedCount} : null} + + + {otherCount > 0 ? {otherCount} : null} + + +
      - - {/* ---- Transfer ---- */} + {/* ---- Actions ---- */} + + {/* Transfer */} - - + + - Transfer to another yard + Move to another yard - - + +
      + + How many wagons + + +