From 97cc9d76b141c58e3bd4cfb5716535a1e05fbb8f Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 00:41:28 +0000 Subject: [PATCH] enhance booking windows section with pagination and improved UI --- .../bookings/booking-pricing.service.ts | 19 +- .../bookings/booking-transition.service.ts | 87 ++++- .../modules/bookings/bookings.repository.ts | 22 +- .../contracts/contract-booking.service.ts | 120 +++--- .../modules/contracts/contracts.controller.ts | 2 +- .../train-scheduling.controller.ts | 10 + .../train-scheduling.service.ts | 44 +++ .../contracts/GlCreateBookingForm.tsx | 135 ++++++- .../contracts/GlUpcomingWindowsSection.tsx | 352 +++++++++++------- .../backoffice/src/constants/URLS.ts | 2 + .../features/bookings/mapBookingListRow.ts | 1 + .../src/hooks/bookings/useBookings.ts | 8 +- .../pages/bookings/BookingRequestsPage.tsx | 21 +- .../backoffice/src/services/api.ts | 8 + .../src/services/contracts.service.ts | 45 +++ .../src/services/trainScheduling.service.ts | 8 + .../backoffice/src/types/booking.ts | 4 + .../backoffice/src/types/trainScheduling.ts | 21 ++ .../components/UpcomingWindowsSection.tsx | 106 ++++-- .../src/pages/contracts/NewShipmentPage.tsx | 50 ++- .../portal/src/services/contracts.service.ts | 25 +- 21 files changed, 805 insertions(+), 285 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e8469e627..d63106c2b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -431,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -571,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { 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 8423e9606..35fc7fd54 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 @@ -1051,7 +1051,22 @@ export class BookingTransitionService { // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.acceptExportBooking(fresh); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the // batch runs after the window closes + staff document review, never at accept @@ -1073,23 +1088,59 @@ export class BookingTransitionService { } | null; } > { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - "CHANGES_REQUESTED", - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); - const activeBatchOffer = - booking.status === "SELECTED_FOR_BATCH" - ? await this.bookingBatchService.getOpenOfferSummary(booking.id) - : null; + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } return { ...booking, latestChangeRequestNote: note?.note ?? null, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 913565f70..25cf4f875 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + // Contract reference for the list column + search (no entity relation on + // Booking → contract, so join by id and select just the reference). + .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ 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 01b35fc71..3ea19f778 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 @@ -8,13 +8,13 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; @@ -66,7 +66,6 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, - private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -587,11 +586,14 @@ export class ContractBookingService { } /** - * Pre-create validation for the shipment form: run the overweight rule + the - * 20ft weight-pairing rule against the entered containers WITHOUT persisting a - * booking. The portal calls this from the price-confirm modal so the customer - * sees the overweight warning (+ surcharge basis) and is blocked on an - * un-pairable 20ft set before the booking is created. + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, @@ -606,22 +608,26 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) { + if (contract.freightType === 'CONTAINER' && !lines.length) { return { overweightLines: [], overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + lineItems: [], + totalAmount: 0, }; } - // Resolve each line's container type + total VGM (sum of unit weights) so the - // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( @@ -636,46 +642,44 @@ export class ContractBookingService { }), ); - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: 'CONTAINER', - cargoTypeId: null, - serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, tradeDirection: contract.tradeDirection, - isHazardous: false, - isReefer: contract.isReefer ?? false, - isGovernment: false, - allowConsolidation: false, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, shippingLineId: null, - totalWagons: 0, - bulkTons: 0, - containers: resolved.map((r) => ({ - containerTypeId: r.ct.id, - quantity: r.line.quantity, - vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, - totalVgmTons: r.totalVgmTons, - isReefer: r.ct.isReefer, - })), - } as never); + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; - const overweightLines: Array<{ - containerTypeCode: string; - totalVgmTons: number; - maxAllowedTons: number; - excessTons: number; - }> = []; - for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { - const wr = ruleResult.containerWeightResults[i]; - if (!wr?.isOverweight) continue; - const r = resolved[i]; - const excessTons = Number(wr.overweightExcessTons ?? 0); - overweightLines.push({ - containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', - totalVgmTons: r?.totalVgmTons ?? 0, - maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), - excessTons, - }); - } + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved @@ -691,27 +695,13 @@ export class ContractBookingService { (v) => v.message, ); - // Real overweight surcharge (same rate the rule engine bills at booking-create - // time) so the confirm-modal total isn't missing the charge the warning refers to. - // Rates are stored in USD; convert to the contract's payment currency the same - // way BookingPricingService does so this preview matches the eventual booking total. - const overweightModifier = ruleResult.appliedModifiers.find( - (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', - ); - let overweightSurchargeAmount = 0; - if (overweightModifier) { - const isEtb = contract.paymentCurrency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - overweightSurchargeAmount = isEtb - ? Math.round(overweightModifier.calculatedAmount * usdToEtb) - : overweightModifier.calculatedAmount; - } - return { - overweightLines, + overweightLines: computed.overweightLines, overweightSurchargeAmount, - currency: overweightLines.length ? contract.paymentCurrency : null, + currency: computed.currency, pairingErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, }; } 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 09e4a7ffd..06ac31d68 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -791,7 +791,7 @@ export class ContractsController { @Post(':id/validate-shipment') @ApiOperation({ summary: - 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 22e322953..773e4738a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -83,6 +83,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.getBookingWindowsForContract(contractId); } + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index bac226c0a..93846d31d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3220,6 +3220,50 @@ export class TrainSchedulingService { return rows.map((r) => this.mapBookingWindowRow(r)); } + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + ); + return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, 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 ef9b2453f..d437d2904 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -4,7 +4,7 @@ import { useParams, useSearchParams, } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -25,6 +25,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -365,8 +366,11 @@ export default function GlCreateBookingForm() { (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); - const handleSubmit = () => { - if (!scheduledDate || !contract || !windowOpen) return; + /** The create-booking DTO from the current form state — shared by the + * authoritative price preview and the actual submit so what GL confirms is + * exactly what gets booked. */ + const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -408,6 +412,56 @@ export default function GlCreateBookingForm() { })); } + return payload; + }; + + // Authoritative price preview (same pricing pass the booking persists at + // create): rail freight + first/last mile + overweight + every surcharge. + // Fired when the price modal opens; the modal falls back to the contract + // unit-rate estimate while it loads. + const validateShipmentMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + contractsService.validateShipment(id ?? "", dto), + }); + const validation = validateShipmentMutation.data ?? null; + + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? priceTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, priceTotal]); + + const displayTotal = serverTotal ?? priceTotal; + const pairingErrors = validation?.pairingErrors ?? []; + const overweightLines = validation?.overweightLines ?? []; + + const openPriceModal = () => { + setPriceOpen(true); + const payload = buildPayload(); + if (payload) { + validateShipmentMutation.reset(); + validateShipmentMutation.mutate(payload); + } + }; + + const handleSubmit = () => { + if (!contract || !windowOpen) return; + // Never book past unresolved 20ft pairing hard-blocks. + if (pairingErrors.length > 0) return; + const payload = buildPayload(); + if (!payload) return; + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -819,7 +873,7 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} disabled={!canSubmit} - onClick={() => setPriceOpen(true)} + onClick={openPriceModal} > Review price & book @@ -850,11 +904,67 @@ export default function GlCreateBookingForm() { } > - {priceTotal ? ( + {displayTotal ? ( + {validateShipmentMutation.isPending && ( + + + + Computing the final price breakdown and checking container + weights… + + + )} + + {pairingErrors.length > 0 && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds + limit {line.maxAllowedTons}t (+{line.excessTons}t + overweight) + + ))} + + An overweight surcharge applies (included in the total + below). + + + + )} + - {priceTotal.lines.map((line, i) => ( + {displayTotal.lines.map((line, i) => ( @@ -862,16 +972,16 @@ export default function GlCreateBookingForm() { {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "} {formatRateUnit(line.unit)} - {line.amount.toLocaleString()} {priceTotal.currency} + {line.amount.toLocaleString()} {displayTotal.currency} ))} - {priceTotal.lines.length === 0 && ( + {displayTotal.lines.length === 0 && ( No priced lines — check the cargo details. @@ -889,9 +999,9 @@ export default function GlCreateBookingForm() { Total - {priceTotal.total.toLocaleString()}{" "} + {displayTotal.total.toLocaleString()}{" "} - {priceTotal.currency} + {displayTotal.currency} @@ -912,6 +1022,9 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} loading={mutations.createBooking.isPending} + disabled={ + validateShipmentMutation.isPending || pairingErrors.length > 0 + } onClick={handleSubmit} > Confirm & book diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index 9d9573909..e5e998721 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -1,14 +1,31 @@ -import { useMemo } from "react"; -import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Card, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { api } from "@/services/api"; -import type { BatchBoardSchedule } from "@/types/trainScheduling"; +import type { StaffBookingWindow } from "@/types/trainScheduling"; /** All window times are communicated in East Africa Time. */ const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { @@ -28,7 +45,7 @@ function fmtTime(iso: string): string { }); } -function windowLabel(w: BatchBoardSchedule): string { +function windowLabel(w: StaffBookingWindow): string { if (w.windowOpensAt && w.windowClosesAt) { return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( w.windowClosesAt, @@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string { /** * The countdown for whichever phase the window is currently in, mirroring the - * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes - * at windowClosesAt) → document review (docReviewEndsAt) → payment - * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that - * lapses between the 60s refetches announces what comes next rather than the - * bare word "Expired". Returns null when no phase is timing down. + * customer portal. `expiredText` names the NEXT step so a deadline that lapses + * between refetches announces what comes next rather than the bare "Expired". */ function phaseCountdown( - w: BatchBoardSchedule, + w: StaffBookingWindow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { - label: "Booking opens in", + label: "Opens in", deadline: w.windowOpensAt, - expiredText: "Booking opening now…", + expiredText: "Opening now…", } : null; case "OPEN": return w.windowClosesAt ? { - label: "Window closes in", + label: "Closes in", deadline: w.windowClosesAt, - expiredText: "Document review starting…", + expiredText: "Review starting…", } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { - label: "Document review ends in", + label: "Doc review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…", } @@ -79,9 +93,9 @@ function phaseCountdown( case "PAYMENT": return w.paymentPhaseEndsAt ? { - label: "Payment window ends in", + label: "Payment ends in", deadline: w.paymentPhaseEndsAt, - expiredText: "Payment window closing…", + expiredText: "Closing…", } : null; default: @@ -89,15 +103,11 @@ function phaseCountdown( } } -function isOpenNow(w: BatchBoardSchedule): boolean { - return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; -} - /** Drop windows whose booking window (or the train itself) has already passed. */ -function isPast(w: BatchBoardSchedule): boolean { +function isPast(w: StaffBookingWindow): boolean { const now = Date.now(); const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; - const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; // Still live while in a post-close staff phase (doc review / payment). if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; if (departs != null && departs <= now) return true; @@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean { return false; } +function WindowCard({ w }: { w: StaffBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + /** - * Upcoming / open import booking windows across all train schedules, shown to GL - * ET on the clearance queue so they can see which lanes are accepting bookings - * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is - * pending. Windows already past close/departure are dropped. + * All announced booking windows (import cycles + export FCFS) across every lane, + * shown to GL ET on the clearance queue as a paged carousel — three lanes per + * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. + * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { const { data, isLoading } = useQuery( - api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + api.trainScheduling.allBookingWindows.queryOptions({ + refetchInterval: 60_000, + }), ); + const [page, setPage] = useState(0); const windows = useMemo(() => { const rows = (data ?? []).filter( @@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() { ); // Open lanes first, then by opening time. return rows.sort((a, b) => { - const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); if (openDiff !== 0) return openDiff; const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; @@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() { }); }, [data]); + const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = windows.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + if (!isLoading && windows.length === 0) return null; return ( - - - - - Booking windows - - - Upcoming and open import booking windows across all lanes (EAT) - - + + + + + + Booking windows + + + Import and export booking windows across all lanes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: + i === safePage + ? "var(--mantine-color-edr-green-6)" + : "var(--mantine-color-gray-3)", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - - {[1, 2].map((i) => ( - + + {[1, 2, 3].map((i) => ( + ))} - + ) : ( - - - {windows.map((w) => { - const open = isOpenNow(w); - const cd = phaseCountdown(w); - return ( - - - - - {w.origin ?? "—"} - - - - {w.destination ?? "—"} - - {w.trainNumber ? ( - - · {w.trainNumber} - - ) : null} - - - {windowLabel(w)} - {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} - - {cd ? ( - - - - ) : null} - - - - {w.direction ? ( - - {w.direction === "IMPORT" ? "Import" : "Export"} - - ) : null} - - {open - ? "Open now" - : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt - ? `Opens ${fmtTime(w.windowOpensAt)} EAT` - : (w.windowPhase ?? w.bookingWindowStatus).replace( - /_/g, - " ", - )} - - - - ); - })} - - + + {visible.map((w) => ( + + ))} + )} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 351c95e7e..d0f09f508 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -201,6 +201,7 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue", @@ -295,6 +296,7 @@ export const URL_CONSTANTS = { MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, GLOBAL_RULES: "/train-scheduling/global-rules", + BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index ec4708868..a5bc34170 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { return { id: booking.id, reference: booking.reference, + contractReference: booking.contractReference ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 8ea6f6f27..1dc2b13ef 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) { note?: string; }) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }), onSuccess: (data) => onSuccess(data, "Operation request reviewed"), - onError: () => toast.error("Failed to review operation request"), + onError: (error) => { + toast.error(parseApiError(error, "Failed to review operation request")); + // The transition may have committed even when the response errored (e.g. + // a post-accept step failed). Refetch so the UI shows the true state + // instead of requiring a manual refresh. + void invalidateBookingDetail(qc, bookingId); + }, }); const approveStep = useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index e45d64c50..477900ee1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -149,7 +149,8 @@ export default function BookingRequestsPage() { return items.filter( (b) => b.reference.toLowerCase().includes(q) || - b.customerLabel.toLowerCase().includes(q), + b.customerLabel.toLowerCase().includes(q) || + (b.contractReference?.toLowerCase().includes(q) ?? false), ); }, [data?.items, query]); @@ -196,6 +197,22 @@ export default function BookingRequestsPage() { ); }, }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const ref = row.original.contractReference; + return ( +
+ {ref ? ( + {ref} + ) : ( + + )} +
+ ); + }, + }, { id: "route", header: () => Route, @@ -373,7 +390,7 @@ export default function BookingRequestsPage() { } value={query} onChange={(e) => setQuery(e.target.value)} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 91685ef8a..7c6746ec7 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -54,6 +54,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -223,6 +224,13 @@ export const api = { () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ), + allBookingWindows: endpoint( + "train-scheduling", + "all-booking-windows", + () => trainSchedulingService.getAllBookingWindows(), + () => ["train-scheduling", "all-booking-windows"], + ), + batchBoardDetail: endpoint< { scheduleId: string }, BatchBoardScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 369e53ea8..0c28a8ad7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -27,6 +27,39 @@ export interface PaginatedContracts { total: number; } +/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-create validation + authoritative price preview for a booking under a + * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — + * the same pricing pass the booking persists at create (rail freight, + * first/last mile, overweight and every other surcharge). `pairingErrors` are + * HARD BLOCKS; `overweightLines` are warnings. + */ +export interface ShipmentValidation { + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; +} + export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; @@ -471,6 +504,18 @@ export const contractsService = { payload: Freight.CreateBookingUnderContractDto, ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + /** + * Pre-create validation + authoritative price preview: the same + * BookingPricingService pass that prices the booking on create (rail + + * first/last mile + every surcharge), plus overweight warnings and 20ft + * pairing hard-blocks. Shown in the GL price-confirm modal. + */ + validateShipment: ( + id: string, + payload: Freight.CreateBookingUnderContractDto, + ) => + postContract(C.VALIDATE_SHIPMENT(id), payload), + /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { const response = await client.get(C.CAPACITY(id)); diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8ec6a5783..aa178d4b7 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -21,6 +21,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -543,6 +544,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getAllBookingWindows: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS, + ); + return unwrap(response.data); + }, + updateGlobalRules: async ( payload: Partial>, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index a89e10378..1caf29726 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -191,6 +191,9 @@ export interface BookingDetail { customsClearingEnabled?: boolean; customsClearingAgent?: string | null; contractKind?: "ONE_TIME" | "GENERAL" | null; + contractId?: string | null; + /** Reference of the contract this booking was created under (list column + search). */ + contractReference?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -218,6 +221,7 @@ export interface BookingDetail { export interface BookingListRow { id: string; reference: string; + contractReference?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3316117f7..d629311ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -226,6 +226,27 @@ export interface BatchBoardBooking { state: BatchBoardBookingState; } +/** + * An announced booking window on any lane (import cycle or export FCFS), for + * staff dashboards. Mirrors the customer portal's MyBookingWindow. + */ +export interface StaffBookingWindow { + scheduleId: string; + trainNumber: string | null; + direction: "IMPORT" | "EXPORT" | null; + windowPhase: BookingWindowPhase | string | null; + isOpenNow: boolean; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingWindowStatus: string; + bookingCycleNo: number; + departureDate: string; + origin: string | null; + destination: string | null; +} + export interface BatchBoardSchedule { scheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 300ff643f..6dcece7ef 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,11 @@ -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { memo } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react"; +import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo, useMemo, useState } from "react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps { * lane the customer has an active contract for carry a "Book now" action; * others route to the contract list. Hidden entirely when nothing is announced. */ +/** Rows shown per carousel page. */ +const PER_PAGE = 3; + export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, isLoading, }: UpcomingWindowsSectionProps) { - const navigate = useNavigate(); + const [page, setPage] = useState(0); + + // Open lanes first, then by opening time — the ones the customer can act on + // lead the carousel. + const sorted = useMemo( + () => + [...windows].sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }), + [windows], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE); // Nothing upcoming — keep the dashboard uncluttered. if (!isLoading && windows.length === 0) return null; @@ -195,17 +220,58 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Upcoming and open booking windows across all lanes + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - {[1, 2].map((i) => ( + {[1, 2, 3].map((i) => ( ))} ) : ( - - {windows.map((w) => ( + + {visible.map((w) => ( + {/* Windows are informational here — booking is done from the + contract page while a window is open, not via a home CTA. */} - {/* ONE_TIME contracts book via their own single-shipment flow, - not window drawdown — show the window + countdown but no - "Book now" entry. */} - {w.isOpenNow && w.contractKind !== "ONE_TIME" && ( - - )} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index b2542881a..ddcff24b7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -218,8 +218,6 @@ function NewShipmentBookingForm({ mode: "onChange", }); - const isContainerContract = contract.freightType === "CONTAINER"; - const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -287,15 +285,14 @@ function NewShipmentBookingForm({ } // Submit validates the whole form, then opens the price modal for - // confirmation. For container contracts we also run the server-side shipment - // validation (overweight warnings + 20ft pairing hard-blocks) so the modal - // can surface them before the booking is created. + // confirmation. The server-side shipment validation also returns the + // authoritative price breakdown (rail + first/last mile + every surcharge) — + // run it for every freight type; container contracts additionally get + // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { setPendingValues(values); - if (isContainerContract) { - validateMutation.reset(); - validateMutation.mutate(buildDto(values)); - } + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); }); const handleConfirm = () => { @@ -449,12 +446,32 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const confirmDisabled = loading || validationLoading || hasPairingBlock; - // The contract's frozen unit rates (computeShipmentTotal) don't carry an - // overweight line — that surcharge only exists in the live rule engine. Fold - // the real amount from validateShipment into the displayed total so the - // customer sees the actual charge the overweight warning refers to, not just - // the warning text. + // Authoritative server breakdown — the SAME BookingPricingService pass that + // prices the booking on create, so it carries every line the booking will be + // charged: rail freight, first/last mile trucking, overweight, hazard/reefer + // and any other rule-engine surcharge. + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? baseTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, baseTotal]); + + // Fallback while the server preview loads: the contract's frozen unit rates + // (container/bulk + hazard/reefer only) with the overweight surcharge folded + // in. Replaced by the full server breakdown the moment it arrives. const total = useMemo(() => { + if (serverTotal) return serverTotal; if (!baseTotal) return null; if (!(overweightSurchargeAmount > 0)) return baseTotal; return { @@ -471,7 +488,7 @@ function PriceConfirmModal({ ], total: baseTotal.total + overweightSurchargeAmount, }; - }, [baseTotal, overweightSurchargeAmount]); + }, [serverTotal, baseTotal, overweightSurchargeAmount]); return ( - Checking container weights and wagon pairing… + Computing the final price breakdown and checking container + weights… )} diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 7c981eee2..0642e8061 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -42,18 +42,37 @@ export interface OverweightLine { } /** - * Pre-submit validation for a shipment booking under a CONTAINER contract. + * One line of the server-priced booking breakdown — the exact line the booking + * will persist at create time (rail freight, first/last mile, surcharges…). + */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-submit validation + authoritative price preview for a shipment booking. * `overweightLines` are WARNINGS only (an overweight surcharge applies — the * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers * that cannot be balanced onto wagons) and must prevent booking. - * `overweightSurchargeAmount` is the real overweight charge (same rate the - * booking is billed at on submit) so the confirm-modal total can include it. + * `lineItems`/`totalAmount` are the full server-computed breakdown — the same + * BookingPricingService pass that prices the booking on create, so the confirm + * modal shows first/last mile, overweight, and every surcharge, not just the + * container estimate. */ export interface ShipmentValidation { overweightLines: OverweightLine[]; overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; } export interface ContractListFilter {