From e0010695be5f81c894ea1f471fb2d8131c050cdc Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 09:41:52 +0000 Subject: [PATCH 01/40] fix --- .../src/pages/operations/FirstMilePage.tsx | 73 ++++++++++--------- .../src/pages/operations/LastMilePage.tsx | 65 +++++++++-------- 2 files changed, 72 insertions(+), 66 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 35618fa3b..01e5ded57 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -122,42 +122,45 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: FirstMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: FirstMileRecord }) => { + const hasPickupAddress = record.booking?.firstMilePickupAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + {hasPickupAddress && } + + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Customer", customerName(record)], diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 70d9e9105..e85fbfd2d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -117,38 +117,41 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: LastMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: LastMileRecord }) => { + const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + + {hasDeliveryAddress && } + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: LastMileRecord): [string, string][] => [ ["Customer", customerName(record)], From 10e442deb1a8a0a627fa2e97c6c79cdecf2b067b Mon Sep 17 00:00:00 2001 From: yaschalew Date: Thu, 2 Jul 2026 13:18:49 +0300 Subject: [PATCH 02/40] fix --- .../src/modules/first-mile/first-mile.controller.ts | 6 ++++++ .../src/modules/first-mile/first-mile.service.ts | 11 ++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 680bb5d09..d5f53ae0c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -65,6 +65,12 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ae0ada831..a7a4f9100 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -43,7 +43,7 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + ) { } /** * Look up a booking by its human-readable reference and confirm it has been @@ -95,7 +95,7 @@ export class FirstMileService { if (booking.paymentStatus !== 'PAID') { throw new BadRequestException(`Booking ${label} is not paid`); } - + console.log("-------------------", booking) if (!this.bookingRequestsFirstMile(booking)) { throw new BadRequestException(`Booking ${label} does not require a first mile`); } @@ -212,11 +212,8 @@ export class FirstMileService { }): boolean { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. - return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, - ); + return Boolean(booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile); } async update(id: string, dto: UpdateFirstMileDto): Promise { From 1c88bd8d686b4b97cd5d26d39f0aa3a7bcef3adb Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:27:01 +0000 Subject: [PATCH 03/40] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 01e5ded57..9f6e82d03 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -469,9 +469,14 @@ const FirstMilePage = () => { const firstMileEligiblePaidBookings = useMemo( () => paidBookings.filter( - (booking) => - booking.tradeDirection === "EXPORT" && - !existingFirstMileBookingIds.has(booking.id), + (booking) => { + if (existingFirstMileBookingIds.has(booking.id)) return false; + return ( + booking.tradeDirection === "EXPORT" || + (booking.firstMilePickupAddress?.trim() ?? false) || + booking.serviceType?.includesFirstMile + ); + }, ), [existingFirstMileBookingIds, paidBookings], ); From 4519a1d75d4b7dc09646cec9be45404429a7d863 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:33:32 +0000 Subject: [PATCH 04/40] fix --- .../src/modules/first-mile/first-mile.service.ts | 9 +++++---- .../backoffice/src/pages/operations/FirstMilePage.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a7a4f9100..bc8801067 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -210,10 +210,11 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. - return Boolean(booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile); + return Boolean( + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), + ); } async update(id: string, dto: UpdateFirstMileDto): Promise { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9f6e82d03..93e3e51b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -471,11 +471,11 @@ const FirstMilePage = () => { paidBookings.filter( (booking) => { if (existingFirstMileBookingIds.has(booking.id)) return false; - return ( - booking.tradeDirection === "EXPORT" || - (booking.firstMilePickupAddress?.trim() ?? false) || - booking.serviceType?.includesFirstMile - ); + if (booking.paymentStatus !== "PAID") return false; + if (booking.tradeDirection !== "EXPORT") return false; + const hasPickupAddress = booking.firstMilePickupAddress?.trim() ?? false; + const includesFirstMile = booking.serviceType?.includesFirstMile ?? false; + return hasPickupAddress || includesFirstMile; }, ), [existingFirstMileBookingIds, paidBookings], From f4f98575f118216219c4dfbcda4eaf0dbab052f9 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:48:31 +0000 Subject: [PATCH 05/40] fix error --- .../backoffice/src/pages/operations/LastMilePage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index e85fbfd2d..9d54326e1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -354,6 +354,10 @@ const LastMilePage = () => { }); const records = listData?.data ?? []; + const existingLastMileBookingIds = useMemo( + () => new Set(records.map((record) => record.bookingId)), + [records], + ); const vehicleOptions = useMemo( () => @@ -414,15 +418,16 @@ const LastMilePage = () => { const arrivalQueue = arrivalQueueData ?? []; const filteredArrivalQueue = useMemo(() => { + let filtered = arrivalQueue.filter((item) => !existingLastMileBookingIds.has(item.bookingId)); const term = arrivalSearch.trim().toLowerCase(); - if (!term) return arrivalQueue; - return arrivalQueue.filter((item) => + if (!term) return filtered; + return filtered.filter((item) => [item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard] .join(" ") .toLowerCase() .includes(term), ); - }, [arrivalQueue, arrivalSearch]); + }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); const acceptMutation = useMutation({ mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { From e59a8f980244fa15f90944ffb548657f11a7dd15 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:54:43 +0000 Subject: [PATCH 06/40] fix delete api --- .../src/pages/operations/FirstMilePage.tsx | 29 +++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 93e3e51b0..198fe50a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -7,6 +7,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -420,6 +421,17 @@ const FirstMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => firstMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Record deleted", description: "First-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -837,6 +849,7 @@ const FirstMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; return ( @@ -889,6 +902,22 @@ const FirstMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + onClick={() => { + if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9d54326e1..eb00c0904 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -6,6 +6,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -397,6 +398,17 @@ const LastMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => lastMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); + toast({ title: "Record deleted", description: "Last-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const allocateMutation = useMutation({ mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => api.post(`/last-mile/${activeId}/allocate-containers`, data), @@ -817,6 +829,7 @@ const LastMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; return ( @@ -869,6 +882,22 @@ const LastMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + onClick={() => { + if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} From bab329e8e5f14bf20ce6771169f4be4708ea26b1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:57:35 +0000 Subject: [PATCH 07/40] fix --- .../backoffice/src/services/first-mile.service.ts | 4 +++- .../backoffice/src/services/last-mile.service.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index b182a16d0..1c81f5dc5 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -60,8 +60,10 @@ export const firstMileService = { list: (pageSize = 1000) => api.get(`${FM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(FM.BY_ID(id)), - update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(FM.BY_ID(id), data), accept: (bookingReference: string) => api.post(FM.ACCEPT(bookingReference)), + remove: (id: string) => + api.delete(FM.BY_ID(id)), }; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index cb9e61b52..158056e46 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -60,8 +60,10 @@ export const lastMileService = { list: (pageSize = 1000) => api.get(`${LM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(LM.BY_ID(id)), - update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(LM.BY_ID(id), data), accept: (bookingReference: string) => api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), + remove: (id: string) => + api.delete(LM.BY_ID(id)), }; From ff7e20d2ee3a1cf25c038ae60d5d829104fbae69 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 11:22:35 +0000 Subject: [PATCH 08/40] fix --- .../1870000000000-AddLocationToVehicles.ts | 22 +++++++++++++++++++ .../vehicles/entities/vehicle.entity.ts | 3 +++ .../src/pages/fleet/config/vehicles.ts | 2 ++ .../src/services/vehicles.service.ts | 3 ++- 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..1e4121802 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -68,4 +68,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 92da9b9dc..648c0579d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -74,6 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, { name: "capacity", label: "Capacity", type: "number", required: true }, + { name: "locationId", label: "Location", type: "select", dataSource: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, @@ -90,6 +91,7 @@ export const vehiclesConfig: FleetResourceConfig = { year: new Date().getFullYear(), fuelType: "DIESEL", capacity: 0, + locationId: null, estimatedDistanceKm: "", actualDistanceKm: "", status: "ACTIVE", diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index b9d5129e3..3cb2ad358 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -29,6 +29,7 @@ export interface Vehicle { code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + locationId?: string | null; createdAt: string; updatedAt: string; } @@ -55,7 +56,7 @@ export const vehiclesService = { getById: (id: string) => apiClient.get(URL_CONSTANTS.VEHICLES.BY_ID(id)), create: (data: Partial) => apiClient.post(URL_CONSTANTS.VEHICLES.BASE, data), - update: (id: string, data: Partial) => + update: (id: string, data: Partial) => apiClient.patch(URL_CONSTANTS.VEHICLES.BY_ID(id), data), delete: (id: string) => apiClient.delete(URL_CONSTANTS.VEHICLES.BY_ID(id)), }; From adde5962895699d5ef23e986adb83873c88a04b7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 11:32:16 +0000 Subject: [PATCH 09/40] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 648c0579d..6ac04e7aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -74,7 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, { name: "capacity", label: "Capacity", type: "number", required: true }, - { name: "locationId", label: "Location", type: "select", dataSource: "yards" }, + { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, From a0f4de05f859113cb26699b7d4ca7fb8d06afaa0 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:37:02 +0000 Subject: [PATCH 10/40] log full error --- .../src/modules/vehicles/dto/create-vehicle.dto.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..5651a3906 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -57,4 +57,8 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; } From 657f3bd2ab962b1b0ffb8210e641dcf474889fe6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:39:47 +0000 Subject: [PATCH 11/40] fix --- .../src/pages/fleet/config/vehicles.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 6ac04e7aa..6f65224bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,18 +49,11 @@ export const vehiclesConfig: FleetResourceConfig = { columns: [ { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, - { id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 }, - { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 }, - { id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 }, - { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 }, - { id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 100 }, - { id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 }, - { id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 }, - { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 }, - { id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 }, - { id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 }, - { id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 }, + { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 }, + { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 }, + { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 }, + { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, + { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, ], formFields: [ From 58b3805d0bd4d60eb3f6e362adf2d92910a354eb Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:43:03 +0000 Subject: [PATCH 12/40] fix --- .../1880000000000-AddVehicleStatuses.ts | 22 +++++++++++++++++++ .../vehicles/entities/vehicle.entity.ts | 2 ++ .../src/components/fleet/fleetFormat.tsx | 2 ++ .../src/pages/fleet/FleetResourcePage.tsx | 1 + .../src/pages/fleet/config/vehicles.ts | 14 +++++++----- .../src/services/vehicles.service.ts | 2 +- 6 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..29da135c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + `); + await queryRunner.query(` + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 1e4121802..a94210e65 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -20,6 +20,8 @@ export enum FuelType { export enum VehicleStatus { ACTIVE = 'ACTIVE', + FREE = 'FREE', + BUSY = 'BUSY', MAINTENANCE = 'MAINTENANCE', RETIRED = 'RETIRED', OUT_OF_SERVICE = 'OUT_OF_SERVICE', diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index 5c997af9f..709f288ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -25,6 +25,8 @@ export const formatFleetCell = ( const getStatusColor = (st: string): string => { const s = st.toUpperCase(); if (s === "ACTIVE" || s === "AVAILABLE") return "green"; + if (s === "FREE") return "teal"; + if (s === "BUSY") return "blue"; if (s === "INACTIVE") return "gray"; if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red"; if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange"; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 615e706f5..5310a6f12 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -189,6 +189,7 @@ const FleetResourcePage = () => { registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); + registerFleetOptionLabels("locationId", dynamicOptions.yards); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 6f65224bc..3643e390f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -19,6 +19,8 @@ const FUEL_TYPE_OPTIONS = [ const VEHICLE_STATUS_OPTIONS = [ { label: "Active", value: "ACTIVE" }, + { label: "Free", value: "FREE" }, + { label: "Busy", value: "BUSY" }, { label: "Maintenance", value: "MAINTENANCE" }, { label: "Retired", value: "RETIRED" }, { label: "Out of service", value: "OUT_OF_SERVICE" }, @@ -47,13 +49,13 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ - { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 }, - { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, - { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 }, - { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 }, + { id: "code", header: "Code", accessorKey: "code", size: 90 }, + { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, + { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, + { id: "model", header: "Model", accessorKey: "model", size: 100 }, + { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, - { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 }, + { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, ], formFields: [ diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index 3cb2ad358..d5e9693d0 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -3,7 +3,7 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type VehicleType = 'TRUCK' | 'VAN' | 'CAR' | 'BUS' | 'TRAILER' | 'TANKER' | 'FLATBED'; export type FuelType = 'PETROL' | 'DIESEL' | 'ELECTRIC' | 'HYBRID'; -export type VehicleStatus = 'ACTIVE' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; +export type VehicleStatus = 'ACTIVE' | 'FREE' | 'BUSY' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; export interface VehicleListFilters { status?: VehicleStatus; From 43a32f192fe4cb9c70d53066246637747866a313 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:47:10 +0000 Subject: [PATCH 13/40] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 3643e390f..1f1ae1412 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,8 +49,8 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ - { id: "code", header: "Code", accessorKey: "code", size: 90 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, + { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, { id: "model", header: "Model", accessorKey: "model", size: 100 }, { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, From c8b7d76073526ce1b65048ed8652b3100fd49b89 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:50:49 +0000 Subject: [PATCH 14/40] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 1f1ae1412..c6923d9fa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,6 +49,7 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ + { id: "code", header: "Code", accessorKey: "code", size: 90 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, From 64e346324b81c0380061c73d004d27cbe393e0cd Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:11:12 +0000 Subject: [PATCH 15/40] fix issue --- .../src/migrations/1880000000000-AddVehicleStatuses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts index 29da135c3..ce9af151b 100644 --- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -15,7 +15,7 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface { `); } - public async down(queryRunner: QueryRunner): Promise { + public async down(_queryRunner: QueryRunner): Promise { // Note: Postgres cannot drop individual enum values, so the down migration is a no-op // The enum values FREE and BUSY will remain but will be unused after downgrade } From e429a2cebff69900473b6bd96f0e74e472fc59e7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:15:44 +0000 Subject: [PATCH 16/40] fix --- ...8427600000-AddServiceTypesAndCargoTypes.ts | 33 +++++++++++-------- .../1880000000000-AddVehicleStatuses.ts | 15 ++++++--- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..c3698aed6 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts index ce9af151b..484a2686b 100644 --- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -7,11 +7,18 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface { name = "AddVehicleStatuses1880000000000"; public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist await queryRunner.query(` - ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; - `); - await queryRunner.query(` - ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; `); } From 1869790eab76c84ff3ccfa3b660ca02c0e633b1c Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:18:34 +0000 Subject: [PATCH 17/40] fix --- .../migrations/1748427600000-AddServiceTypesAndCargoTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index c3698aed6..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -94,7 +94,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter // Create indexes for service_types const table = await queryRunner.getTable("freight.service_types"); - if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) { + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { await queryRunner.createIndex( "freight.service_types", new TableIndex({ @@ -103,7 +103,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter }), ); } - if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) { + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { await queryRunner.createIndex( "freight.service_types", new TableIndex({ From fe40d9f4be5b25ea1658f90f9f753f9b8e06959c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:24:39 +0000 Subject: [PATCH 18/40] update import gl flow --- .../bookings/booking-invoice.service.ts | 2 +- .../bookings/entities/booking.entity.ts | 2 +- .../booking-clearance.service.spec.ts | 11 ++ .../contracts/booking-clearance.service.ts | 39 ++++-- .../contracts/contract-clearance.service.ts | 42 +++++-- .../modules/contracts/contracts.controller.ts | 27 ++++ .../contracts/gl-operations.service.ts | 118 +++++++++++++++++- .../contracts/phased-clearance.util.ts | 68 +++++++++- 8 files changed, 280 insertions(+), 29 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3bfb838b4..e01e6992a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -135,7 +135,7 @@ export class BookingInvoiceService { ); return; } - if (booking.paymentStatus === "PAID") return; + // if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3ae0fb64f..484b368fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -272,7 +272,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7ea818e34..2b0126003 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -65,6 +65,16 @@ function makeService(overrides?: { children: [{ value: '2' }], }), }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; const service = new BookingClearanceService( bookingsRepository as never, @@ -74,6 +84,7 @@ function makeService(overrides?: { workflowService as never, milestoneService as never, dropdownSettingsService as never, + glOperationsService as never, ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 7bb835df2..4e9a7b69d 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -11,6 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -63,6 +64,8 @@ export interface BookingClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; } @Injectable() @@ -75,6 +78,7 @@ export class BookingClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -162,6 +166,15 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + return { bookingId, status: booking.status, @@ -192,6 +205,7 @@ export class BookingClearanceService { preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), dutyAdvice, workflowFiles, + t1, }; } @@ -421,6 +435,13 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + return this.bookingsService.findById(bookingId); } @@ -434,15 +455,11 @@ export class BookingClearanceService { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!booking.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', @@ -450,8 +467,10 @@ export class BookingClearanceService { file, }); - await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForOperation(bookingId); + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index a09de407c..34e3d3d94 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -10,6 +10,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; @@ -77,6 +78,8 @@ export interface ContractClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; } @Injectable() @@ -90,6 +93,7 @@ export class ContractClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -224,6 +228,15 @@ export class ContractClearanceService { workflowFiles = [...byCode.values()]; } + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing — view stays usable + } + } + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const bookingMilestones = await this.workflowService.listMilestonesForBooking( @@ -272,6 +285,7 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, dutyAdvice, workflowFiles, + t1, }; } @@ -1011,6 +1025,13 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlDjCollection, }); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + return this.contractsService.findById(contractId); } @@ -1025,17 +1046,11 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - const cycle = await this.contractsRepository.currentCycle(contractId); - if (!cycle?.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); - if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', @@ -1043,8 +1058,11 @@ export class ContractClearanceService { file, }); - await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForBooking(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } return this.contractsService.findById(contractId); } 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 5e712a177..872d13a0c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -873,6 +873,33 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 73ca3a65d..f900c564f 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -1,14 +1,19 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { isT1TransportFileCode, type Freight } from '@edr/types'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; -import { persistExportTransportUploads } from './phased-clearance.util'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -18,7 +23,8 @@ import { persistExportTransportUploads } from './phased-clearance.util'; const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ - t1_transport_document: 'T1_CLOSED', // import — GL ET + // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only + // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET @@ -161,6 +167,114 @@ export class GlOperationsService { return { uploaded: files.length, completedMilestones }; } + /** + * T1 transit-document lifecycle state for an import shipment booking. Wagon + * allocation opens the upload window; train departure locks it; train arrival + * lets GL Ethiopia close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const booking = await this.getBooking(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonAllocated = + wagonMilestone?.status === 'COMPLETED' || + booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId); + + let schedule: TrainSchedule | null = null; + if (booking.trainScheduleId) { + schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + } + + const closedMilestone = milestones.find( + (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', + ); + + return { + bookingId, + wagonAllocated, + trainDepartedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + trainArrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + closed: Boolean(closedMilestone), + closedAt: closedMilestone?.triggeredAt + ? new Date(closedMilestone.triggeredAt).toISOString() + : null, + }; + } + + /** + * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. + * Replaces the previous batch; locked once the train departs or T1 is closed. + */ + async uploadT1Documents( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 transport documents apply to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (!state.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before T1 transport documents can be uploaded.', + ); + } + if (state.closed) { + throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); + } + if (state.trainDepartedAt) { + throw new BadRequestException( + 'The train has departed — T1 transport documents can no longer be changed.', + ); + } + + await persistT1TransportUploads(this.filesService, bookingId, files); + return { uploaded: files.length }; + } + + /** + * GL Ethiopia closes (accepts) the T1 document set once the train has arrived. + * Completes the T1_CLOSED milestone; the document set becomes final. + */ + async closeT1( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 closure applies to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (state.closed) return state; + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + + await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); + return this.t1State(bookingId); + } + /** * GL ET uploads export transport document after wagon allocation (export ONE_TIME). */ diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 89fbf797e..2e8682951 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,7 +5,9 @@ import { isDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, + isT1TransportFileCode, exportTransportFileLabel, + t1TransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; @@ -160,6 +162,50 @@ export async function persistExportTransportUploads( ); } +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; @@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue( ); if (hasDjActivity) return true; - const preFinalized = - cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; - if (tradeDirection === 'IMPORT' && preFinalized) return true; + // Import DO upload is un-gated — Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; return false; } @@ -295,6 +341,22 @@ export function buildWorkflowFiles( file: { id: file.id, name: file.name, url: file.url }, }); }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); } if (tradeDirection === 'EXPORT') { From fec2a5d3208e1250418a3900243f17229e434cb0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:27:59 +0000 Subject: [PATCH 19/40] update import gl flow --- .../contracts/GlClearanceUploadModal.tsx | 3 +- .../contracts/PhasedClearanceActionPanel.tsx | 232 ++++++++++++- .../backoffice/src/constants/URLS.ts | 4 + .../pages/contracts/GlClearanceDetailPage.tsx | 3 +- .../src/services/contracts.service.ts | 21 ++ apps/edr-freight-web/portal/package.json | 5 +- .../new-booking-form/LocationPicker.tsx | 308 ++++++++---------- apps/edr-freight-web/portal/src/vite-env.d.ts | 1 + .../src/freight/clearance-files.catalog.ts | 28 ++ packages/types/src/freight/contracts.ts | 16 + packages/types/src/freight/index.ts | 2 + pnpm-lock.yaml | 81 ++--- 12 files changed, 452 insertions(+), 252 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index b33729bf1..ba54e3b8b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -121,7 +121,8 @@ export function GlClearanceUploadModal({ & { operationReady?: boolean }; type MilestoneRow = NonNullable[number]; @@ -79,7 +80,10 @@ function isBookingMilestoneDone( return m?.status === "COMPLETED" || m?.status === "SKIPPED"; } -function computeImportActiveStep(clearance: ClearanceViewLike): number { +function computeImportActiveStep( + clearance: ClearanceViewLike, + bookingCreated: boolean, +): number { if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1; if ( @@ -98,7 +102,23 @@ function computeImportActiveStep(clearance: ClearanceViewLike): number { if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4; if (!clearance.preClearanceFinalized) return 5; if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6; - return 7; + if (!bookingCreated) return 7; + if (!clearance.t1?.closed) return 8; + return 9; +} + +function t1FilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): Array<{ code: string; label: string; file: { id: string; name: string } }> { + return workflowFiles + .filter( + (f) => f.code.toLowerCase().startsWith("t1_transport_document") && f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); } function declarationFilesFromWorkflow( @@ -174,9 +194,12 @@ export function PhasedClearanceActionPanel({ const showEt = roleMode === "ET" || roleMode === "ALL"; const showDj = roleMode === "DJ" || roleMode === "ALL"; const isImport = tradeDirection === "IMPORT"; + // The server only builds the t1 block once a booking is linked — use it as the + // booking-created signal on pages that don't pass bookingCreated (GL DJ detail). + const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1); const activeStep = useMemo( - () => (isImport ? computeImportActiveStep(clearance) : 0), - [clearance, isImport], + () => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0), + [clearance, isImport, effectiveBookingCreated], ); if (isImport) { @@ -401,11 +424,7 @@ export function PhasedClearanceActionPanel({ description="GL Djibouti uploads DO" icon={} > - {showDj && - canDj && - !useUploadModals && - (activeStep >= 6 || - isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? ( + {showDj && canDj && !useUploadModals ? ( {useUploadModals && showDj && canDj && onUploadDoRequest ? ( @@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({ color="edr-green" leftSection={} onClick={onUploadDoRequest} - disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")} > {findWorkflowFile(workflowFiles, "delivery_order") ? "Replace DO" @@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({ ) : ( )} + + : + } + > + + @@ -578,6 +612,170 @@ export function PhasedClearanceActionPanel({ ); } +function ImportT1Section({ + t1, + workflowFiles = [], + canDjAct, + canEtAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + t1: Freight.ClearanceT1State | null; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canDjAct: boolean; + canEtAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [closing, setClosing] = useState(false); + + const uploaded = t1FilesFromWorkflow(workflowFiles); + const replaceMode = uploaded.length > 0; + + if (!t1) { + return ( + + ); + } + + const departed = Boolean(t1.trainDepartedAt); + const arrived = Boolean(t1.trainArrivedAt); + const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed; + + return ( + + {uploaded.length > 0 ? ( + + + T1 document{uploaded.length > 1 ? "s" : ""} + + {uploaded.map((row) => ( + + ))} + + ) : null} + + {t1.closed ? ( + + ) : !t1.wagonAllocated ? ( + + ) : departed ? ( + }> + The train has departed — T1 documents are locked and can no longer be changed. + + ) : uploaded.length === 0 && !canUpload ? ( + + ) : null} + + {canUpload ? ( + <> + + + + + + ) : null} + + {canEtAct && !t1.closed ? ( + arrived ? ( + + + The train has arrived — review the T1 documents and close (accept) them. + + + + ) : departed ? ( + }> + Train en route — T1 can be closed once it arrives in Ethiopia. + + ) : null + ) : null} + + ); +} + function StepStatus({ done, pendingLabel, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 753dcb2a5..eb48a89b1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -211,6 +211,10 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/documents`, BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) => `/contracts/bookings/${bookingId}/transport-document`, + BOOKING_T1_DOCUMENTS: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-documents`, + BOOKING_T1_CLOSE: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-close`, BOOKING_INCIDENTS: (bookingId: string) => `/contracts/bookings/${bookingId}/incidents`, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index ecf95fe89..6756ecc3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -112,7 +112,8 @@ export default function GlClearanceDetailPage() { const isImport = data.tradeDirection === "IMPORT"; const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order")); const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order")); - const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo); + // DO upload is un-gated — Djibouti GL may attach it at any point, any file type. + const canUploadDo = isImport; const vesselDepartureDate = "vesselDepartureDate" in data.clearance ? (data.clearance.vesselDepartureDate ?? null) 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 465cc302d..fe923d016 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -333,6 +333,27 @@ export const contractsService = { return unwrap(response.data); }, + /** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */ + uploadT1Documents: async ( + bookingId: string, + files: Record, + ) => { + const form = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } + const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return unwrap(response.data); + }, + + /** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */ + closeT1: async (bookingId: string): Promise => { + const response = await client.post(C.BOOKING_T1_CLOSE(bookingId)); + return unwrap(response.data) as Freight.ClearanceT1State; + }, + // ── Path A self-clearance (Operations review) ── getOpsClearanceQueue: async (): Promise => { const response = await client.get( diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index c445d67e9..a9bcf4ecc 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -20,18 +20,17 @@ "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "leaflet": "^1.9.4", "lucide-react": "^1.14.0", "radix-ui": "^1.4.3", "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", - "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", @@ -44,7 +43,7 @@ "@edr/tsconfig": "workspace:*", "@hookform/devtools": "^4.4.0", "@tailwindcss/vite": "^4.3.0", - "@types/leaflet": "^1.9.21", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 4aad2be68..176cd0aa9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -1,5 +1,3 @@ -import "leaflet/dist/leaflet.css"; - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, @@ -13,8 +11,14 @@ import { useCombobox, } from "@mantine/core"; import { Check, MapPin, Search } from "lucide-react"; -import L from "leaflet"; -import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { + APIProvider, + Map as GoogleMap, + type MapMouseEvent, + Marker, + useMap, + useMapsLibrary, +} from "@vis.gl/react-google-maps"; import { fieldStyles } from "./shared"; @@ -25,129 +29,93 @@ export interface LocationValue { lng: number | null; } -/** A single Nominatim search result, normalised to what the UI needs. */ +/** A single geocoding result, normalised to what the UI needs. */ interface GeocodeResult { displayName: string; lat: number; lng: number; } -// Leaflet's default marker icon URLs break under bundlers; point them at the -// CDN-hosted assets once so every map instance renders a visible pin. -const markerIcon = L.icon({ - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); +// Maps JavaScript API keys are public client-side keys (lock them down by +// HTTP-referrer in the Google Cloud console). The env var lets deployments +// override the default key without a code change. +const GOOGLE_MAPS_API_KEY = + import.meta.env.VITE_GOOGLE_MAPS_API_KEY || + "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; // Centre of the EDR corridor (Addis Ababa) — a sensible default view. -const DEFAULT_CENTER: [number, number] = [9.03, 38.74]; +const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; const DEFAULT_ZOOM = 6; const PINNED_ZOOM = 14; -const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; -const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; // Search only fires once the user pauses typing for this long. Slightly longer // than a keystroke burst so we make one request per pause, not per character. const SEARCH_DEBOUNCE_MS = 550; const MIN_QUERY_LEN = 2; // Bias geocoding toward the EDR corridor countries so local addresses surface -// first (Nominatim still returns global matches if nothing local fits). -const SEARCH_COUNTRYCODES = "et,dj"; -// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard -// floor a touch above 1s so a flurry of map clicks / searches can never trip -// the 429 ("Too Many Requests") wall. -const MIN_REQUEST_INTERVAL_MS = 1100; +// first (we retry globally if nothing local matches). +const SEARCH_COUNTRIES = ["ET", "DJ"]; +const MAX_RESULTS = 8; // Reverse-geocode precision: coordinates are rounded to ~11m before caching so // near-identical pin drags resolve from cache instead of re-hitting the API. const REVERSE_COORD_PRECISION = 4; -// ── Module-level rate-limited request queue ───────────────────────────────── -// Every Nominatim call (forward + reverse, across ALL picker instances on the -// page) funnels through one promise chain that spaces requests ≥1.1s apart. -let lastRequestAt = 0; -let queueTail: Promise = Promise.resolve(); - -function scheduleRequest(run: () => Promise): Promise { - const result = queueTail.then(async () => { - const now = Date.now(); - const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now); - if (wait > 0) await new Promise((r) => setTimeout(r, wait)); - lastRequestAt = Date.now(); - return run(); - }); - // Keep the chain alive even if this request rejects, so one failure doesn't - // stall every queued request behind it. - queueTail = result.catch(() => undefined); - return result; -} - // Simple in-memory caches keyed by the normalized query / rounded coordinate. const searchCache = new Map(); const reverseCache = new Map(); -/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */ -async function nominatimSearch( - query: string, - signal: AbortSignal, - countryCodes?: string, +/** + * One Geocoder request, normalised. The promise-based `geocode` rejects on + * ZERO_RESULTS (and any other non-OK status), so failures collapse to "no + * matches" rather than surfacing as an error state. + */ +async function geocode( + geocoder: google.maps.Geocoder, + request: google.maps.GeocoderRequest, ): Promise { - const params = new URLSearchParams({ - q: query, - format: "jsonv2", - addressdetails: "0", - limit: "8", - }); - if (countryCodes) params.set("countrycodes", countryCodes); - const res = await fetch(`${NOMINATIM_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return []; - const data = (await res.json()) as Array<{ - display_name: string; - lat: string; - lon: string; - }>; - return data.map((d) => ({ - displayName: d.display_name, - lat: Number(d.lat), - lng: Number(d.lon), - })); + try { + const { results } = await geocoder.geocode(request); + return results.slice(0, MAX_RESULTS).map((r) => ({ + displayName: r.formatted_address, + lat: r.geometry.location.lat(), + lng: r.geometry.location.lng(), + })); + } catch { + return []; + } } /** - * Forward-geocode a free-text query. Served from cache when possible; otherwise - * queued (rate-limited) and tried EDR-corridor-first, then global, so local - * addresses rank highest without the field ever looking "broken". + * Forward-geocode a free-text query. Served from cache when possible; + * otherwise tried EDR-corridor-first, then global, so local addresses rank + * highest without the field ever looking "broken". */ async function searchPlaces( + geocoder: google.maps.Geocoder, query: string, - signal: AbortSignal, ): Promise { const key = query.trim().toLowerCase(); const cached = searchCache.get(key); if (cached) return cached; - const found = await scheduleRequest(async () => { - if (signal.aborted) return []; - const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES); - if (local.length > 0) return local; - return nominatimSearch(query, signal); - }); + // The Geocoder only accepts one country restriction per request, so the + // corridor pass fans out to one request per country and merges in order. + const perCountry = await Promise.all( + SEARCH_COUNTRIES.map((country) => + geocode(geocoder, { address: query, componentRestrictions: { country } }), + ), + ); + const local = perCountry.flat().slice(0, MAX_RESULTS); + const found = local.length > 0 ? local : await geocode(geocoder, { address: query }); if (found.length > 0) searchCache.set(key, found); return found; } -/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */ +/** Reverse-geocode a dropped pin to its nearest address (cached). */ async function reverseGeocode( + geocoder: google.maps.Geocoder, lat: number, lng: number, - signal?: AbortSignal, ): Promise { const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed( REVERSE_COORD_PRECISION, @@ -155,63 +123,33 @@ async function reverseGeocode( const cached = reverseCache.get(key); if (cached != null) return cached; - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lng), - format: "json", - }); - try { - const address = await scheduleRequest(async () => { - if (signal?.aborted) return ""; - const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return ""; - const data = (await res.json()) as { display_name?: string }; - return data.display_name ?? ""; - }); - reverseCache.set(key, address); - return address; - } catch { - return ""; - } + const [best] = await geocode(geocoder, { location: { lat, lng } }); + const address = best?.displayName ?? ""; + reverseCache.set(key, address); + return address; } -/** - * Leaflet computes its tile layout from the container size at mount. When the - * map is revealed inside a just-toggled section it can mount before layout - * settles and render grey tiles — invalidating the size on the next frame - * forces a correct redraw. - */ -function InvalidateSizeOnMount() { - const map = useMap(); - useEffect(() => { - const id = setTimeout(() => map.invalidateSize(), 0); - return () => clearTimeout(id); - }, [map]); - return null; +/** Lazily constructs a Geocoder once the geocoding library has loaded. */ +function useGeocoder(): google.maps.Geocoder | null { + const geocodingLib = useMapsLibrary("geocoding"); + return useMemo( + () => (geocodingLib ? new geocodingLib.Geocoder() : null), + [geocodingLib], + ); } /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); useEffect(() => { - if (lat != null && lng != null) { - map.setView([lat, lng], PINNED_ZOOM, { animate: true }); + if (map && lat != null && lng != null) { + map.panTo({ lat, lng }); + map.setZoom(PINNED_ZOOM); } }, [lat, lng, map]); return null; } -/** Captures map clicks and forwards the dropped coordinate. */ -function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) { - useMapEvents({ - click: (e) => onPick(e.latlng.lat, e.latlng.lng), - }); - return null; -} - export interface LocationPickerProps { value: LocationValue; onChange: (value: LocationValue) => void; @@ -227,14 +165,21 @@ export interface LocationPickerProps { } /** - * Address + map location picker backed by free OpenStreetMap services: - * - type to search (Nominatim forward geocoding), - * - or click anywhere on the map to drop a pin (Nominatim reverse geocoding). + * Address + map location picker backed by Google Maps: + * - type to search (Geocoding API forward geocoding, debounced), + * - or click anywhere on the map to drop a pin (reverse geocoding). * Reports the resolved address and coordinates up via `onChange`. */ export function LocationPicker(props: LocationPickerProps) { - if (props.variant === "modal") return ; - return ; + return ( + + {props.variant === "modal" ? ( + + ) : ( + + )} + + ); } /** Compact trigger + modal wrapper around the inline picker. */ @@ -343,12 +288,13 @@ function LocationPickerInline({ withinPortal = true, }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { const combobox = useCombobox(); + const geocoder = useGeocoder(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); - const abortRef = useRef(null); - const reverseAbortRef = useRef(null); + const searchStaleRef = useRef<{ stale: boolean } | null>(null); + const reverseStaleRef = useRef<{ stale: boolean } | null>(null); const hasPin = value.lat != null && value.lng != null; @@ -366,32 +312,31 @@ function LocationPickerInline({ } setSearching(true); combobox.openDropdown(); - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; + if (!geocoder) return; // re-runs once the geocoding library loads + // The Geocoder has no abort support, so a token marks superseded requests + // and their responses are dropped instead of overwriting newer results. + const token = { stale: false }; + searchStaleRef.current = token; const handle = setTimeout(async () => { - try { - const found = await searchPlaces(q, controller.signal); - if (controller.signal.aborted) return; - setResults(found); - combobox.openDropdown(); - } catch (err) { - // Ignore aborts (a newer keystroke superseded this request). - if ((err as Error)?.name !== "AbortError") setResults([]); - } finally { - if (!controller.signal.aborted) setSearching(false); - } + const found = await searchPlaces(geocoder, q); + if (token.stale) return; + setResults(found); + setSearching(false); + combobox.openDropdown(); }, SEARCH_DEBOUNCE_MS); - // Cancel both the pending debounce AND any in-flight request when the query - // changes, so a stale response can't overwrite newer results. return () => { clearTimeout(handle); - controller.abort(); + token.stale = true; }; - }, [query, combobox]); + }, [query, geocoder, combobox]); - // Abort any in-flight reverse lookup when the picker unmounts. - useEffect(() => () => reverseAbortRef.current?.abort(), []); + // Drop any in-flight reverse lookup when the picker unmounts. + useEffect( + () => () => { + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + }, + [], + ); const selectResult = useCallback( (r: GeocodeResult) => { @@ -407,13 +352,14 @@ function LocationPickerInline({ async (lat: number, lng: number) => { // Show the pin immediately; fill the address once reverse geocoding lands. onChange({ address: value.address, lat, lng }); - // Cancel any in-flight reverse lookup — only the latest dropped pin counts. - reverseAbortRef.current?.abort(); - const controller = new AbortController(); - reverseAbortRef.current = controller; + if (!geocoder) return; + // Mark any in-flight reverse lookup stale — only the latest pin counts. + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + const token = { stale: false }; + reverseStaleRef.current = token; setResolving(true); - const address = await reverseGeocode(lat, lng, controller.signal); - if (controller.signal.aborted) return; // a newer pin superseded this one + const address = await reverseGeocode(geocoder, lat, lng); + if (token.stale) return; // a newer pin superseded this one setResolving(false); onChange({ address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, @@ -421,14 +367,21 @@ function LocationPickerInline({ lng, }); }, - [onChange, value.address], + [onChange, value.address, geocoder], + ); + + const handleMapClick = useCallback( + (e: MapMouseEvent) => { + const latLng = e.detail.latLng; + if (latLng) void handlePin(latLng.lat, latLng.lng); + }, + [handlePin], ); const inputValue = query || value.address; - const center = useMemo<[number, number]>( - () => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER), - [hasPin, value.lat, value.lng], - ); + const center = hasPin + ? { lat: value.lat as number, lng: value.lng as number } + : DEFAULT_CENTER; return ( @@ -494,26 +447,23 @@ function LocationPickerInline({ border: "1px solid #E6ECF2", }} > - - - - {hasPin && ( )} - + diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index d755e510b..b24245689 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -16,6 +16,7 @@ interface Window { interface ImportMetaEnv { readonly VITE_API_URL: string; + readonly VITE_GOOGLE_MAPS_API_KEY?: string; } interface ImportMeta { diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts index fe6e9ebbf..307965dd8 100644 --- a/packages/types/src/freight/clearance-files.catalog.ts +++ b/packages/types/src/freight/clearance-files.catalog.ts @@ -33,6 +33,13 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[ }, { code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" }, { code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" }, + { + code: "t1_transport_document", + label: "T1 Transport Document", + uploadedBy: "gl_dj", + category: "djibouti", + tradeDirection: "IMPORT", + }, ]; /** Legacy single-type declaration codes (still shown when already uploaded). */ @@ -101,6 +108,27 @@ export function transitPermitFileLabel(code: string, index?: number): string { return code; } +/** Legacy single T1 code (GL post-booking uploader). */ +export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document"; + +/** Multi-file T1 transport uploads use `t1_transport_document_0`, `_1`, … */ +export const T1_TRANSPORT_FILE_PREFIX = "t1_transport_document_"; + +export function isT1TransportFileCode(code: string | null | undefined): boolean { + if (!code) return false; + const lower = code.toLowerCase(); + return lower === LEGACY_T1_TRANSPORT_CODE || lower.startsWith(T1_TRANSPORT_FILE_PREFIX); +} + +export function t1TransportFileLabel(code: string, index?: number): string { + const lower = code.toLowerCase(); + if (lower === LEGACY_T1_TRANSPORT_CODE) return "T1 Transport Document"; + if (lower.startsWith(T1_TRANSPORT_FILE_PREFIX)) { + return index != null ? `T1 transport document ${index + 1}` : "T1 Transport Document"; + } + return code; +} + export const LEGACY_EXPORT_TRANSPORT_CODE = "export_transport_document"; export function isExportTransportFileCode(code: string | null | undefined): boolean { diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index f4c0b3380..c9d698f86 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -242,6 +242,20 @@ export interface ContractClearanceDocument { reviewedByStaffId?: string | null; } +/** + * Post-allocation T1 transit document state for the booking linked to an import + * customs flow. GL Djibouti uploads after wagon allocation; uploads lock once the + * train departs; GL Ethiopia closes (accepts) T1 when the train arrives. + */ +export interface ClearanceT1State { + bookingId: string; + wagonAllocated: boolean; + trainDepartedAt: string | null; + trainArrivedAt: string | null; + closed: boolean; + closedAt?: string | null; +} + export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ @@ -283,6 +297,8 @@ export interface ContractClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; } export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS"; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 2def8fa75..b05c9d8d1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -548,6 +548,8 @@ export interface ClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: import("./contracts").ClearanceT1State | null; } /** Company an invoice is billed to (minimal projection). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8051dab4a..abd86df8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + '@vis.gl/react-google-maps': + specifier: ^1.8.3 + version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) axios: specifier: ^1.7.7 version: 1.17.0 @@ -356,9 +359,6 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 - leaflet: - specifier: ^1.9.4 - version: 1.9.4 lucide-react: specifier: ^1.14.0 version: 1.17.0(react@19.2.6) @@ -377,9 +377,6 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-leaflet: - specifier: ^5.0.0 - version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-phone-number-input: specifier: ^3.4.17 version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -411,9 +408,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@types/leaflet': - specifier: ^1.9.21 - version: 1.9.21 + '@types/google.maps': + specifier: ^3.65.2 + version: 3.65.2 '@types/react': specifier: ^18.3.11 version: 18.3.31 @@ -1701,6 +1698,9 @@ packages: reflect-metadata: ^0.2.2 rxjs: ^7.x + '@googlemaps/js-api-loader@2.1.1': + resolution: {integrity: sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==} + '@hello-pangea/dnd@18.0.1': resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} peerDependencies: @@ -3515,13 +3515,6 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@react-leaflet/core@3.0.0': - resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - '@react-pdf-viewer/attachment@3.12.0': resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==} peerDependencies: @@ -4265,8 +4258,8 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/google.maps@3.65.2': + resolution: {integrity: sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==} '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4303,9 +4296,6 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} - '@types/leaflet@1.9.21': - resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} - '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} @@ -4609,6 +4599,12 @@ packages: cpu: [x64] os: [win32] + '@vis.gl/react-google-maps@1.8.3': + resolution: {integrity: sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==} + peerDependencies: + react: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -7978,9 +7974,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - leaflet@1.9.4: - resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -9368,13 +9361,6 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-leaflet@5.0.0: - resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: @@ -12134,6 +12120,10 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 + '@googlemaps/js-api-loader@2.1.1': + dependencies: + '@types/google.maps': 3.65.2 + '@hello-pangea/dnd@18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -14803,12 +14793,6 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@react-pdf-viewer/attachment@3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15809,7 +15793,7 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 - '@types/geojson@7946.0.16': {} + '@types/google.maps@3.65.2': {} '@types/graceful-fs@4.1.9': dependencies: @@ -15847,10 +15831,6 @@ snapshots: dependencies: '@types/node': 20.19.42 - '@types/leaflet@1.9.21': - dependencies: - '@types/geojson': 7946.0.16 - '@types/lodash@4.17.24': {} '@types/luxon@3.7.1': {} @@ -16142,6 +16122,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vis.gl/react-google-maps@1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@googlemaps/js-api-loader': 2.1.1 + '@types/google.maps': 3.65.2 + fast-deep-equal: 3.1.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 @@ -20147,8 +20135,6 @@ snapshots: dependencies: readable-stream: 2.3.8 - leaflet@1.9.4: {} - leven@3.1.0: {} levn@0.4.1: @@ -21629,13 +21615,6 @@ snapshots: react-is@19.2.7: {} - react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 From 60e0c269430fae98cdd4c4b864f6e97285420032 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 2 Jul 2026 16:35:54 +0300 Subject: [PATCH 20/40] feat: ( iam ) add portal auth registration, forgot/change password, Fayda setup --- .../portal/src/app/fayda-setup/page.tsx | 5 + .../portal/src/app/forgot-password/page.tsx | 104 +++++++ .../portal/src/app/login/page.tsx | 29 +- .../portal/src/app/register/page.tsx | 176 ++++++++++++ .../portal/src/app/reset-password/page.tsx | 154 +++++++++++ .../portal/src/app/set-password/page.tsx | 24 ++ .../portal/src/components/AppHeader.tsx | 94 ++++++- .../src/components/ChangePasswordModal.tsx | 157 +++++++++++ .../src/components/FaydaSetupWizard.tsx | 256 ++++++++++++++++++ .../portal/src/lib/api/auth.ts | 51 ++++ .../portal/src/lib/auth-store.ts | 14 +- 11 files changed, 1058 insertions(+), 6 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/register/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/reset-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/set-password/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx create mode 100644 apps/edr-passenger-web/portal/src/lib/api/auth.ts diff --git a/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx new file mode 100644 index 000000000..8f36e4039 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx @@ -0,0 +1,5 @@ +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +export default function FaydaSetupPage() { + return ; +} diff --git a/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx new file mode 100644 index 000000000..254b65eb8 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Train, MailCheck, ArrowLeft } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [sent, setSent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.forgotPassword(email); + setSent(true); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError( + msg === 'user_not_found' + ? 'No account found with that email address.' + : msg || 'Failed to send the reset link. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Reset your password

+

+ Enter your email and we'll send a reset link to the phone number on your account. +

+
+ +
+ {sent ? ( +
+
+ +

+ A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes. +

+
+ + + Back to sign in + +
+ ) : ( + <> +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setEmail(e.target.value); setError(''); }} + className="input-field" + placeholder="your@email.com" + autoComplete="email" + required + /> +
+ + +
+ +
+ + + Back to sign in + +
+ + )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 86ac4dcce..9453c39e2 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -6,7 +6,8 @@ import { z } from 'zod'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useState, Suspense } from 'react'; -import { Train } from 'lucide-react'; +import Link from 'next/link'; +import { Train, ShieldCheck } from 'lucide-react'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), @@ -85,6 +86,14 @@ function LoginContent() { {errors.password && (

{errors.password.message}

)} +
+ + Forgot password? + +
-
+
+
+ Don't have an account? + + Create account + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ Already have an account? + + Sign in + +
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx new file mode 100644 index 000000000..da61c9da5 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +function ResetPasswordContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const verificationCode = searchParams.get('verificationCode') || ''; + const linkValid = Boolean(email && userId && verificationCode); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + await iamAuthApi.resetPassword({ userId, email, verificationCode, newPassword, confirmPassword }); + setSuccess(true); + setTimeout(() => router.push('/login'), 2000); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Failed to reset password. The link may have expired — request a new one.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Set a new password

+ {linkValid && !success && ( +

+ Choose a new password for {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This password reset link is invalid or incomplete. Request a new one from the sign-in page. +
+ + Request a new link + + + + Back to sign in + +
+ ) : success ? ( +
+
+ +

Password reset successfully. Redirecting to sign in…

+
+ + Go to sign in + + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + + + Back to sign in + + + )} +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/set-password/page.tsx b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx new file mode 100644 index 000000000..2c270f727 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +// Landing page for the IAM's Fayda set-password SMS link: +// ${FE_BASE_URL}/set-password?email=..&userId=..&verificationCode=.. +// The wizard starts at step 2 with the code prefilled; the user enters their +// phone number (verify-and-login requires it) and a new password. +function SetPasswordContent() { + const searchParams = useSearchParams(); + const verificationCode = searchParams.get('verificationCode') || ''; + + return ; +} + +export default function SetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 6422e12fa..c3d68365c 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -1,18 +1,24 @@ "use client"; -import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; +import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; +import { useAuthStore } from "@/lib/auth-store"; +import ChangePasswordModal from "@/components/ChangePasswordModal"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); const [isDark, setIsDark] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); + const { user, isAuthenticated, initialize, logout } = useAuthStore(); useEffect(() => { const isDarkMode = document.documentElement.classList.contains("dark"); setIsDark(isDarkMode); - }, []); + initialize(); + }, [initialize]); const toggleTheme = () => { const html = document.documentElement; @@ -84,6 +90,67 @@ export default function AppHeader() { )} + {/* Auth */} + {isAuthenticated && user ? ( +
+ + + {showUserMenu && ( +
+
+

{user.fullName}

+

{user.email}

+
+
+ + +
+
+ )} +
+ ) : ( +
+ + Sign in + + + Register + +
+ )} + {/* Mobile Menu Button */} + +
+ {success && ( +
+ +

Password changed successfully.

+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + { setCurrentPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="current-password" + required + /> +
+
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + +
+
+ + , + document.body + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx new file mode 100644 index 000000000..92f3843fc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +interface FaydaSetupWizardProps { + // Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...) + initialOtp?: string; +} + +type Outcome = 'success' | 'hasPassword' | null; + +export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps) { + const router = useRouter(); + const [step, setStep] = useState<1 | 2>(initialOtp ? 2 : 1); + const [phone, setPhone] = useState(''); + const [otp, setOtp] = useState(initialOtp || ''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [outcome, setOutcome] = useState(null); + + const handleRequestCode = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.faydaRequestPasswordSetup(phone); + setStep(2); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to send the code. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + const res = await iamAuthApi.faydaVerifyAndLogin({ phoneNumber: phone, otp }); + const data = (res.data as any)?.data ?? res.data; + if (!data.requiresPassword) { + setOutcome('hasPassword'); + return; + } + await iamAuthApi.setFaydaPassword( + { userId: data.iamUserId, newPassword, confirmPassword }, + data.token, + ); + setOutcome('success'); + setTimeout(() => router.push('/login'), 2500); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Verification failed. The code may be wrong or expired.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

+ + Fayda account setup +

+

+ Already verified with Fayda? Set a password to access your account online. +

+
+ +
+ {outcome === 'success' ? ( +
+
+ +

+ Your password has been set and your account is now active. Redirecting to sign in… +

+
+ + Go to sign in + + +
+ ) : outcome === 'hasPassword' ? ( +
+
+ +

+ This account already has a password. Sign in with your email or phone number, + or use forgot password if you can't remember it. +

+
+ + Sign in + + + Forgot password? + +
+ ) : step === 1 ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +

+ The phone number you used during Fayda verification. +

+
+ + +
+ ) : ( +
+
+ +

+ If this phone number is Fayda-verified, an SMS with a verification code has been sent. + Enter it below with your new password. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +
+ +
+ + { setOtp(e.target.value); setError(''); }} + className="input-field" + placeholder="6-character code from SMS" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + +
+ )} + + {outcome === null && ( +
+ + + Back to sign in + +
+ )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts new file mode 100644 index 000000000..aa5272173 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +// IAM (/v1/auth/*) and Fayda (/auth/fayda/*) endpoints use raw axios instead of +// apiClient: apiClient's response interceptor clears the token and redirects to +// /login on any 401 for non-public URLs — but the IAM returns 401 when the +// current password is wrong on change-password, and OTP failures must surface +// as inline errors, not a logout. +export const iamAuthApi = { + forgotPassword: (email: string) => + axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + + // Completes the forgot-password flow using the link sent via SMS: + // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. + resetPassword: (data: { + userId: string; + email: string; + verificationCode: string; + newPassword: string; + confirmPassword: string; + }) => axios.patch(`${API_URL}/v1/auth/set-password`, data), + + changePassword: (data: { + oldPassword: string; + newPassword: string; + confirmPassword: string; + }) => + axios.patch(`${API_URL}/v1/auth/change-password`, data, { + headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }, + }), + + faydaRequestPasswordSetup: (phoneNumber: string) => + axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }), + + faydaVerifyAndLogin: (data: { phoneNumber: string; otp: string }) => + axios.post<{ + success: boolean; + data: { token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }; + }>(`${API_URL}/auth/fayda/verify-and-login`, data), + + // Bearer token comes from faydaVerifyAndLogin's response, not localStorage — + // the user is not logged into the portal at this point. + setFaydaPassword: ( + data: { userId: string; newPassword: string; confirmPassword: string }, + token: string, + ) => + axios.patch(`${API_URL}/v1/auth/set-fayda-password`, data, { + headers: { Authorization: `Bearer ${token}` }, + }), +}; diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 72a89ce87..2157cfc0a 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -40,10 +40,11 @@ interface AuthState { } interface RegisterData { + fullName: string; email: string; phone: string; - fullName: string; password: string; + confirmPassword: string; } export const useAuthStore = create((set, get) => ({ @@ -118,7 +119,16 @@ export const useAuthStore = create((set, get) => ({ }, register: async (data: RegisterData) => { - const response: any = await apiClient.post('/auth/register', data); + // Shape required by the passenger-api RegisterDto; username = email by convention. + const payload = { + email: data.email, + username: data.email, + phoneNumber: data.phone, + name: { en: data.fullName, am: data.fullName }, + password: data.password, + confirmPassword: data.confirmPassword, + }; + const response: any = await apiClient.post('/auth/register', payload); const { token, user } = response.data || response; if (typeof window !== 'undefined') { From 71894ae2d3442baa7c0984ea4cbd38b5a53104ef Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:43:07 +0000 Subject: [PATCH 21/40] merge conflict --- apps/edr-freight-api/src/app.module.ts | 4 - .../seed/paid-indode-demo-bookings.seeder.ts | 1131 ----------------- .../src/pages/billing/InvoiceDetailPage.tsx | 30 - 3 files changed, 1165 deletions(-) delete mode 100644 apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a81a539ba..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,7 +59,6 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; -import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -161,7 +160,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, - PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -180,7 +178,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, - private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -202,7 +199,6 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); - await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts deleted file mode 100644 index e33baa7fd..000000000 --- a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts +++ /dev/null @@ -1,1131 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; -import { randomUUID } from 'crypto'; -import { DataSource, EntityManager, In } from 'typeorm'; - -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { - Company, - CompanyKind, - CompanyNationality, - CompanyStatus, - CompanyType, -} from '../modules/companies/entities/company.entity'; -import { - CompanyProfile, - ProfileStatus, - ProfileType, -} from '../modules/companies/entities/company-profile.entity'; -import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; -import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; -import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; -import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; -import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { Wagon } from '../modules/wagons/entities/wagon.entity'; -import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; -import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; -import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; -import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; -import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; -import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; - -const CUSTOMER_TIN = 'US12DEMO01'; - -const DEMO_TRAINS = [ - { - trainNumber: 'US12-DJI-IND-01', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 30, - arrivalHoursAgo: 14, - }, - { - trainNumber: 'US12-IND-DJI-01', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 28, - arrivalHoursAgo: 12, - }, - { - trainNumber: 'US12-DJI-IND-LM-02', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 24, - arrivalHoursAgo: 8, - }, - { - trainNumber: 'US12-IND-DJI-LM-02', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 22, - arrivalHoursAgo: 6, - }, -] as const; - -const TRAIN_DEMO_BOOKINGS = [ - { - reference: 'US12-IMP-FM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 27, - totalAmount: 18450, - pickupAddress: 'Doraleh Container Terminal, Djibouti', - pickupLat: 11.5881, - pickupLng: 43.1372, - deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', - deliveryLat: 8.7566, - deliveryLng: 38.9846, - }, - { - reference: 'US12-IMP-NOFM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 42, - totalAmount: 22100, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-FM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 19, - totalAmount: 15680, - pickupAddress: 'Indode export truck gate, Ethiopia', - pickupLat: 8.7566, - pickupLng: 38.9846, - deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', - deliveryLat: 11.5536, - deliveryLng: 43.1103, - }, - { - reference: 'US12-EXP-NOFM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 55, - totalAmount: 29800, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-IMP-LM-TRAIN-001', - trainNumber: 'US12-DJI-IND-LM-02', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 31, - totalAmount: 20300, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', - deliveryLat: 8.7581, - deliveryLng: 38.9834, - }, - { - reference: 'US12-EXP-LM-TRAIN-001', - trainNumber: 'US12-IND-DJI-LM-02', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 21, - totalAmount: 17600, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', - deliveryLat: 11.5549, - deliveryLng: 43.1121, - }, -] as const; - -const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ - { - reference: 'US12-EXP-FM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: false, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 24, - totalAmount: 14800, - pickupAddress: 'Customer factory gate, Addis Ababa', - pickupLat: 8.9806, - pickupLng: 38.8736, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-NOFM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: false, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 18, - totalAmount: 11200, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - customerTruckPlateNumber: 'ET-CUS-2046', - customerTruckDriverName: 'Dawit Customer Carrier', - customerTruckType: 'Container Chassis', - customerTruckContainerNumber: 'USDU1234567', - }, -] as const; - -const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; - -@Injectable() -export class PaidIndodeDemoBookingsSeeder { - private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run(): Promise { - try { - await this.dataSource.transaction(async (manager) => { - const refs = await this.ensureReferenceData(manager); - const schedules = await this.ensureArrivedTrains(manager, refs); - const bookings = await this.ensureBookings(manager, refs, schedules); - await this.ensureTrainLinks(manager, refs, schedules, bookings); - await this.ensureGatepasses(manager, schedules); - await this.ensureImportWarehouseInventory(manager, bookings); - }); - - this.logger.log( - `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, - ); - } catch (error) { - this.logger.error( - `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - private async ensureReferenceData(manager: EntityManager) { - await manager.getRepository(Yard).upsert( - [ - { - code: 'INDODE', - label: 'Indode Terminal', - country: 'Ethiopia', - isActive: true, - displayOrder: 1, - }, - { - code: 'NAGAD', - label: 'Nagad Terminal, Djibouti', - country: 'Djibouti', - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ServiceType).upsert( - [ - { - code: 'RAIL_CONTAINER_FIRST_LAST', - serviceName: 'Rail Freight with First and Last Mile', - description: 'Rail movement with first-mile pickup and last-mile delivery', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 3, - }, - { - code: 'RAIL_CONTAINER_LAST_MILE', - serviceName: 'Rail Freight with Last Mile', - description: 'Rail movement with last-mile delivery from terminal', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 8, - isActive: true, - displayOrder: 4, - }, - { - code: 'RAIL_CONTAINER', - serviceName: 'Rail Freight', - description: 'Rail movement without first-mile pickup', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: 'RAIL_CONTAINER_FIRST_MILE', - serviceName: 'Rail Freight with First Mile', - description: 'Rail movement with first-mile pickup to terminal', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ContainerType).upsert( - [ - { - code: '20FT', - label: '20FT Standard', - sizeFt: 20, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 1, - }, - { - code: '40FT', - label: '40FT Standard', - sizeFt: 40, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(CargoType).upsert( - [ - { - code: 'GENERAL_CARGO', - cargoTypeName: 'General Cargo', - showFreeTextBox: true, - unitOfMeasure: null, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 1, - }, - { - code: 'BULK', - cargoTypeName: 'Bulk Cargo', - showFreeTextBox: true, - unitOfMeasure: CargoUnitOfMeasure.PerTon, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(WagonType).upsert( - { - code: 'US12-DEMO', - name: 'US12 Demo Flat/Bulk Wagon', - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER', 'BULK'], - isActive: true, - equatedLengthM: 14, - tareWeightTons: 14, - supportsContainer: true, - maxContainerGrossT: 40, - }, - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(Company).upsert( - { - name: 'US12 Indode Demo Customer PLC', - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin: CUSTOMER_TIN, - vatNumber: 'VAT-US12-001', - fanNumber: 'US12000000000001', - country: 'Ethiopia', - nationality: CompanyNationality.Ethiopian, - address: 'Bole Road, Addis Ababa, Ethiopia', - phone: '251911120012', - email: 'us12.indode.demo@edr.local', - website: 'https://edr.local/us12-demo', - contactPersonName: 'Aster Bekele', - contactPersonPhone: '251911120013', - generalManagerName: 'Mekonnen Desta', - generalManagerEmail: 'manager.us12.demo@edr.local', - generalManagerPhone: '251911120014', - licenceNumber: 'LIC-US12-2026', - region: 'Addis Ababa', - zone: 'Bole', - woreda: '03', - kebele: '12', - houseNo: 'US12-01', - attributes: { - seededBy: 'PaidIndodeDemoBookingsSeeder', - note: 'Paid customer with import/export demo bookings for US12.', - } as any, - }, - { conflictPaths: { tin: true } }, - ); - - const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); - await manager.getRepository(CompanyProfile).upsert( - [ - { - companyId: company.id, - type: ProfileType.importer, - reference: 'US12-IMP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-IMP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - { - companyId: company.id, - type: ProfileType.exporter, - reference: 'US12-EXP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-EXP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - ], - { conflictPaths: { reference: true } }, - ); - - const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = - await Promise.all([ - manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), - manager - .getRepository(ServiceType) - .find({ - where: { - code: In([ - 'RAIL_CONTAINER', - 'RAIL_CONTAINER_FIRST_MILE', - 'RAIL_CONTAINER_LAST_MILE', - 'RAIL_CONTAINER_FIRST_LAST', - ]), - }, - }), - manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), - manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), - manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), - ]); - - return { - company, - importerProfile, - exporterProfile, - yards: new Map(yards.map((yard) => [yard.code, yard])), - serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), - containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), - cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), - wagonType, - }; - } - - private async ensureArrivedTrains( - manager: EntityManager, - refs: Awaited>, - ): Promise> { - const schedules = new Map(); - const now = new Date(); - - for (const demo of DEMO_TRAINS) { - const origin = refs.yards.get(demo.originCode); - const destination = refs.yards.get(demo.destinationCode); - if (!origin || !destination) { - throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); - } - - const departure = this.addHours(now, -demo.departureHoursAgo); - const arrival = this.addHours(now, -demo.arrivalHoursAgo); - const locomotive = await this.ensureLocomotive(manager, origin.id); - const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); - const schedule = await this.ensureTrainSchedule(manager, { - trainNumber: demo.trainNumber, - trainSetId: trainSet.id, - originStationId: origin.id, - destinationStationId: destination.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualDepartureAt: departure, - actualArrivalAt: arrival, - direction: demo.direction, - }); - - await manager.getRepository(TrainSet).update(trainSet.id, { - totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) - .reduce((sum, booking) => sum + booking.weightTons, 0), - totalLengthMeters: 28, - wagonCount: 2, - status: 'COMPLETED', - }); - schedules.set(demo.trainNumber, schedule); - } - - return schedules; - } - - private async ensureBookings( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - ): Promise> { - const bookingRepo = manager.getRepository(Booking); - const bookingContainerRepo = manager.getRepository(BookingContainer); - const firstMileRepo = manager.getRepository(FirstMile); - const lastMileRepo = manager.getRepository(LastMile); - const now = new Date(); - const references = DEMO_BOOKINGS.map((booking) => booking.reference); - const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); - const existingBookingIds = existingBookings.map((booking) => booking.id); - const firstMileVehicle = await this.ensureFirstMileVehicle(manager); - - if (existingBookingIds.length) { - const existingInventory = await manager.getRepository(WarehouseInventory).find({ - where: { bookingId: In(existingBookingIds) }, - select: { id: true }, - }); - const existingInventoryIds = existingInventory.map((item) => item.id); - if (existingInventoryIds.length) { - await manager.getRepository(WarehouseActivityLog).delete({ - inventoryId: In(existingInventoryIds), - }); - await manager.getRepository(WarehouseInventory).delete({ - id: In(existingInventoryIds), - }); - } - await this.deleteBookingTrainChildren(manager, existingBookingIds); - await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); - await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); - await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); - } - - for (const demo of DEMO_BOOKINGS) { - const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; - if (demo.trainNumber && !schedule) { - throw new Error(`US12 demo booking missing train: ${demo.reference}`); - } - - const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; - const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); - const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); - const origin = originCode ? refs.yards.get(originCode) : null; - const destination = destinationCode ? refs.yards.get(destinationCode) : null; - const serviceType = refs.serviceTypes.get( - demo.withFirstMile && demo.withLastMile - ? 'RAIL_CONTAINER_FIRST_LAST' - : demo.withFirstMile - ? 'RAIL_CONTAINER_FIRST_MILE' - : demo.withLastMile - ? 'RAIL_CONTAINER_LAST_MILE' - : 'RAIL_CONTAINER', - ); - const cargoType = refs.cargoTypes.get(demo.cargoCode); - const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; - - if (!origin || !destination || !serviceType || !cargoType) { - throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); - } - - await bookingRepo.upsert( - { - reference: demo.reference, - companyId: refs.company.id, - companyProfileId: profile.id, - isGovernment: false, - status: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? 'TRUCK_ASSIGNED' - : demo.trainNumber - ? 'IN_TRANSIT' - : 'PAID', - scheduledDate: schedule?.scheduledDepartureDate ?? now, - estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, - totalAmount: demo.totalAmount, - paymentStatus: 'PAID', - contractType: 'NEW', - serviceTypeId: serviceType.id, - firstMilePickupAddress: demo.pickupAddress, - firstMilePickupLat: demo.pickupLat, - firstMilePickupLng: demo.pickupLng, - lastMileDeliveryAddress: demo.deliveryAddress, - lastMileDeliveryLat: demo.deliveryLat, - lastMileDeliveryLng: demo.deliveryLng, - customerTruckPlateNumber: - 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, - customerTruckDriverName: - 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, - customerTruckType: - 'customerTruckType' in demo ? demo.customerTruckType : null, - customerTruckContainerNumber: - 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, - customerTruckAssignedAt: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? this.addHours(now, -2) - : null, - customerTruckArrivedAt: null, - customsClearingEnabled: false, - equipmentReturn: 'WITHOUT_RETURN', - originYardId: origin.id, - destinationYardId: destination.id, - tradeDirection: demo.tradeDirection, - freightType: demo.freightType, - cargoTypeId: cargoType.id, - cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', - shippingLineId: null, - cargoTotalWeightVgm: demo.weightTons, - isHazardous: false, - isReefer: false, - paymentCurrency: 'ETB', - pnrCode: `PNR-${demo.reference}`, - versionNumber: 1, - approvedByStaffAt: now, - customerSignedAt: now, - fullyExecutedAt: now, - pricingBreakdown: { - paid: true, - source: 'PaidIndodeDemoBookingsSeeder', - firstMileIncluded: demo.withFirstMile, - lastMileIncluded: demo.withLastMile, - }, - priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, - wagonsRequired: 1, - schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', - scheduledAt: demo.trainNumber ? now : null, - trainScheduleId: schedule?.id ?? null, - paymentDeadline: null, - selectedForBatchAt: demo.trainNumber ? now : null, - }, - { conflictPaths: { reference: true } }, - ); - - const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); - - if (demo.freightType === 'CONTAINER' && demo.containerCode) { - const containerType = refs.containerTypes.get(demo.containerCode); - if (!containerType) { - throw new Error(`US12 demo booking missing container type: ${demo.reference}`); - } - await bookingContainerRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - containerTypeId: containerType.id, - containerNumber: this.containerNumber(demo.reference), - containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', - quantity: 1, - hazardousQuantity: 0, - reeferQuantity: 0, - vgmPerUnitTons: demo.weightTons, - totalVgmTons: demo.weightTons, - wagonsRequired: 1, - weightLimitRuleId: null, - isOverweight: false, - overweightExcessTons: null, - }); - } - - if (demo.withFirstMile) { - await firstMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'RECEIVED_TO_PORT', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, - exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, - vehicleId: firstMileVehicle.id, - }); - } - - if (demo.withLastMile) { - await lastMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'DELIVERED', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, - exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, - vehicleId: null, - }); - } - } - - const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); - return new Map(savedBookings.map((booking) => [booking.reference, booking])); - } - - private async ensureTrainLinks( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - bookings: Map, - ): Promise { - const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); - const trainSetWagonRepo = manager.getRepository(TrainSetWagon); - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); - const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; - const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; - - for (const demo of TRAIN_DEMO_BOOKINGS) { - const schedule = schedules.get(demo.trainNumber); - const booking = bookings.get(demo.reference); - if (!schedule || !booking) continue; - - const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); - const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; - const wagon = await this.ensureWagon(manager, { - wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, - wagonTypeId: refs.wagonType.id, - yardId: schedule.destinationStationId, - trainScheduleId: schedule.id, - trainSetWagonId: null, - tareWeight, - capacityTons: wagonCapacity, - }); - - let trainSetWagon = await trainSetWagonRepo.findOne({ - where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, - }); - trainSetWagon = await trainSetWagonRepo.save( - trainSetWagonRepo.create({ - ...(trainSetWagon ? { id: trainSetWagon.id } : {}), - trainSetId: schedule.trainSetId, - wagonTypeId: refs.wagonType.id, - physicalWagonId: wagon.id, - sequenceNo: sequence, - capacityTons: wagonCapacity, - lengthMeters: wagonLength, - assignedWeightTons: demo.weightTons, - status: 'DEPARTED', - }), - ); - - await manager.getRepository(Wagon).update(wagon.id, { - trainSetWagonId: trainSetWagon.id, - currentTrainScheduleId: schedule.id, - currentYardId: schedule.destinationStationId, - status: WagonStatus.Assigned, - }); - - const allocation = await allocationRepo.save( - allocationRepo.create({ - trainSetWagonId: trainSetWagon.id, - bookingId: booking.id, - allocatedWeightTons: demo.weightTons, - loadType: demo.freightType, - status: 'DEPARTED', - confirmedAt: schedule.actualDepartureAt ?? new Date(), - }), - ); - - if (demo.freightType === 'CONTAINER') { - const bookingContainer = await manager.getRepository(BookingContainer).findOne({ - where: { bookingId: booking.id }, - }); - const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; - await containerItemRepo.insert({ - id: randomUUID(), - wagonBookingAllocationId: allocation.id, - bookingContainerId: bookingContainer?.id ?? null, - containerNumber: this.containerNumber(demo.reference), - containerTypeId: containerType?.id ?? null, - positionOnWagon: 1, - sealNumber: `SEAL-${demo.reference}`, - chassisNumber: `CHS-${demo.reference}`, - grossWeightTons: demo.weightTons, - }); - } - - await scheduleBookingRepo.insert({ - id: randomUUID(), - trainScheduleId: schedule.id, - bookingId: booking.id, - }); - } - } - - private async ensureGatepasses( - manager: EntityManager, - schedules: Map, - ): Promise { - const repo = manager.getRepository(ImportDjiboutiOperation); - const securedAt = this.addHours(new Date(), -20); - - for (const schedule of schedules.values()) { - const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); - await repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - trainScheduleId: schedule.id, - documents: { - ...(existing?.documents ?? {}), - GATE_PASS: { - reference: `GP-${schedule.trainNumber}`, - uploadedAt: securedAt.toISOString(), - uploadedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', - }, - }, - gatepassGrantedAt: securedAt, - performedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', - }), - ); - } - } - - private async ensureImportWarehouseInventory( - manager: EntityManager, - bookings: Map, - ): Promise { - const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); - if (!warehouse) { - this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); - return; - } - - for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { - const booking = bookings.get(demo.reference); - if (!booking) continue; - - const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); - if (!yard) { - this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); - continue; - } - const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); - if (!zone) { - this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); - continue; - } - - const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); - const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: warehouse.id, - yardId: yard.id, - zoneId: zone.id, - bookingId: booking.id, - quantity: demo.freightType === 'CONTAINER' ? 1 : 1, - weight: demo.weightTons, - volume: null, - grnNumber, - status: 'UNLOADED', - inspectionStatus: null, - arrivedAt, - unloadedAt: arrivedAt, - notes: [ - `GRN Number: ${grnNumber}`, - 'Direction: IMPORT', - `Train: ${demo.trainNumber}`, - `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, - 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', - ].join('\n'), - }), - ); - - await manager.getRepository(WarehouseActivityLog).save( - manager.getRepository(WarehouseActivityLog).create({ - inventoryId: saved.id, - warehouseId: warehouse.id, - activityType: 'INVENTORY_UNLOADED', - description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, - performedBy: 'PaidIndodeDemoBookingsSeeder', - }), - ); - } - } - - private async findWarehouseYard( - manager: EntityManager, - warehouseId: string, - freightType: string, - ): Promise { - const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; - return ( - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId, type: preferredType as any }, - })) ?? - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId }, - })) - ); - } - - private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const allocations = await allocationRepo.find({ - where: { bookingId: In(bookingIds) }, - select: { id: true }, - }); - const allocationIds = allocations.map((allocation) => allocation.id); - if (allocationIds.length) { - await manager.getRepository(WagonAllocationContainerItem).delete({ - wagonBookingAllocationId: In(allocationIds), - }); - } - await allocationRepo.delete({ bookingId: In(bookingIds) }); - await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); - } - - private async ensureLocomotive( - manager: EntityManager, - currentYardId: string, - ): Promise { - const repo = manager.getRepository(Locomotive); - const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); - if (existing) { - await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); - return { ...existing, currentYardId, status: 'AVAILABLE' }; - } - - return repo.save( - repo.create({ - code: 'US12-DEMO-LOCO', - name: 'US12 Demo Locomotive', - locomotiveType: 'DIESEL', - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: 'AVAILABLE', - currentYardId, - }), - ); - } - - private async ensureTrainSet( - manager: EntityManager, - trainNumber: string, - locomotiveId: string, - ): Promise { - const schedule = await manager.getRepository(TrainSchedule).findOne({ - where: { trainNumber }, - }); - if (schedule) { - const existing = await manager.getRepository(TrainSet).findOneByOrFail({ - id: schedule.trainSetId, - }); - await manager.getRepository(TrainSet).update(existing.id, { - locomotiveId, - status: 'COMPLETED', - }); - return { ...existing, locomotiveId, status: 'COMPLETED' }; - } - - return manager.getRepository(TrainSet).save( - manager.getRepository(TrainSet).create({ - locomotiveId, - totalWeightTons: 0, - totalLengthMeters: 0, - wagonCount: 0, - status: 'COMPLETED', - }), - ); - } - - private async ensureTrainSchedule( - manager: EntityManager, - input: { - trainNumber: string; - trainSetId: string; - originStationId: string; - destinationStationId: string; - scheduledDepartureDate: Date; - scheduledArrivalDate: Date; - actualDepartureAt: Date; - actualArrivalAt: Date; - direction: 'IMPORT' | 'EXPORT'; - }, - ): Promise { - const repo = manager.getRepository(TrainSchedule); - const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); - const nextSchedule = repo.create({ - ...(existing ? { id: existing.id } : {}), - trainSetId: input.trainSetId, - originStationId: input.originStationId, - destinationStationId: input.destinationStationId, - scheduledDepartureDate: input.scheduledDepartureDate, - scheduledArrivalDate: input.scheduledArrivalDate, - actualDepartureAt: input.actualDepartureAt, - actualArrivalAt: input.actualArrivalAt, - status: TrainScheduleStatus.Arrived, - trainNumber: input.trainNumber, - direction: input.direction, - maxWagons: 53, - bookingWindowStatus: 'CLOSED', - }); - return repo.save(nextSchedule); - } - - private async ensureWagon( - manager: EntityManager, - input: { - wagonNumber: string; - wagonTypeId: string; - yardId: string; - trainScheduleId: string; - trainSetWagonId: string | null; - tareWeight: number; - capacityTons: number; - }, - ): Promise { - const repo = manager.getRepository(Wagon); - const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); - return repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - wagonNumber: input.wagonNumber, - wagonTypeId: input.wagonTypeId, - currentYardId: input.yardId, - currentTrainScheduleId: input.trainScheduleId, - trainSetWagonId: input.trainSetWagonId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, - status: WagonStatus.Assigned, - notes: 'US12 paid Indode demo seed wagon', - }), - ); - } - - private async ensureFirstMileVehicle(manager: EntityManager): Promise { - const driverRepo = manager.getRepository(Driver); - const vehicleRepo = manager.getRepository(Vehicle); - const licenseNumber = 'US12-FM-LIC-001'; - const plateNumber = 'ET-FM-1201'; - - await driverRepo.upsert( - { - licenseNumber, - firstName: 'Tesfaye', - lastName: 'Firstmile', - email: 'tesfaye.firstmile@edr.local', - phoneNumber: '251911120120', - licenseExpiryDate: this.addHours(new Date(), 24 * 365), - status: DriverStatus.ACTIVE, - vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], - notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', - }, - { conflictPaths: { licenseNumber: true } }, - ); - const driver = await driverRepo.findOneByOrFail({ licenseNumber }); - - await vehicleRepo.upsert( - { - plateNumber, - registrationNumber: 'US12-FM-REG-001', - vehicleType: VehicleType.TRUCK, - manufacturer: 'Sinotruk', - model: 'HOWO Container Carrier', - year: 2024, - fuelType: FuelType.DIESEL, - capacity: 40, - status: VehicleStatus.ACTIVE, - assignedDriverId: driver.id, - assignedDriverName: `${driver.firstName} ${driver.lastName}`, - description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', - estimatedDistanceKm: 35, - actualDistanceKm: 34.6, - }, - { conflictPaths: { plateNumber: true } }, - ); - const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); - await manager.query( - `UPDATE freight.vehicles - SET trailer_plate_no = $2, - assigned_driver_id = $3, - assigned_driver_name = $4, - updated_at = NOW() - WHERE id = $1`, - [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], - ); - return vehicleRepo.findOneByOrFail({ plateNumber }); - } - - private containerNumber(reference: string): string { - const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); - return `US12${suffix}`; - } - - private addHours(date: Date, hours: number): Date { - return new Date(date.getTime() + hours * 60 * 60 * 1000); - } -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 888a09c1a..a06c4c775 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -130,11 +130,6 @@ export default function InvoiceDetailPage() { const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); const handlePay = () => { -<<<<<<< HEAD - const returnUrl = `${window.location.origin}/payment/success`; - const failureUrl = `${window.location.origin}/payment/failure`; - payMutation.mutate({ id, payload: { method: paymentMethod, returnUrl, failureUrl } }); -======= setPayModalOpen(true); }; @@ -184,7 +179,6 @@ export default function InvoiceDetailPage() { } toast.error("This invoice's source isn't linked to a booking."); } ->>>>>>> 03740ee719f22f9617379a652b26f13b6870f671 }; return ( @@ -214,20 +208,6 @@ export default function InvoiceDetailPage() { -<<<<<<< HEAD - {payable && ( - - @@ -1202,12 +1208,18 @@ const FirstMilePage = () => { @@ -1143,11 +1149,17 @@ const LastMilePage = () => { setCurrency(v ?? "ETB")} + /> + +