diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8413bf71..7de5c5239 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ permissions: jobs: detect-changes: name: Detect changed services - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: @@ -52,7 +52,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then @@ -91,7 +91,7 @@ jobs: name: Deploy ${{ matrix.service }} needs: detect-changes if: ${{ needs.detect-changes.outputs.matrix != '[]' }} - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 0e3f0986f..ffdc4b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ .idea/ .vscode/ .npmrc +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index bc0394eb6..e2535083e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateFirstMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } 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 315e70447..161604e81 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 @@ -55,6 +55,13 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.firstMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index b9b59845c..713efa52d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile])], + imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], 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 a1ee4d364..a4ead8c2a 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 @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; @@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [ @Injectable() export class FirstMileService { - constructor(private readonly firstMileRepository: FirstMileRepository) {} + constructor( + private readonly firstMileRepository: FirstMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + /** + * Look up a booking by its human-readable reference and confirm it has been + * paid before any first-mile work proceeds. Throws if the reference is + * unknown or the booking has not reached PAID status. + */ + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + return null; + } + + if (booking.paymentStatus !== 'PAID') { + return null; + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, + }); + } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; @@ -45,7 +71,10 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +93,10 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index b47eb0479..4f6f5fc8f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateLastMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e6cc1e7ee..ea1e29a3d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -55,6 +55,13 @@ export class LastMileController { return this.lastMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.lastMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index a3662debe..fa654f6ec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([LastMile])], + imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule], controllers: [LastMileController], providers: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 55b8fee24..d25729324 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ @Injectable() export class LastMileService { - constructor(private readonly lastMileRepository: LastMileRepository) {} + constructor( + private readonly lastMileRepository: LastMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingReference} not found`); + } + + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, + ); + } + + return this.create({ + bookingId: booking.id, + advancedPayment: booking.totalAmount, + }); + } async findAll(filter: LastMileListFilter = {}): Promise<{ data: LastMile[]; @@ -45,7 +68,10 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +90,10 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index aced8cda4..8e8b9f94c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -69,6 +69,18 @@ export const QUERY_KEYS = { list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, }, + FIRST_MILE: { + ROOT: ["first-mile"] as const, + list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, + byId: (id: string) => ["first-mile", "detail", id] as const, + }, + + LAST_MILE: { + ROOT: ["last-mile"] as const, + list: (filter?: Record) => ["last-mile", "list", filter ?? {}] as const, + byId: (id: string) => ["last-mile", "detail", id] as const, + }, + RULE_ENGINE: { ROOT: ["rule-engine"] as const, list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 6e12dea39..fc3897fa6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -362,6 +362,18 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/vehicles/${id}`, }, + FIRST_MILE: { + BASE: '/first-mile', + BY_ID: (id: string) => `/first-mile/${id}`, + ACCEPT: (reference: string) => `/first-mile/accept/${reference}`, + }, + + LAST_MILE: { + BASE: '/last-mile', + BY_ID: (id: string) => `/last-mile/${id}`, + ACCEPT: (reference: string) => `/last-mile/accept/${reference}`, + }, + DRIVERS: { BASE: '/drivers', BY_ID: (id: string) => `/drivers/${id}`, 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 68ce805df..7842adfeb 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 { RefreshCw, Truck, } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -28,29 +29,17 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; - -type FirstMileStatus = "UNASSIGNED" | "ASSIGNED"; -type PickupStatus = "PAYMENT_PENDING" | "READY_FOR_PICKUP" | "PICKED_UP"; - -interface FirstMileJob { - id: string; - bookingRef: string; - customer: string; - pickup: string; - cargo: string; - status: FirstMileStatus; - pickupStatus: PickupStatus; - assignedVehicle: string | null; - // Booking info shown in the Assign / View Detail modals. - serviceType: string; - weight: string; - price: number; - destinationYard: string; - contactName: string; - contactPhone: string; - requestedDate: string; -} +import { + FIRST_MILE_STATUSES, + type FirstMileApiStatus, + type FirstMileRecord, + firstMileService, +} from "@/services/first-mile.service"; +import { bookingsService } from "@/services/bookings.service"; +import { vehiclesService } from "@/services/vehicles.service"; +import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -58,97 +47,60 @@ const formatPrice = (amount: number) => maximumFractionDigits: 2, })}`; -const PICKUP_STATUS_META: Record< - PickupStatus, - { label: string; color: string } -> = { +const STATUS_META: Record = { PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, - READY_FOR_PICKUP: { label: "Ready for Pickup", color: "blue" }, - PICKED_UP: { label: "Picked Up", color: "green" }, + READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" }, + IN_TRANSIT: { label: "In Transit", color: "indigo" }, + RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; -// Forward-only lifecycle: Payment Pending → Ready for Pickup → Picked Up. -const NEXT_PICKUP_STATUS: Partial> = { - PAYMENT_PENDING: "READY_FOR_PICKUP", - READY_FOR_PICKUP: "PICKED_UP", +const NEXT_STATUS: Partial> = { + PAYMENT_PENDING: "READY_TO_TRANSIT", + READY_TO_TRANSIT: "IN_TRANSIT", + IN_TRANSIT: "RECEIVED_TO_PORT", }; -// Single filter covering both the pickup lifecycle and assignment state. -type StatusFilter = - | "ALL" - | PickupStatus - | FirstMileStatus; +type AssignmentStatus = "ASSIGNED" | "UNASSIGNED"; +type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus; const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "ALL", label: "All statuses" }, - { value: "PAYMENT_PENDING", label: "Payment Pending" }, - { value: "READY_FOR_PICKUP", label: "Ready for Pickup" }, - { value: "PICKED_UP", label: "Picked Up" }, + { value: "ALL", label: "All" }, + ...FIRST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })), { value: "ASSIGNED", label: "Assigned" }, { value: "UNASSIGNED", label: "Unassigned" }, ]; -// Placeholder data — replace with a real first-mile service once the API exists. -const PLACEHOLDER_JOBS: FirstMileJob[] = [ - { - id: "1", - bookingRef: "BK-10242", - customer: "Awash Trading PLC", - pickup: "Kera Warehouse, Addis Ababa", - cargo: "20ft container · Electronics", - status: "UNASSIGNED", - pickupStatus: "PAYMENT_PENDING", - assignedVehicle: null, - serviceType: "Door-to-terminal (First Mile)", - weight: "12.4 t", - price: 4200, - destinationYard: "Indode Dry Port", - contactName: "Selam Bekele", - contactPhone: "+251 911 234 567", - requestedDate: "2026-06-22", - }, - { - id: "2", - bookingRef: "BK-10239", - customer: "Dire Logistics", - pickup: "Factory Gate 4, Dire Dawa", - cargo: "Bulk · 18t Cement", - status: "ASSIGNED", - pickupStatus: "READY_FOR_PICKUP", - assignedVehicle: "Isuzu FVR (3-AA-45821)", - serviceType: "Door-to-terminal (First Mile)", - weight: "18.0 t", - price: 3000, - destinationYard: "Dire Dawa Terminal", - contactName: "Yonas Tadesse", - contactPhone: "+251 912 887 010", - requestedDate: "2026-06-21", - }, - { - id: "3", - bookingRef: "BK-10235", - customer: "Horizon Imports", - pickup: "Lebu Industrial Park, Addis Ababa", - cargo: "40ft container · Machinery", - status: "UNASSIGNED", - pickupStatus: "PICKED_UP", - assignedVehicle: null, - serviceType: "Door-to-terminal (First Mile)", - weight: "24.7 t", - price: 6500, - destinationYard: "Mojo Dry Port", - contactName: "Hanna Girma", - contactPhone: "+251 913 445 221", - requestedDate: "2026-06-23", - }, -]; +const vehicleLabel = (record: FirstMileRecord) => { + if (!record.vehicle) return null; + const v = record.vehicle; + return `${v.manufacturer} ${v.model} (${v.plateNumber})`; +}; -// Placeholder vehicle options — replace with the vehicles service. -const VEHICLE_OPTIONS = [ - { value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, - { value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, - { value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, -]; +const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); + +// Map API record → display fields used in modals and trip slip +const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; +const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; +const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; +const cargoDesc = (r: FirstMileRecord) => { + const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean); + if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`); + return parts.join(" · ") || "—"; +}; +const priceAmount = (r: FirstMileRecord) => + r.booking?.totalAmount ?? r.advancedPayment; +const destinationYardName = (r: FirstMileRecord) => + r.booking?.destinationYard?.name ?? "—"; +const contactPersonName = (r: FirstMileRecord) => + r.booking?.company?.contactPersonName ?? "—"; +const contactPhone = (r: FirstMileRecord) => + r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—"; +const requestedDate = (r: FirstMileRecord) => { + const d = r.booking?.scheduledDate; + return d ? new Date(d).toISOString().slice(0, 10) : "—"; +}; +const serviceTypeName = (r: FirstMileRecord) => + r.booking?.serviceType?.name ?? "—"; const InfoRow = ({ label, value }: { label: string; value: string }) => ( @@ -159,57 +111,51 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ job }: { job: FirstMileJob }) => ( +const BookingInfo = ({ record }: { record: FirstMileRecord }) => ( - {job.bookingRef} + {bookingRef(record)} - - {PICKUP_STATUS_META[job.pickupStatus].label} + + {STATUS_META[record.status].label} - {job.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + {isAssigned(record) ? "Assigned" : "Unassigned"} - - - - - - - - - - - + + + + + + + + + + ); -const tripSlipRows = (job: FirstMileJob): [string, string][] => [ - ["Customer", job.customer], - ["Service", job.serviceType], - ["Pickup location", job.pickup], - ["Destination yard", job.destinationYard], - ["Cargo", job.cargo], - ["Weight", job.weight], - ["Price", formatPrice(job.price)], - ["Vehicle", job.assignedVehicle ?? "Unassigned"], - ["Contact", `${job.contactName} · ${job.contactPhone}`], - ["Requested date", job.requestedDate], - ["Pickup status", PICKUP_STATUS_META[job.pickupStatus].label], +const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup location", pickupLocation(record)], + ["Destination yard", destinationYardName(record)], + ["Cargo", cargoDesc(record)], + ["Price", formatPrice(priceAmount(record))], + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], ]; const SampleStamp = () => ( @@ -261,52 +207,34 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) {title} - - Name: - + Name: - - Signature: - + Signature: {stamp && ( - + {stamp} )} ); -const TripSlipDocument = ({ job }: { job: FirstMileJob }) => ( +const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( EDR Freight - - First Mile Trip Slip - + First Mile Trip Slip - - {job.bookingRef} - - - {job.requestedDate} - + {bookingRef(record)} + {requestedDate(record)} - {tripSlipRows(job).map(([label, value]) => ( + {tripSlipRows(record).map(([label, value]) => ( ))} @@ -318,32 +246,22 @@ const TripSlipDocument = ({ job }: { job: FirstMileJob }) => ( ); -const escapeHtml = (value: string) => - value - .replace(/&/g, "&") - .replace(//g, ">"); +const escapeHtml = (v: string) => + v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (job: FirstMileJob) => { - const rows = tripSlipRows(job) - .map( - ([label, value]) => - `${escapeHtml(label)}${escapeHtml(value)}`, - ) +const buildTripSlipHtml = (record: FirstMileRecord) => { + const rows = tripSlipRows(record) + .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); - const signature = (title: string, withStamp: boolean) => ` + const sig = (title: string, withStamp: boolean) => `
${title}
Name:
Signature:
- ${ - withStamp - ? '
EDR FREIGHTAPPROVEDOPERATIONS
' - : "" - } + ${withStamp ? '
EDR FREIGHTAPPROVEDOPERATIONS
' : ""}
`; return ` - Trip Slip ${escapeHtml(job.bookingRef)} + Trip Slip ${escapeHtml(bookingRef(record))}

EDR Freight

First Mile Trip Slip

-
${escapeHtml(job.bookingRef)}${escapeHtml(job.requestedDate)}
+
${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
Acknowledgement
-
${signature("Driver", false)}${signature("Operator", true)}
+
${sig("Driver", false)}${sig("Operator", true)}
`; }; const FirstMilePage = () => { const { toast } = useToast(); + const qc = useQueryClient(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [jobs, setJobs] = useState(PLACEHOLDER_JOBS); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const [rowSelection, setRowSelection] = useState>({}); @@ -388,13 +306,81 @@ const FirstMilePage = () => { const [bulkMode, setBulkMode] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); - const [tripSlipJob, setTripSlipJob] = useState(null); - const [activeJobId, setActiveJobId] = useState(null); + const [tripSlipRecord, setTripSlipRecord] = useState(null); + const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); - const activeJob = useMemo( - () => jobs.find((job) => job.id === activeJobId) ?? null, - [jobs, activeJobId], + const [acceptOpen, setAcceptOpen] = useState(false); + const [acceptStep, setAcceptStep] = useState<1 | 2>(1); + const [selectedBooking, setSelectedBooking] = useState(null); + const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); + const [bookingSearch, setBookingSearch] = useState(""); + + const { data: listData, isLoading } = useQuery({ + queryKey: QUERY_KEYS.FIRST_MILE.list(), + queryFn: async () => { + const res = await firstMileService.list(); + return res.data; + }, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "list"], + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE" }); + return res.data; + }, + }); + + const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({ + queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }), + queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }), + enabled: acceptOpen, + }); + const paidBookings = paidBookingsData?.items ?? []; + + const records = listData?.data ?? []; + + const vehicleOptions = useMemo( + () => + (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ + value: v.id, + label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, + })), + [vehiclesData], + ); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) => + firstMileService.update(id, data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + + const acceptMutation = useMutation({ + mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { + const res = await firstMileService.accept(reference); + const created = res.data; + if (vehicleId) await firstMileService.update(created.id, { vehicleId }); + return created; + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); + closeAccept(); + }, + onError: () => { + toast({ title: "Accept failed", variant: "destructive" }); + }, + }); + + const activeRecord = useMemo( + () => records.find((r) => r.id === activeId) ?? null, + [records, activeId], ); const selectedIds = useMemo( @@ -402,160 +388,161 @@ const FirstMilePage = () => { [rowSelection], ); - const matchesStatusFilter = (job: FirstMileJob) => { + const filteredPaidBookings = useMemo(() => { + const term = bookingSearch.trim().toLowerCase(); + if (!term) return paidBookings; + return paidBookings.filter((b) => + [b.reference, b.company?.name, b.company?.companyName] + .join(" ") + .toLowerCase() + .includes(term), + ); + }, [paidBookings, bookingSearch]); + + const openAccept = () => { + setAcceptOpen(true); + setAcceptStep(1); + setSelectedBooking(null); + setAcceptVehicleValue(null); + setBookingSearch(""); + }; + + const closeAccept = () => { + setAcceptOpen(false); + setAcceptStep(1); + setSelectedBooking(null); + setAcceptVehicleValue(null); + setBookingSearch(""); + }; + + const handleAcceptConfirm = () => { + if (!selectedBooking) return; + acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue }); + }; + + const matchesFilter = (r: FirstMileRecord) => { switch (statusFilter) { - case "ALL": - return true; - case "ASSIGNED": - case "UNASSIGNED": - return job.status === statusFilter; - default: - return job.pickupStatus === statusFilter; + case "ALL": return true; + case "ASSIGNED": return isAssigned(r); + case "UNASSIGNED": return !isAssigned(r); + default: return r.status === statusFilter; } }; const statusCounts = useMemo(() => { const counts: Record = { - ALL: jobs.length, + ALL: records.length, PAYMENT_PENDING: 0, - READY_FOR_PICKUP: 0, - PICKED_UP: 0, + READY_TO_TRANSIT: 0, + IN_TRANSIT: 0, + RECEIVED_TO_PORT: 0, ASSIGNED: 0, UNASSIGNED: 0, }; - for (const job of jobs) { - counts[job.pickupStatus] += 1; - counts[job.status] += 1; + for (const r of records) { + counts[r.status] = (counts[r.status] ?? 0) + 1; + if (isAssigned(r)) counts.ASSIGNED += 1; + else counts.UNASSIGNED += 1; } return counts; - }, [jobs]); + }, [records]); - const filteredJobs = useMemo(() => { + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); - return jobs.filter((job) => { - if (!matchesStatusFilter(job)) return false; + return records.filter((r) => { + if (!matchesFilter(r)) return false; if (!term) return true; - return [job.bookingRef, job.customer, job.pickup, job.cargo] + return [bookingRef(r), customerName(r), pickupLocation(r), cargoDesc(r)] .join(" ") .toLowerCase() .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobs, search, statusFilter]); + }, [records, search, statusFilter]); - const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize)); - const pagedJobs = useMemo(() => { + const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); + const pagedRecords = useMemo(() => { const start = pagination.pageIndex * pagination.pageSize; - return filteredJobs.slice(start, start + pagination.pageSize); - }, [filteredJobs, pagination.pageIndex, pagination.pageSize]); + return filteredRecords.slice(start, start + pagination.pageSize); + }, [filteredRecords, pagination]); - const openAssign = (jobId: string | null) => { - const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; + const openAssign = (id: string | null) => { + const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; setBulkMode(false); - setActiveJobId(resolved); + setActiveId(resolved); setVehicleValue(null); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); setAssignOpen(true); }; - const openDetail = (jobId: string) => { - setActiveJobId(jobId); - setDetailOpen(true); - }; - const closeAssign = () => { setAssignOpen(false); setBulkMode(false); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); }; - const closeDetail = () => { - setDetailOpen(false); - setActiveJobId(null); - }; - const handleAssign = () => { if (!vehicleValue) { - toast({ - title: "Select a vehicle", - description: "Choose a vehicle to assign to this pickup.", - variant: "destructive", - }); + toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); return; } - const vehicleLabel = - VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue; - const targetIds = bulkMode ? selectedIds - : [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter( - (id): id is string => Boolean(id), - ); + : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const targetSet = new Set(targetIds); - setJobs((current) => - current.map((job) => - targetSet.has(job.id) - ? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel } - : job, - ), - ); + const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - toast({ - title: "Vehicle assigned", - description: bulkMode - ? `${targetIds.length} pickups → ${vehicleLabel}` - : vehicleLabel, - }); - if (bulkMode) setRowSelection({}); - closeAssign(); + Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + .then(() => { + toast({ + title: "Vehicle assigned", + description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + }); + if (bulkMode) setRowSelection({}); + closeAssign(); + }) + .catch(() => void 0); }; - const handleAdvanceStatus = (job: FirstMileJob) => { - const next = NEXT_PICKUP_STATUS[job.pickupStatus]; + const handleAdvanceStatus = (record: FirstMileRecord) => { + const next = NEXT_STATUS[record.status]; if (!next) return; - setJobs((current) => - current.map((item) => - item.id === job.id ? { ...item, pickupStatus: next } : item, - ), + updateMutation.mutate( + { id: record.id, data: { status: next } }, + { + onSuccess: () => + toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }), + }, ); - toast({ - title: "Status updated", - description: `${job.bookingRef} → ${PICKUP_STATUS_META[next].label}`, - }); }; - const handlePrintTripSlip = (job: FirstMileJob) => { - setTripSlipJob(job); + const handlePrintTripSlip = (record: FirstMileRecord) => { + setTripSlipRecord(record); setTripSlipOpen(true); }; const printTripSlip = () => { - if (!tripSlipJob) return; + if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); if (!win) { - toast({ - title: "Pop-up blocked", - description: "Allow pop-ups to print the trip slip.", - variant: "destructive", - }); + toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipJob)); + win.document.write(buildTripSlipHtml(tripSlipRecord)); win.document.close(); }; - const columns = useMemo((): ColumnDef[] => { + const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ @@ -567,9 +554,7 @@ const FirstMilePage = () => { table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), @@ -586,67 +571,54 @@ const FirstMilePage = () => { id: "bookingRef", header: "Booking", meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {row.original.bookingRef} - - ), + cell: ({ row }) => {bookingRef(row.original)}, }, { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.customer, + cell: ({ row }) => customerName(row.original), }, { id: "pickup", header: "Pickup", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.pickup, + cell: ({ row }) => pickupLocation(row.original), }, { id: "cargo", header: "Cargo", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.cargo, + cell: ({ row }) => cargoDesc(row.original), }, { id: "price", header: "Price", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.price), + cell: ({ row }) => formatPrice(priceAmount(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - row.original.assignedVehicle ?? , - }, - { - id: "pickupStatus", - header: "Status", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => { - const meta = PICKUP_STATUS_META[row.original.pickupStatus]; - return ( - - {meta.label} - - ); - }, + cell: ({ row }) => vehicleLabel(row.original) ?? , }, { id: "status", + header: "Status", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return {meta.label}; + }, + }, + { + id: "assignment", header: "Assignment", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - - {row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(row.original) ? "Assigned" : "Unassigned"} ), }, @@ -655,11 +627,9 @@ const FirstMilePage = () => { header: "Actions", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { - const isAssigned = row.original.status === "ASSIGNED"; - const nextStatus = NEXT_PICKUP_STATUS[row.original.pickupStatus]; - const canPrintTripSlip = - row.original.pickupStatus === "READY_FOR_PICKUP" || - row.original.pickupStatus === "PICKED_UP"; + const assigned = isAssigned(row.original); + const nextStatus = NEXT_STATUS[row.original.status]; + const canPrint = row.original.status !== "PAYMENT_PENDING"; return ( @@ -674,21 +644,19 @@ const FirstMilePage = () => { disabled={!nextStatus} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus - ? `Mark ${PICKUP_STATUS_META[nextStatus].label}` - : "Picked Up"} + {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} } - disabled={isAssigned} + disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!isAssigned} + disabled={!assigned} onClick={() => openAssign(row.original.id)} > Reassign @@ -696,11 +664,11 @@ const FirstMilePage = () => { } - onClick={() => openDetail(row.original.id)} + onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail - {canPrintTripSlip && ( + {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} @@ -715,18 +683,12 @@ const FirstMilePage = () => { }, }, ]; - }, []); - - const tableStatus = "success" as const; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleOptions]); return ( - + @@ -739,18 +701,11 @@ const FirstMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -768,7 +723,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - {option.label} ({statusCounts[option.value]}) + {option.label} ({statusCounts[option.value] ?? 0}) ); })} @@ -778,14 +733,14 @@ const FirstMilePage = () => { { onRowSelectionChange: setRowSelection, }} containerClassName="border-0 shadow-none bg-transparent" - footer={({ table, pagination: footerPagination }) => ( - + footer={({ table, pagination: fp }) => ( + )} /> + {/* Assign / Reassign modal */} { {bulkMode ? ( Assigning a vehicle to{" "} - - {selectedIds.length} - {" "} + {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. - ) : activeJob ? ( - + ) : activeRecord ? ( + ) : ( - - No unassigned pickups available. - + No unassigned pickups available. )} + + + + + + + + + )} + ); }; 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 cd5191c5c..a5ad12c5b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -7,6 +7,7 @@ import { RefreshCw, Truck, } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -28,29 +29,15 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; - -type LastMileStatus = "UNASSIGNED" | "ASSIGNED"; -type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED"; - -interface LastMileJob { - id: string; - bookingRef: string; - customer: string; - destination: string; - cargo: string; - status: LastMileStatus; - deliveryStatus: DeliveryStatus; - assignedVehicle: string | null; - // Booking info shown in the Assign / View Detail modals. - serviceType: string; - weight: string; - price: number; - originYard: string; - contactName: string; - contactPhone: string; - requestedDate: string; -} +import { + LAST_MILE_STATUSES, + type LastMileApiStatus, + type LastMileRecord, + lastMileService, +} from "@/services/last-mile.service"; +import { vehiclesService } from "@/services/vehicles.service"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -58,158 +45,108 @@ const formatPrice = (amount: number) => maximumFractionDigits: 2, })}`; -const DELIVERY_STATUS_META: Record< - DeliveryStatus, - { label: string; color: string } -> = { +const STATUS_META: Record = { PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" }, + IN_TRANSIT: { label: "In Transit", color: "indigo" }, DELIVERED: { label: "Delivered", color: "green" }, }; -// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered. -const NEXT_DELIVERY_STATUS: Partial> = { +const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", - READY_TO_TRANSIT: "DELIVERED", + READY_TO_TRANSIT: "IN_TRANSIT", + IN_TRANSIT: "DELIVERED", }; -// Single filter covering both the delivery lifecycle and assignment state. -type StatusFilter = - | "ALL" - | DeliveryStatus - | LastMileStatus; +type AssignmentStatus = "ASSIGNED" | "UNASSIGNED"; +type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus; const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "ALL", label: "All statuses" }, - { value: "PAYMENT_PENDING", label: "Payment Pending" }, - { value: "READY_TO_TRANSIT", label: "Ready to Transit" }, - { value: "DELIVERED", label: "Delivered" }, + { value: "ALL", label: "All" }, + ...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })), { value: "ASSIGNED", label: "Assigned" }, { value: "UNASSIGNED", label: "Unassigned" }, ]; -// Placeholder data — replace with a real last-mile service once the API exists. -const PLACEHOLDER_JOBS: LastMileJob[] = [ - { - id: "1", - bookingRef: "BK-10241", - customer: "Awash Trading PLC", - destination: "Bole Sub-city, Addis Ababa", - cargo: "20ft container · Electronics", - status: "UNASSIGNED", - deliveryStatus: "PAYMENT_PENDING", - assignedVehicle: null, - serviceType: "Door-to-door (Last Mile)", - weight: "12.4 t", - price: 4500, - originYard: "Indode Dry Port", - contactName: "Selam Bekele", - contactPhone: "+251 911 234 567", - requestedDate: "2026-06-22", - }, - { - id: "2", - bookingRef: "BK-10238", - customer: "Dire Logistics", - destination: "Industry Zone, Dire Dawa", - cargo: "Bulk · 18t Cement", - status: "ASSIGNED", - deliveryStatus: "READY_TO_TRANSIT", - assignedVehicle: "Isuzu FVR (3-AA-45821)", - serviceType: "Terminal-to-door (Last Mile)", - weight: "18.0 t", - price: 3200, - originYard: "Dire Dawa Terminal", - contactName: "Yonas Tadesse", - contactPhone: "+251 912 887 010", - requestedDate: "2026-06-21", - }, - { - id: "3", - bookingRef: "BK-10233", - customer: "Horizon Imports", - destination: "Kality Terminal, Addis Ababa", - cargo: "40ft container · Machinery", - status: "UNASSIGNED", - deliveryStatus: "DELIVERED", - assignedVehicle: null, - serviceType: "Door-to-door (Last Mile)", - weight: "24.7 t", - price: 6800, - originYard: "Mojo Dry Port", - contactName: "Hanna Girma", - contactPhone: "+251 913 445 221", - requestedDate: "2026-06-23", - }, -]; +const vehicleLabel = (record: LastMileRecord) => { + if (!record.vehicle) return null; + const v = record.vehicle; + return `${v.manufacturer} ${v.model} (${v.plateNumber})`; +}; -// Placeholder vehicle options — replace with the vehicles service. -const VEHICLE_OPTIONS = [ - { value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, - { value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, - { value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, -]; +const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); + +const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; +const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; +const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; +const cargoDesc = (r: LastMileRecord) => { + const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean); + if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`); + return parts.join(" · ") || "—"; +}; +const priceAmount = (r: LastMileRecord) => + r.booking?.totalAmount ?? r.advancedPayment; +const originYardName = (r: LastMileRecord) => + r.booking?.originYard?.name ?? "—"; +const contactPersonName = (r: LastMileRecord) => + r.booking?.company?.contactPersonName ?? "—"; +const contactPhone = (r: LastMileRecord) => + r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—"; +const requestedDate = (r: LastMileRecord) => { + const d = r.booking?.scheduledDate; + return d ? new Date(d).toISOString().slice(0, 10) : "—"; +}; +const serviceTypeName = (r: LastMileRecord) => + r.booking?.serviceType?.name ?? "—"; const InfoRow = ({ label, value }: { label: string; value: string }) => ( - - {label} - + {label} {value} ); -const BookingInfo = ({ job }: { job: LastMileJob }) => ( +const BookingInfo = ({ record }: { record: LastMileRecord }) => ( - {job.bookingRef} + {bookingRef(record)} - - {DELIVERY_STATUS_META[job.deliveryStatus].label} + + {STATUS_META[record.status].label} - - {job.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(record) ? "Assigned" : "Unassigned"} - - - - - - - - - - - + + + + + + + + + + ); -const tripSlipRows = (job: LastMileJob): [string, string][] => [ - ["Customer", job.customer], - ["Service", job.serviceType], - ["Origin yard", job.originYard], - ["Destination", job.destination], - ["Cargo", job.cargo], - ["Weight", job.weight], - ["Price", formatPrice(job.price)], - ["Vehicle", job.assignedVehicle ?? "Unassigned"], - ["Contact", `${job.contactName} · ${job.contactPhone}`], - ["Requested date", job.requestedDate], - ["Delivery status", DELIVERY_STATUS_META[job.deliveryStatus].label], +const tripSlipRows = (record: LastMileRecord): [string, string][] => [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Origin yard", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Price", formatPrice(priceAmount(record))], + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], ]; const SampleStamp = () => ( @@ -241,15 +178,9 @@ const SampleStamp = () => ( lineHeight: 1.1, }} > - - EDR FREIGHT - - - APPROVED - - - OPERATIONS - + EDR FREIGHT + APPROVED + OPERATIONS @@ -257,56 +188,36 @@ const SampleStamp = () => ( const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( - - {title} - + {title} - - Name: - + Name: - - Signature: - + Signature: {stamp && ( - + {stamp} )} ); -const TripSlipDocument = ({ job }: { job: LastMileJob }) => ( +const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( EDR Freight - - Last Mile Trip Slip - + Last Mile Trip Slip - - {job.bookingRef} - - - {job.requestedDate} - + {bookingRef(record)} + {requestedDate(record)} - {tripSlipRows(job).map(([label, value]) => ( + {tripSlipRows(record).map(([label, value]) => ( ))} @@ -318,32 +229,22 @@ const TripSlipDocument = ({ job }: { job: LastMileJob }) => ( ); -const escapeHtml = (value: string) => - value - .replace(/&/g, "&") - .replace(//g, ">"); +const escapeHtml = (v: string) => + v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (job: LastMileJob) => { - const rows = tripSlipRows(job) - .map( - ([label, value]) => - `${escapeHtml(label)}${escapeHtml(value)}`, - ) +const buildTripSlipHtml = (record: LastMileRecord) => { + const rows = tripSlipRows(record) + .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); - const signature = (title: string, withStamp: boolean) => ` + const sig = (title: string, withStamp: boolean) => `
${title}
Name:
Signature:
- ${ - withStamp - ? '
EDR FREIGHTAPPROVEDOPERATIONS
' - : "" - } + ${withStamp ? '
EDR FREIGHTAPPROVEDOPERATIONS
' : ""}
`; return ` - Trip Slip ${escapeHtml(job.bookingRef)} + Trip Slip ${escapeHtml(bookingRef(record))}

EDR Freight

Last Mile Trip Slip

-
${escapeHtml(job.bookingRef)}${escapeHtml(job.requestedDate)}
+
${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
Acknowledgement
-
${signature("Driver", false)}${signature("Operator", true)}
+
${sig("Driver", false)}${sig("Operator", true)}
`; }; const LastMilePage = () => { const { toast } = useToast(); + const qc = useQueryClient(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [jobs, setJobs] = useState(PLACEHOLDER_JOBS); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const [rowSelection, setRowSelection] = useState>({}); @@ -388,13 +289,51 @@ const LastMilePage = () => { const [bulkMode, setBulkMode] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); - const [tripSlipJob, setTripSlipJob] = useState(null); - const [activeJobId, setActiveJobId] = useState(null); + const [tripSlipRecord, setTripSlipRecord] = useState(null); + const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); - const activeJob = useMemo( - () => jobs.find((job) => job.id === activeJobId) ?? null, - [jobs, activeJobId], + const { data: listData, isLoading } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE.list(), + queryFn: async () => { + const res = await lastMileService.list(); + return res.data; + }, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "list"], + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE" }); + return res.data; + }, + }); + + const records = listData?.data ?? []; + + const vehicleOptions = useMemo( + () => + (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ + value: v.id, + label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, + })), + [vehiclesData], + ); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { status?: LastMileApiStatus; vehicleId?: string | null } }) => + lastMileService.update(id, data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + + const activeRecord = useMemo( + () => records.find((r) => r.id === activeId) ?? null, + [records, activeId], ); const selectedIds = useMemo( @@ -402,160 +341,129 @@ const LastMilePage = () => { [rowSelection], ); - const matchesStatusFilter = (job: LastMileJob) => { + const matchesFilter = (r: LastMileRecord) => { switch (statusFilter) { - case "ALL": - return true; - case "ASSIGNED": - case "UNASSIGNED": - return job.status === statusFilter; - default: - return job.deliveryStatus === statusFilter; + case "ALL": return true; + case "ASSIGNED": return isAssigned(r); + case "UNASSIGNED": return !isAssigned(r); + default: return r.status === statusFilter; } }; const statusCounts = useMemo(() => { const counts: Record = { - ALL: jobs.length, + ALL: records.length, PAYMENT_PENDING: 0, READY_TO_TRANSIT: 0, + IN_TRANSIT: 0, DELIVERED: 0, ASSIGNED: 0, UNASSIGNED: 0, }; - for (const job of jobs) { - counts[job.deliveryStatus] += 1; - counts[job.status] += 1; + for (const r of records) { + counts[r.status] = (counts[r.status] ?? 0) + 1; + if (isAssigned(r)) counts.ASSIGNED += 1; + else counts.UNASSIGNED += 1; } return counts; - }, [jobs]); + }, [records]); - const filteredJobs = useMemo(() => { + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); - return jobs.filter((job) => { - if (!matchesStatusFilter(job)) return false; + return records.filter((r) => { + if (!matchesFilter(r)) return false; if (!term) return true; - return [job.bookingRef, job.customer, job.destination, job.cargo] + return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)] .join(" ") .toLowerCase() .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobs, search, statusFilter]); + }, [records, search, statusFilter]); - const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize)); - const pagedJobs = useMemo(() => { + const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); + const pagedRecords = useMemo(() => { const start = pagination.pageIndex * pagination.pageSize; - return filteredJobs.slice(start, start + pagination.pageSize); - }, [filteredJobs, pagination.pageIndex, pagination.pageSize]); + return filteredRecords.slice(start, start + pagination.pageSize); + }, [filteredRecords, pagination]); - const openAssign = (jobId: string | null) => { - const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; + const openAssign = (id: string | null) => { + const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; setBulkMode(false); - setActiveJobId(resolved); + setActiveId(resolved); setVehicleValue(null); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); setAssignOpen(true); }; - const openDetail = (jobId: string) => { - setActiveJobId(jobId); - setDetailOpen(true); - }; - const closeAssign = () => { setAssignOpen(false); setBulkMode(false); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); }; - const closeDetail = () => { - setDetailOpen(false); - setActiveJobId(null); - }; - const handleAssign = () => { if (!vehicleValue) { - toast({ - title: "Select a vehicle", - description: "Choose a vehicle to assign to this delivery.", - variant: "destructive", - }); + toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); return; } - const vehicleLabel = - VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue; - const targetIds = bulkMode ? selectedIds - : [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter( - (id): id is string => Boolean(id), - ); + : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const targetSet = new Set(targetIds); - setJobs((current) => - current.map((job) => - targetSet.has(job.id) - ? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel } - : job, - ), - ); + const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - toast({ - title: "Vehicle assigned", - description: bulkMode - ? `${targetIds.length} deliveries → ${vehicleLabel}` - : vehicleLabel, - }); - if (bulkMode) setRowSelection({}); - closeAssign(); + Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + .then(() => { + toast({ + title: "Vehicle assigned", + description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel, + }); + if (bulkMode) setRowSelection({}); + closeAssign(); + }) + .catch(() => void 0); }; - const handleAdvanceStatus = (job: LastMileJob) => { - const next = NEXT_DELIVERY_STATUS[job.deliveryStatus]; + const handleAdvanceStatus = (record: LastMileRecord) => { + const next = NEXT_STATUS[record.status]; if (!next) return; - setJobs((current) => - current.map((item) => - item.id === job.id ? { ...item, deliveryStatus: next } : item, - ), + updateMutation.mutate( + { id: record.id, data: { status: next } }, + { + onSuccess: () => + toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }), + }, ); - toast({ - title: "Status updated", - description: `${job.bookingRef} → ${DELIVERY_STATUS_META[next].label}`, - }); }; - const handlePrintTripSlip = (job: LastMileJob) => { - setTripSlipJob(job); + const handlePrintTripSlip = (record: LastMileRecord) => { + setTripSlipRecord(record); setTripSlipOpen(true); }; const printTripSlip = () => { - if (!tripSlipJob) return; + if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); if (!win) { - toast({ - title: "Pop-up blocked", - description: "Allow pop-ups to print the trip slip.", - variant: "destructive", - }); + toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipJob)); + win.document.write(buildTripSlipHtml(tripSlipRecord)); win.document.close(); }; - const columns = useMemo((): ColumnDef[] => { + const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ @@ -567,9 +475,7 @@ const LastMilePage = () => { table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), @@ -586,67 +492,54 @@ const LastMilePage = () => { id: "bookingRef", header: "Booking", meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {row.original.bookingRef} - - ), + cell: ({ row }) => {bookingRef(row.original)}, }, { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.customer, + cell: ({ row }) => customerName(row.original), }, { id: "destination", header: "Destination", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.destination, + cell: ({ row }) => deliveryLocation(row.original), }, { id: "cargo", header: "Cargo", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.cargo, + cell: ({ row }) => cargoDesc(row.original), }, { id: "price", header: "Price", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.price), + cell: ({ row }) => formatPrice(priceAmount(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - row.original.assignedVehicle ?? , - }, - { - id: "deliveryStatus", - header: "Status", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => { - const meta = DELIVERY_STATUS_META[row.original.deliveryStatus]; - return ( - - {meta.label} - - ); - }, + cell: ({ row }) => vehicleLabel(row.original) ?? , }, { id: "status", + header: "Status", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return {meta.label}; + }, + }, + { + id: "assignment", header: "Assignment", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - - {row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(row.original) ? "Assigned" : "Unassigned"} ), }, @@ -655,11 +548,9 @@ const LastMilePage = () => { header: "Actions", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { - const isAssigned = row.original.status === "ASSIGNED"; - const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus]; - const canPrintTripSlip = - row.original.deliveryStatus === "READY_TO_TRANSIT" || - row.original.deliveryStatus === "DELIVERED"; + const assigned = isAssigned(row.original); + const nextStatus = NEXT_STATUS[row.original.status]; + const canPrint = row.original.status !== "PAYMENT_PENDING"; return ( @@ -674,21 +565,19 @@ const LastMilePage = () => { disabled={!nextStatus} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus - ? `Mark ${DELIVERY_STATUS_META[nextStatus].label}` - : "Delivered"} + {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} } - disabled={isAssigned} + disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!isAssigned} + disabled={!assigned} onClick={() => openAssign(row.original.id)} > Reassign @@ -696,11 +585,11 @@ const LastMilePage = () => { } - onClick={() => openDetail(row.original.id)} + onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail - {canPrintTripSlip && ( + {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} @@ -715,18 +604,12 @@ const LastMilePage = () => { }, }, ]; - }, []); - - const tableStatus = "success" as const; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleOptions]); return ( - + @@ -739,18 +622,11 @@ const LastMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -768,7 +644,7 @@ const LastMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - {option.label} ({statusCounts[option.value]}) + {option.label} ({statusCounts[option.value] ?? 0}) ); })} @@ -778,14 +654,14 @@ const LastMilePage = () => { { onRowSelectionChange: setRowSelection, }} containerClassName="border-0 shadow-none bg-transparent" - footer={({ table, pagination: footerPagination }) => ( - + footer={({ table, pagination: fp }) => ( + )} /> + {/* Assign / Reassign modal */} { {bulkMode ? ( Assigning a vehicle to{" "} - - {selectedIds.length} - {" "} + {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "delivery" : "deliveries"}. - ) : activeJob ? ( - + ) : activeRecord ? ( + ) : ( - - No unassigned deliveries available. - + No unassigned deliveries available. )} { + setBookingRef(e.target.value.toUpperCase()); + setError(""); + }} + placeholder="Enter your PNR" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono" + /> + {error && ( +

{error}

+ )} + + + + + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index a9fdf5b35..d2ed43878 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -557,11 +557,14 @@ export default function PassengersPage() { nationalId: p.nationalId, passportNumber: p.passportNumber, passportCountry: p.passportCountry, - phone: p.phone, - email: p.email, + passportIssueDate: p.passportIssueDate, + passportExpiryDate: p.passportExpiryDate, + passportIssuingAuthority: p.passportIssuingAuthority, + phone: p.phone || '', + email: p.email || '', isPrimaryPassenger: i === 0, passengerId: i === 0 && passengerId ? passengerId : undefined, - })); + })) const deviceId = typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || crypto.randomUUID()) diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index b73bac953..3b6c19336 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,11 +4,9 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { usePathname } from "next/navigation"; import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { - const pathname = usePathname(); const [isOpen, setIsOpen] = useState(false); const [isDark, setIsDark] = useState(false); @@ -31,13 +29,7 @@ export default function AppHeader() { } }; - const isLandingPage = [ - "/", - "/services", - "/about", - "/contact", - "/help", - ].includes(pathname); + return (
@@ -59,35 +51,15 @@ export default function AppHeader() { /> - {/* Desktop Menu - only show for landing pages */} - {isLandingPage && ( -
- - Home - - - Services - - - About - - - Contact - -
- )} + {/* Desktop Menu */} +
+ + My Booking + +
{/* Right Actions */}
@@ -133,39 +105,13 @@ export default function AppHeader() { {/* Mobile Menu */} {isOpen && (
- {isLandingPage && ( - <> - setIsOpen(false)} - > - Home - - setIsOpen(false)} - > - Services - - setIsOpen(false)} - > - About - - setIsOpen(false)} - > - Contact - - - )} - + setIsOpen(false)} + > + My Booking + ; + schedule: { + trainNumber: string; + trainName?: string; + origin: { + name: string; + code: string; + city: string; + }; + destination: { + name: string; + code: string; + city: string; + }; + departureAt: string; + arrivalAt: string; + }; + totalMinor: number; + currency: string; + bookingType: string; + createdAt: string; +} + +export const generateVoucherPDF = async (booking: VoucherData) => { + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 15; + let yPos = margin; + + // Colors + const primaryColor = [20, 113, 76]; // EDR Green + const darkGray = [51, 51, 51]; + const mediumGray = [102, 102, 102]; + const lightGray = [200, 200, 200]; + + // ============ HEADER ============ + // Company branding strip + doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.rect(0, 0, pageWidth, 30, 'F'); + + // Load and add logo + try { + const logoImg = await fetch('/edr-logo.png'); + const logoBlob = await logoImg.blob(); + const logoDataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(logoBlob); + }); + + // Create image to get dimensions + const img = new Image(); + await new Promise((resolve) => { + img.onload = resolve; + img.src = logoDataUrl; + }); + + // Calculate aspect ratio and dimensions + const logoHeight = 18; + const logoWidth = (img.width / img.height) * logoHeight; + + // Add logo on left side with proper aspect ratio + doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight); + + // Company name next to logo + doc.setTextColor(255, 255, 255); + doc.setFontSize(20); + doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); + + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', margin + logoWidth + 5, 20); + } catch (error) { + console.error('Failed to load logo:', error); + // Fallback: just show text centered + doc.setTextColor(255, 255, 255); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' }); + } + + yPos = 40; + + // ============ TITLE & STATUS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(20); + doc.setFont('helvetica', 'bold'); + doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' }); + + yPos += 10; + + // Status badge (simplified) + const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status; + const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8]; + + doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]); + doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); + + yPos += 12; + + // ============ QR CODE ============ + // Generate QR code data URL + const canvas = document.createElement('canvas'); + const QRCode = (await import('qrcode')).default; + + const qrSize = 35; // 35mm = 3.5cm + await QRCode.toCanvas(canvas, booking.bookingRef, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }); + + const qrDataUrl = canvas.toDataURL('image/png'); + + // Place QR code at top-right + const qrX = pageWidth - margin - qrSize; + const qrY = yPos; + + doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); + + // ============ BOOKING REFERENCE ============ + doc.setFillColor(245, 245, 245); + doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); + + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('BOOKING REFERENCE', margin + 5, yPos + 6); + + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text(booking.bookingRef, margin + 5, yPos + 14); + + yPos += 25; + + // ============ JOURNEY DETAILS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('JOURNEY DETAILS', margin, yPos); + + yPos += 8; + + // Route box + doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); + doc.setLineWidth(0.5); + doc.rect(margin, yPos, pageWidth - margin * 2, 40); + + // Origin + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('FROM', margin + 5, yPos + 6); + + doc.setFontSize(16); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.origin.code, margin + 5, yPos + 14); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(booking.schedule.origin.name, margin + 5, yPos + 20); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.text(booking.schedule.origin.city, margin + 5, yPos + 25); + + // Departure time + const departureDate = new Date(booking.schedule.departureAt); + doc.setFontSize(14); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38); + + // Arrow + doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setLineWidth(1); + const arrowStartX = pageWidth / 2 - 10; + const arrowEndX = pageWidth / 2 + 10; + const arrowY = yPos + 20; + + // Draw arrow line + doc.line(arrowStartX, arrowY, arrowEndX, arrowY); + + // Draw arrow head manually with lines + doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2); + doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2); + + // Destination + const destX = pageWidth - margin - 50; + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('TO', destX, yPos + 6); + + doc.setFontSize(16); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.destination.code, destX, yPos + 14); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(booking.schedule.destination.name, destX, yPos + 20); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.text(booking.schedule.destination.city, destX, yPos + 25); + + // Arrival time + const arrivalDate = new Date(booking.schedule.arrivalAt); + doc.setFontSize(14); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38); + + yPos += 48; + + // Train info + doc.setFillColor(250, 250, 250); + doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); + + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + 5, yPos + 5); + + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9); + + if (booking.schedule.trainName) { + doc.setFont('helvetica', 'normal'); + doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9); + } + + yPos += 18; + + // ============ PASSENGERS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('PASSENGERS', margin, yPos); + + yPos += 8; + + // Passenger table + const passengerData = booking.passengers.map((p, idx) => [ + (idx + 1).toString(), + p.fullName, + p.category, + p.seat?.number || '-', + p.seat?.coach || '-', + p.seat?.seatClass || '-', + ]); + + autoTable(doc, { + startY: yPos, + head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], + body: passengerData, + theme: 'striped', + headStyles: { + fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], + textColor: [255, 255, 255], + fontSize: 9, + fontStyle: 'bold', + }, + bodyStyles: { + fontSize: 9, + textColor: [darkGray[0], darkGray[1], darkGray[2]], + }, + alternateRowStyles: { + fillColor: [250, 250, 250], + }, + margin: { left: margin, right: margin }, + }); + + yPos = (doc as any).lastAutoTable.finalY + 10; + + // ============ PAYMENT SUMMARY ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('PAYMENT SUMMARY', margin, yPos); + + yPos += 8; + + doc.setFillColor(250, 250, 250); + doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F'); + + doc.setFontSize(10); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('Total Amount', margin + 5, yPos + 7); + + doc.setFontSize(16); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' }); + + doc.setFontSize(9); + doc.setTextColor(34, 197, 94); + doc.setFont('helvetica', 'bold'); + doc.text('✓ PAID', margin + 5, yPos + 15); + + yPos += 28; + + // ============ INSTRUCTIONS ============ + doc.setFillColor(252, 211, 77); + doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); + + doc.setFontSize(9); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8); + doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11); + doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15); + + // ============ FOOTER ============ + const footerY = pageHeight - 25; + + doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); + doc.line(margin, footerY, pageWidth - margin, footerY); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); + doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' }); + + doc.setFontSize(7); + doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + + // Watermark (removed rotation as it may cause issues) + doc.setTextColor(240, 240, 240); + doc.setFontSize(50); + doc.setFont('helvetica', 'bold'); + doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' }); + + // Save PDF + doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 948616110..d0cb89d19 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,6 +1,3 @@ -import { createRequire } from "module"; - -const require = createRequire(import.meta.url); /** @type {import('tailwindcss').Config} */ export default { @@ -90,3 +87,4 @@ export default { }, plugins: [], }; + diff --git a/packages/api-common/src/constants/apiModules.ts b/packages/api-common/src/constants/apiModules.ts index bb010a02e..78ef2badf 100644 --- a/packages/api-common/src/constants/apiModules.ts +++ b/packages/api-common/src/constants/apiModules.ts @@ -1,5 +1,6 @@ export const flatResponseModules: string[] = [ "/api/file-settings", + "api/me", "/api/auth", "/api/sessions", "/api/users", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 610821e53..3bca217d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -656,6 +656,9 @@ importers: '@tanstack/react-query': specifier: ^5.59.0 version: 5.101.0(react@18.3.1) + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 axios: specifier: ^1.7.7 version: 1.17.0 @@ -665,12 +668,21 @@ importers: date-fns: specifier: ^3.0.0 version: 3.6.0 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + jspdf-autotable: + specifier: ^5.0.8 + version: 5.0.8(jspdf@4.2.1) lucide-react: specifier: ^0.446.0 version: 0.446.0(react@18.3.1) next: specifier: ^14.2.0 version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 qrcode.react: specifier: ^3.1.0 version: 3.2.0(react@18.3.1) @@ -7786,9 +7798,21 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.8: + resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + jspdf@3.0.4: resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -19797,6 +19821,10 @@ snapshots: ms: 2.1.3 semver: 7.8.2 + jspdf-autotable@5.0.8(jspdf@4.2.1): + dependencies: + jspdf: 4.2.1 + jspdf@3.0.4: dependencies: '@babel/runtime': 7.29.7 @@ -19808,6 +19836,28 @@ snapshots: dompurify: 3.4.8 html2canvas: 1.4.1 + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.8 + html2canvas: 1.4.1 + + jsprim@1.4.2: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.8 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9