diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts new file mode 100644 index 000000000..dffa8caed --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts @@ -0,0 +1,44 @@ +import { orderSlotsByWagonSequence } from './train-builder.service'; + +describe('orderSlotsByWagonSequence', () => { + const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, + }); + + it('reorders pinned slots to the wagons’ new positions, unpinned trail in old order', () => { + // Built train reordered to w3, w1, w2. Slots 1..5: three pinned + two empty. + const newSeq = new Map([ + ['w3', 1], + ['w1', 2], + ['w2', 3], + ]); + const slots = [ + slot(1, 'w1'), + slot(2, 'w2'), + slot(3, 'w3'), + slot(4, null), + slot(5, null), + ]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w3', + 'w1', + 'w2', + null, + null, + ]); + // Unpinned keep their old relative order (4 before 5). + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.sequenceNo)).toEqual([ + 3, 1, 2, 4, 5, + ]); + }); + + it('slots pinned to wagons outside the reorder trail like unpinned ones', () => { + const newSeq = new Map([['w2', 1]]); + const slots = [slot(1, 'w-foreign'), slot(2, 'w2')]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w2', + 'w-foreign', + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index abbf887e1..fbb5301fb 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -16,6 +16,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -601,26 +602,6 @@ export class TrainBuilderService { return rows.length > 0; } - /** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */ - private async isAnyWagonPinnedToLiveSchedule( - manager: EntityManager, - wagonIds: string[], - ): Promise { - if (!wagonIds.length) return false; - const rows: { exists: boolean }[] = await manager.query( - `SELECT TRUE AS exists - FROM freight.train_set_wagons tsw - JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id - WHERE tsw.physical_wagon_id = ANY($1::uuid[]) - AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') - AND ts.deleted_at IS NULL - AND tsw.deleted_at IS NULL - LIMIT 1`, - [wagonIds], - ); - return rows.length > 0; - } - /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -633,20 +614,63 @@ export class TrainBuilderService { if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { throw new BadRequestException('Reorder must include every wagon of the train exactly once'); } - // A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at - // its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so - // renumbering here would silently desync that schedule's drawn consist - // from the built train's real order (loaded slots keep the old order, - // empty ones show the new one). Same guard as remove/maintenance. - if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) { + // Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder + // is allowed — the pinned schedules' consists are resequenced below so + // they can never desync from the built train's real order. + const dispatched: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = ANY($1::uuid[]) + AND ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [[...current]], + ); + if (dispatched.length > 0) { throw new ConflictException( - 'This train has wagons pinned to an active schedule and cannot be reordered — ' + - "it would desync the schedule's consist view from the built train's real order.", + 'This train is dispatched — wagons cannot be reordered while it is rolling.', ); } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + + // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of + // this train: slots pinned to a reordered wagon adopt the wagon's new + // position, unpinned slots trail in their old relative order. Allocations + // ride the slot row (by id), so cargo stays with its physical wagon. + const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1])); + const sets: { train_set_id: string }[] = await manager.query( + `SELECT DISTINCT ts.train_set_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED')`, + [id], + ); + for (const { train_set_id: trainSetId } of sets) { + const slots = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + order: { sequenceNo: 'ASC' }, + }); + const sorted = orderSlotsByWagonSequence(slots, newSeq); + // (train_set_id, sequence_no) is unique — shift to a temp range first + // so the final renumbering can't collide mid-loop. + await manager.query( + `UPDATE freight.train_set_wagons + SET sequence_no = sequence_no + 100000 + WHERE train_set_id = $1 AND deleted_at IS NULL`, + [trainSetId], + ); + for (let i = 0; i < sorted.length; i++) { + await manager + .getRepository(TrainSetWagon) + .update(sorted[i].id, { sequenceNo: i + 1 }); + } + } }); return this.getComposition(id); } @@ -1067,3 +1091,20 @@ export class TrainBuilderService { } } } + +/** + * New consist order for a schedule's slots after a built-train reorder: slots + * pinned to a reordered wagon adopt the wagon's new position; unpinned slots + * trail behind in their previous relative order. + */ +export function orderSlotsByWagonSequence< + T extends Pick, +>(slots: T[], newSeq: Map): T[] { + const key = (s: T): number => + (s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity; + return [...slots].sort((a, b) => { + const sa = key(a); + const sb = key(b); + return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo; + }); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 44cd3053c..de954742e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -72,19 +72,39 @@ function apiErrorMessage(error: unknown, fallback: string): string { */ function phaseCountdown( schedule: TrainScheduleDetail, -): { label: string; deadline: string } | null { +): { label: string; deadline: string; expiredText: string } | null { switch (schedule.windowPhase) { + case "PRE_WINDOW": + return schedule.windowOpensAt + ? { + label: "Booking window opens in", + deadline: schedule.windowOpensAt, + expiredText: "Booking opening now…", + } + : null; case "OPEN": return schedule.windowClosesAt - ? { label: "Booking window closes in", deadline: schedule.windowClosesAt } + ? { + label: "Booking window closes in", + deadline: schedule.windowClosesAt, + expiredText: "Document review starting…", + } : null; case "DOC_REVIEW": return schedule.docReviewEndsAt - ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt } + ? { + label: "Document review ends in", + deadline: schedule.docReviewEndsAt, + expiredText: "Payment starting…", + } : null; case "PAYMENT": return schedule.paymentPhaseEndsAt - ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt } + ? { + label: "Payment window ends in", + deadline: schedule.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + } : null; default: return null; @@ -523,7 +543,12 @@ export function ScheduleWorkspacePanel({ border: "1px solid var(--mantine-color-blue-2)", }} > - + ) : null; })()} diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index d5409584f..b9195b1ff 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -147,6 +147,12 @@ export function useBookingWindowSocket(enabled: boolean = true) { void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId), }); + // Same for the schedule detail page (workspace tab countdown reads + // windowPhase/paymentPhaseEndsAt from it) — without this the page shows + // the OLD phase's expired countdown through the whole import cycle. + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(event.scheduleId), + }); // Always refresh the batch board (different shape, not patched). When the // schedule wasn't in any window list either, refresh those too so a newly diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 2d4489f22..3b4d2edd9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -147,8 +147,12 @@ export default function ContractClearanceDetailPage() { !isDjiboutiGl(user); const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; const canRebook = bookingExpired && isGlBookingOwner; + // Rebook completes the SAME expired booking (it already carries the price and + // cargo from its first completion) via the /complete endpoint's EXPIRED + // branch — routing it through create-booking instead would create a + // duplicate booking on the contract rather than resubmitting the existing one. const rebookHref = linkedBookingId - ? `${bookingHref}?copyFrom=${linkedBookingId}` + ? `/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}` : bookingHref; const { data: bookingMilestones, refetch: refetchBookingMilestones } = diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 06d588a11..e0c17a57b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -77,6 +77,7 @@ import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; import { openPdfBlob } from "@/components/warehouses/pdf"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import { useToast } from "@/hooks/use-toast"; @@ -121,8 +122,13 @@ export default function TrainScheduleV2DetailPage() { api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "" }, enabled: Boolean(scheduleId), + // Live phase updates come from the booking-window socket (PHASE pushes + // invalidate this query); 60s is the self-heal net for a missed emit so + // the workspace countdown never freezes on an expired phase. + refetchInterval: 60_000, }), ); + useBookingWindowSocket(Boolean(scheduleId)); const schedule = detailQuery.data; const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined; const isDjiboutiPort = (value?: string | null) => diff --git a/apps/edr-passenger-web/portal/public/packages/package.jpeg b/apps/edr-passenger-web/portal/public/packages/package.jpeg new file mode 100644 index 000000000..6b42468ae Binary files /dev/null and b/apps/edr-passenger-web/portal/public/packages/package.jpeg differ diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index af884f59a..3c8e66b98 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -531,9 +531,14 @@ function PassengerCountModal({ // uses) — package pricing/currency is fixed per tier and is never affected by // this. No default: nationality is a deliberate choice, same as the normal // flow's search-step picker. - const [nationality, setNationality] = useState<"" | "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER">(""); + const [nationality, setNationality] = useState< + "" | "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER" + >(""); const [showNationalityError, setShowNationalityError] = useState(false); - const natOptions: Array<{ value: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"; label: string }> = [ + const natOptions: Array<{ + value: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"; + label: string; + }> = [ { value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" }, { value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" }, { value: "OTHER", label: "🌍 Other" }, @@ -1037,7 +1042,7 @@ export default function PackageDetailPage() { {/* Hero */}
{pkg.name} (a.priceMinor < b.priceMinor ? a : b)); - return { amount: (min.priceMinor * multiplier) / 100, currency: min.currency }; + return { + amount: (min.priceMinor * multiplier) / 100, + currency: min.currency, + }; } function fmtPrice(minor: number, currency: string): string { @@ -148,7 +152,7 @@ function AvailBar({ booked, total }: { booked: number; total: number }) { // ─── Featured Card (first package — full-width, image left) ─────────────────── function FeaturedCard({ pkg }: { pkg: HolidayPackage }) { - const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1; + const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1; const price = minPrice(pkg.priceTiers, multiplier); const origin = pkg.outboundSchedule?.originStation; const dest = pkg.outboundSchedule?.destinationStation; @@ -161,7 +165,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) { {/* ── Left: Image ── */}
{pkg.name}
)} -
{/* ── Right: Content ── */} @@ -319,7 +322,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) { // ─── Regular Package Card ───────────────────────────────────────────────────── function PackageCard({ pkg }: { pkg: HolidayPackage }) { - const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1; + const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1; const price = minPrice(pkg.priceTiers, multiplier); const origin = pkg.outboundSchedule?.originStation; const dest = pkg.outboundSchedule?.destinationStation;