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..16337230e 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 { BadRequestException, 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,34 @@ 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) { + 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: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; @@ -45,7 +73,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 +95,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 77c4ea10e..05b492feb 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -365,6 +365,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..d9e4c372c 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,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 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 { vehiclesService } from "@/services/vehicles.service"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -58,97 +45,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 +109,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 +205,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 +244,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 +304,51 @@ 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 { 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 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 activeRecord = useMemo( + () => records.find((r) => r.id === activeId) ?? null, + [records, activeId], ); const selectedIds = useMemo( @@ -402,160 +356,129 @@ const FirstMilePage = () => { [rowSelection], ); - const matchesStatusFilter = (job: FirstMileJob) => { + 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 +490,7 @@ const FirstMilePage = () => { table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), @@ -586,67 +507,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 +563,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 +580,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 +600,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 +619,12 @@ const FirstMilePage = () => { }, }, ]; - }, []); - - const tableStatus = "success" as const; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleOptions]); return ( - + @@ -739,18 +637,11 @@ const FirstMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -768,7 +659,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - {option.label} ({statusCounts[option.value]}) + {option.label} ({statusCounts[option.value] ?? 0}) ); })} @@ -778,14 +669,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. )} - + + {/* View detail modal */} { setDetailOpen(false); setActiveId(null); }} title={Delivery Detail} size="lg" radius="lg" centered > - {activeJob && } + {activeRecord && } - + + {/* Trip slip modal */} setTripSlipOpen(false)} @@ -882,15 +750,11 @@ const LastMilePage = () => { centered > - {tripSlipJob && } + {tripSlipRecord && } - - + + 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 new file mode 100644 index 000000000..f761719ae --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -0,0 +1,64 @@ +import { api } from '../auth/http'; +import { URL_CONSTANTS } from '@/constants/URLS'; + +export const FIRST_MILE_STATUSES = [ + 'PAYMENT_PENDING', + 'READY_TO_TRANSIT', + 'IN_TRANSIT', + 'RECEIVED_TO_PORT', +] as const; +export type FirstMileApiStatus = (typeof FIRST_MILE_STATUSES)[number]; + +export interface FirstMileBooking { + id: string; + reference: string; + firstMilePickupAddress?: string | null; + cargoFreeText?: string | null; + cargoTotalWeightVgm: number; + totalAmount: number; + scheduledDate?: string | null; + company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; + serviceType?: { id: string; name?: string } | null; + originYard?: { id: string; name?: string } | null; + destinationYard?: { id: string; name?: string } | null; + cargoType?: { id: string; name?: string } | null; +} + +export interface FirstMileVehicle { + id: string; + plateNumber: string; + manufacturer: string; + model: string; +} + +export interface FirstMileRecord { + id: string; + bookingId: string; + status: FirstMileApiStatus; + advancedPayment: number; + remainingPayment: number; + estimatedKm?: number | null; + exactKm?: number | null; + vehicleId?: string | null; + booking?: FirstMileBooking | null; + vehicle?: FirstMileVehicle | null; + createdAt: string; + updatedAt: string; +} + +export interface FirstMileListResponse { + data: FirstMileRecord[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; +} + +const FM = URL_CONSTANTS.FIRST_MILE; + +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 }) => + api.patch(FM.BY_ID(id), data), + accept: (bookingReference: string) => + api.post(FM.ACCEPT(bookingReference)), +}; 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 new file mode 100644 index 000000000..5d8b60df2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -0,0 +1,64 @@ +import { api } from '../auth/http'; +import { URL_CONSTANTS } from '@/constants/URLS'; + +export const LAST_MILE_STATUSES = [ + 'PAYMENT_PENDING', + 'READY_TO_TRANSIT', + 'IN_TRANSIT', + 'DELIVERED', +] as const; +export type LastMileApiStatus = (typeof LAST_MILE_STATUSES)[number]; + +export interface LastMileBooking { + id: string; + reference: string; + lastMileDeliveryAddress?: string | null; + cargoFreeText?: string | null; + cargoTotalWeightVgm: number; + totalAmount: number; + scheduledDate?: string | null; + company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; + serviceType?: { id: string; name?: string } | null; + originYard?: { id: string; name?: string } | null; + destinationYard?: { id: string; name?: string } | null; + cargoType?: { id: string; name?: string } | null; +} + +export interface LastMileVehicle { + id: string; + plateNumber: string; + manufacturer: string; + model: string; +} + +export interface LastMileRecord { + id: string; + bookingId: string; + status: LastMileApiStatus; + advancedPayment: number; + remainingPayment: number; + estimatedKm?: number | null; + exactKm?: number | null; + vehicleId?: string | null; + booking?: LastMileBooking | null; + vehicle?: LastMileVehicle | null; + createdAt: string; + updatedAt: string; +} + +export interface LastMileListResponse { + data: LastMileRecord[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; +} + +const LM = URL_CONSTANTS.LAST_MILE; + +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 }) => + api.patch(LM.BY_ID(id), data), + accept: (bookingReference: string) => + api.post(LM.ACCEPT(bookingReference)), +};