Merge pull request #253 from Tria-plc/freight/feature/vehicle_2

Freight/feature/vehicle 2
This commit is contained in:
yaschalew10
2026-06-24 09:59:04 +03:00
committed by GitHub
14 changed files with 916 additions and 765 deletions

View File

@@ -55,6 +55,7 @@ export class CreateFirstMileDto {
nullable: true, nullable: true,
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID() @IsUUID()
vehicleId?: string | null; vehicleId?: string | null;
} }

View File

@@ -55,6 +55,13 @@ export class FirstMileController {
return this.firstMileService.findById(id); 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() @Post()
@TrainSchedulingManage() @TrainSchedulingManage()
@ApiOperation({ summary: 'Create a first-mile leg' }) @ApiOperation({ summary: 'Create a first-mile leg' })

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { FirstMile } from './entities/first-mile.entity'; import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller'; import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository'; import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service'; import { FirstMileService } from './first-mile.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([FirstMile])], imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
controllers: [FirstMileController], controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService], providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
@@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable() @Injectable()
export class FirstMileService { 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<FirstMile | null> {
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<{ async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[]; data: FirstMile[];
@@ -45,7 +71,10 @@ export class FirstMileService {
const [data, total] = await this.firstMileRepository.findAndCount({ const [data, total] = await this.firstMileRepository.findAndCount({
where, where,
relations: { booking: true, vehicle: true }, relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder }, order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
@@ -64,7 +93,10 @@ export class FirstMileService {
async findById(id: string): Promise<FirstMile> { async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, { 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) { if (!record) {

View File

@@ -55,6 +55,7 @@ export class CreateLastMileDto {
nullable: true, nullable: true,
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID() @IsUUID()
vehicleId?: string | null; vehicleId?: string | null;
} }

View File

@@ -55,6 +55,13 @@ export class LastMileController {
return this.lastMileService.findById(id); 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() @Post()
@TrainSchedulingManage() @TrainSchedulingManage()
@ApiOperation({ summary: 'Create a last-mile leg' }) @ApiOperation({ summary: 'Create a last-mile leg' })

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { LastMile } from './entities/last-mile.entity'; import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller'; import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository'; import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service'; import { LastMileService } from './last-mile.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([LastMile])], imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
controllers: [LastMileController], controllers: [LastMileController],
providers: [LastMileRepository, LastMileService], providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable() @Injectable()
export class LastMileService { export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {} constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile> {
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<{ async findAll(filter: LastMileListFilter = {}): Promise<{
data: LastMile[]; data: LastMile[];
@@ -45,7 +68,10 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({ const [data, total] = await this.lastMileRepository.findAndCount({
where, where,
relations: { booking: true, vehicle: true }, relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder }, order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
@@ -64,7 +90,10 @@ export class LastMileService {
async findById(id: string): Promise<LastMile> { async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, { 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) { if (!record) {

View File

@@ -69,6 +69,18 @@ export const QUERY_KEYS = {
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
}, },
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) => ["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<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["last-mile", "detail", id] as const,
},
RULE_ENGINE: { RULE_ENGINE: {
ROOT: ["rule-engine"] as const, ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -365,6 +365,18 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/vehicles/${id}`, 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: { DRIVERS: {
BASE: '/drivers', BASE: '/drivers',
BY_ID: (id: string) => `/drivers/${id}`, BY_ID: (id: string) => `/drivers/${id}`,

View File

@@ -7,6 +7,7 @@ import {
RefreshCw, RefreshCw,
Truck, Truck,
} from "lucide-react"; } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { import {
@@ -28,29 +29,15 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import {
type LastMileStatus = "UNASSIGNED" | "ASSIGNED"; LAST_MILE_STATUSES,
type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED"; type LastMileApiStatus,
type LastMileRecord,
interface LastMileJob { lastMileService,
id: string; } from "@/services/last-mile.service";
bookingRef: string; import { vehiclesService } from "@/services/vehicles.service";
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;
}
const formatPrice = (amount: number) => const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", { `ETB ${amount.toLocaleString("en-US", {
@@ -58,158 +45,108 @@ const formatPrice = (amount: number) =>
maximumFractionDigits: 2, maximumFractionDigits: 2,
})}`; })}`;
const DELIVERY_STATUS_META: Record< const STATUS_META: Record<LastMileApiStatus, { label: string; color: string }> = {
DeliveryStatus,
{ label: string; color: string }
> = {
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" }, READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
IN_TRANSIT: { label: "In Transit", color: "indigo" },
DELIVERED: { label: "Delivered", color: "green" }, DELIVERED: { label: "Delivered", color: "green" },
}; };
// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered. const NEXT_STATUS: Partial<Record<LastMileApiStatus, LastMileApiStatus>> = {
const NEXT_DELIVERY_STATUS: Partial<Record<DeliveryStatus, DeliveryStatus>> = {
PAYMENT_PENDING: "READY_TO_TRANSIT", 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 AssignmentStatus = "ASSIGNED" | "UNASSIGNED";
type StatusFilter = type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
| "ALL"
| DeliveryStatus
| LastMileStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "All statuses" }, { value: "ALL", label: "All" },
{ value: "PAYMENT_PENDING", label: "Payment Pending" }, ...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
{ value: "READY_TO_TRANSIT", label: "Ready to Transit" },
{ value: "DELIVERED", label: "Delivered" },
{ value: "ASSIGNED", label: "Assigned" }, { value: "ASSIGNED", label: "Assigned" },
{ value: "UNASSIGNED", label: "Unassigned" }, { value: "UNASSIGNED", label: "Unassigned" },
]; ];
// Placeholder data — replace with a real last-mile service once the API exists. const vehicleLabel = (record: LastMileRecord) => {
const PLACEHOLDER_JOBS: LastMileJob[] = [ if (!record.vehicle) return null;
{ const v = record.vehicle;
id: "1", return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
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",
},
];
// Placeholder vehicle options — replace with the vehicles service. const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const VEHICLE_OPTIONS = [
{ value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
{ value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
{ value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, 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 }) => ( const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Stack gap={2}> <Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}> <Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
{label}
</Text>
<Text size="sm">{value}</Text> <Text size="sm">{value}</Text>
</Stack> </Stack>
); );
const BookingInfo = ({ job }: { job: LastMileJob }) => ( const BookingInfo = ({ record }: { record: LastMileRecord }) => (
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)"> <Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600}>{job.bookingRef}</Text> <Text fw={600}>{bookingRef(record)}</Text>
<Group gap="xs"> <Group gap="xs">
<Badge <Badge color={STATUS_META[record.status].color} variant="light" size="sm">
color={DELIVERY_STATUS_META[job.deliveryStatus].color} {STATUS_META[record.status].label}
variant="light"
size="sm"
>
{DELIVERY_STATUS_META[job.deliveryStatus].label}
</Badge> </Badge>
<Badge <Badge color={isAssigned(record) ? "green" : "orange"} variant="light" size="sm">
color={job.status === "ASSIGNED" ? "green" : "orange"} {isAssigned(record) ? "Assigned" : "Unassigned"}
variant="light"
size="sm"
>
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge> </Badge>
</Group> </Group>
</Group> </Group>
<SimpleGrid cols={2} spacing="sm"> <SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={job.customer} /> <InfoRow label="Customer" value={customerName(record)} />
<InfoRow label="Service type" value={job.serviceType} /> <InfoRow label="Service type" value={serviceTypeName(record)} />
<InfoRow label="Origin yard" value={job.originYard} /> <InfoRow label="Origin yard" value={originYardName(record)} />
<InfoRow label="Destination" value={job.destination} /> <InfoRow label="Destination" value={deliveryLocation(record)} />
<InfoRow label="Cargo" value={job.cargo} /> <InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Weight" value={job.weight} /> <InfoRow label="Price" value={formatPrice(priceAmount(record))} />
<InfoRow label="Price" value={formatPrice(job.price)} /> <InfoRow label="Contact" value={contactPersonName(record)} />
<InfoRow label="Contact" value={job.contactName} /> <InfoRow label="Phone" value={contactPhone(record)} />
<InfoRow label="Phone" value={job.contactPhone} /> <InfoRow label="Requested date" value={requestedDate(record)} />
<InfoRow label="Requested date" value={job.requestedDate} /> <InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Card> </Card>
); );
const tripSlipRows = (job: LastMileJob): [string, string][] => [ const tripSlipRows = (record: LastMileRecord): [string, string][] => [
["Customer", job.customer], ["Customer", customerName(record)],
["Service", job.serviceType], ["Service", serviceTypeName(record)],
["Origin yard", job.originYard], ["Origin yard", originYardName(record)],
["Destination", job.destination], ["Destination", deliveryLocation(record)],
["Cargo", job.cargo], ["Cargo", cargoDesc(record)],
["Weight", job.weight], ["Price", formatPrice(priceAmount(record))],
["Price", formatPrice(job.price)], ["Vehicle", vehicleLabel(record) ?? "Unassigned"],
["Vehicle", job.assignedVehicle ?? "Unassigned"], ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
["Contact", `${job.contactName} · ${job.contactPhone}`], ["Requested date", requestedDate(record)],
["Requested date", job.requestedDate], ["Status", STATUS_META[record.status].label],
["Delivery status", DELIVERY_STATUS_META[job.deliveryStatus].label],
]; ];
const SampleStamp = () => ( const SampleStamp = () => (
@@ -241,15 +178,9 @@ const SampleStamp = () => (
lineHeight: 1.1, lineHeight: 1.1,
}} }}
> >
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}> <Text size="9px" fw={700} style={{ letterSpacing: 1 }}>EDR FREIGHT</Text>
EDR FREIGHT <Text size="sm" fw={800}>APPROVED</Text>
</Text> <Text size="8px" fw={600}>OPERATIONS</Text>
<Text size="sm" fw={800}>
APPROVED
</Text>
<Text size="8px" fw={600}>
OPERATIONS
</Text>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -257,56 +188,36 @@ const SampleStamp = () => (
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}> <Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
<Text fw={600} size="sm"> <Text fw={600} size="sm">{title}</Text>
{title}
</Text>
<Group gap="xs" align="flex-end"> <Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">Name:</Text>
Name:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} /> <Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group> </Group>
<Group gap="xs" align="flex-end"> <Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">Signature:</Text>
Signature:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} /> <Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group> </Group>
{stamp && ( {stamp && (
<Box <Box style={{ position: "absolute", right: 4, top: 22, opacity: 0.85, pointerEvents: "none" }}>
style={{
position: "absolute",
right: 4,
top: 22,
opacity: 0.85,
pointerEvents: "none",
}}
>
{stamp} {stamp}
</Box> </Box>
)} )}
</Stack> </Stack>
); );
const TripSlipDocument = ({ job }: { job: LastMileJob }) => ( const TripSlipDocument = ({ record }: { record: LastMileRecord }) => (
<Stack gap="md"> <Stack gap="md">
<Stack gap={2} align="center"> <Stack gap={2} align="center">
<Text fw={700}>EDR Freight</Text> <Text fw={700}>EDR Freight</Text>
<Text size="sm" c="dimmed" tt="uppercase" fw={600}> <Text size="sm" c="dimmed" tt="uppercase" fw={600}>Last Mile Trip Slip</Text>
Last Mile Trip Slip
</Text>
</Stack> </Stack>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>{bookingRef(record)}</Text>
{job.bookingRef} <Text size="sm" c="dimmed">{requestedDate(record)}</Text>
</Text>
<Text size="sm" c="dimmed">
{job.requestedDate}
</Text>
</Group> </Group>
<Divider /> <Divider />
<SimpleGrid cols={2} spacing="xs"> <SimpleGrid cols={2} spacing="xs">
{tripSlipRows(job).map(([label, value]) => ( {tripSlipRows(record).map(([label, value]) => (
<InfoRow key={label} label={label} value={value} /> <InfoRow key={label} label={label} value={value} />
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -318,32 +229,22 @@ const TripSlipDocument = ({ job }: { job: LastMileJob }) => (
</Stack> </Stack>
); );
const escapeHtml = (value: string) => const escapeHtml = (v: string) =>
value v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const buildTripSlipHtml = (job: LastMileJob) => { const buildTripSlipHtml = (record: LastMileRecord) => {
const rows = tripSlipRows(job) const rows = tripSlipRows(record)
.map( .map(([l, v]) => `<tr><td class="lbl">${escapeHtml(l)}</td><td>${escapeHtml(v)}</td></tr>`)
([label, value]) =>
`<tr><td class="lbl">${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`,
)
.join(""); .join("");
const signature = (title: string, withStamp: boolean) => ` const sig = (title: string, withStamp: boolean) => `
<div class="sign-col"> <div class="sign-col">
<div class="sign-title">${title}</div> <div class="sign-title">${title}</div>
<div class="sign-field"><span>Name:</span><span class="line"></span></div> <div class="sign-field"><span>Name:</span><span class="line"></span></div>
<div class="sign-field"><span>Signature:</span><span class="line"></span></div> <div class="sign-field"><span>Signature:</span><span class="line"></span></div>
${ ${withStamp ? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>' : ""}
withStamp
? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>'
: ""
}
</div>`; </div>`;
return `<!doctype html><html><head><meta charset="utf-8" /> return `<!doctype html><html><head><meta charset="utf-8" />
<title>Trip Slip ${escapeHtml(job.bookingRef)}</title> <title>Trip Slip ${escapeHtml(bookingRef(record))}</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; } body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
@@ -368,18 +269,18 @@ const buildTripSlipHtml = (job: LastMileJob) => {
</style></head> </style></head>
<body onload="window.print()"> <body onload="window.print()">
<div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div> <div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div>
<div class="meta"><span>${escapeHtml(job.bookingRef)}</span><span>${escapeHtml(job.requestedDate)}</span></div> <div class="meta"><span>${escapeHtml(bookingRef(record))}</span><span>${escapeHtml(requestedDate(record))}</span></div>
<table>${rows}</table> <table>${rows}</table>
<div class="ack">Acknowledgement</div> <div class="ack">Acknowledgement</div>
<div class="signs">${signature("Driver", false)}${signature("Operator", true)}</div> <div class="signs">${sig("Driver", false)}${sig("Operator", true)}</div>
</body></html>`; </body></html>`;
}; };
const LastMilePage = () => { const LastMilePage = () => {
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [jobs, setJobs] = useState<LastMileJob[]>(PLACEHOLDER_JOBS);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL"); const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
@@ -388,13 +289,51 @@ const LastMilePage = () => {
const [bulkMode, setBulkMode] = useState(false); const [bulkMode, setBulkMode] = useState(false);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipJob, setTripSlipJob] = useState<LastMileJob | null>(null); const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null);
const [activeJobId, setActiveJobId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null); const [vehicleValue, setVehicleValue] = useState<string | null>(null);
const activeJob = useMemo( const { data: listData, isLoading } = useQuery({
() => jobs.find((job) => job.id === activeJobId) ?? null, queryKey: QUERY_KEYS.LAST_MILE.list(),
[jobs, activeJobId], 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( const selectedIds = useMemo(
@@ -402,160 +341,129 @@ const LastMilePage = () => {
[rowSelection], [rowSelection],
); );
const matchesStatusFilter = (job: LastMileJob) => { const matchesFilter = (r: LastMileRecord) => {
switch (statusFilter) { switch (statusFilter) {
case "ALL": case "ALL": return true;
return true; case "ASSIGNED": return isAssigned(r);
case "ASSIGNED": case "UNASSIGNED": return !isAssigned(r);
case "UNASSIGNED": default: return r.status === statusFilter;
return job.status === statusFilter;
default:
return job.deliveryStatus === statusFilter;
} }
}; };
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<StatusFilter, number> = { const counts: Record<StatusFilter, number> = {
ALL: jobs.length, ALL: records.length,
PAYMENT_PENDING: 0, PAYMENT_PENDING: 0,
READY_TO_TRANSIT: 0, READY_TO_TRANSIT: 0,
IN_TRANSIT: 0,
DELIVERED: 0, DELIVERED: 0,
ASSIGNED: 0, ASSIGNED: 0,
UNASSIGNED: 0, UNASSIGNED: 0,
}; };
for (const job of jobs) { for (const r of records) {
counts[job.deliveryStatus] += 1; counts[r.status] = (counts[r.status] ?? 0) + 1;
counts[job.status] += 1; if (isAssigned(r)) counts.ASSIGNED += 1;
else counts.UNASSIGNED += 1;
} }
return counts; return counts;
}, [jobs]); }, [records]);
const filteredJobs = useMemo(() => { const filteredRecords = useMemo(() => {
const term = search.trim().toLowerCase(); const term = search.trim().toLowerCase();
return jobs.filter((job) => { return records.filter((r) => {
if (!matchesStatusFilter(job)) return false; if (!matchesFilter(r)) return false;
if (!term) return true; if (!term) return true;
return [job.bookingRef, job.customer, job.destination, job.cargo] return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
.join(" ") .join(" ")
.toLowerCase() .toLowerCase()
.includes(term); .includes(term);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
const pagedJobs = useMemo(() => { const pagedRecords = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize; const start = pagination.pageIndex * pagination.pageSize;
return filteredJobs.slice(start, start + pagination.pageSize); return filteredRecords.slice(start, start + pagination.pageSize);
}, [filteredJobs, pagination.pageIndex, pagination.pageSize]); }, [filteredRecords, pagination]);
const openAssign = (jobId: string | null) => { const openAssign = (id: string | null) => {
const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
setBulkMode(false); setBulkMode(false);
setActiveJobId(resolved); setActiveId(resolved);
setVehicleValue(null); setVehicleValue(null);
setAssignOpen(true); setAssignOpen(true);
}; };
const openBulkAssign = () => { const openBulkAssign = () => {
setBulkMode(true); setBulkMode(true);
setActiveJobId(null); setActiveId(null);
setVehicleValue(null); setVehicleValue(null);
setAssignOpen(true); setAssignOpen(true);
}; };
const openDetail = (jobId: string) => {
setActiveJobId(jobId);
setDetailOpen(true);
};
const closeAssign = () => { const closeAssign = () => {
setAssignOpen(false); setAssignOpen(false);
setBulkMode(false); setBulkMode(false);
setActiveJobId(null); setActiveId(null);
setVehicleValue(null); setVehicleValue(null);
}; };
const closeDetail = () => {
setDetailOpen(false);
setActiveJobId(null);
};
const handleAssign = () => { const handleAssign = () => {
if (!vehicleValue) { if (!vehicleValue) {
toast({ toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
title: "Select a vehicle",
description: "Choose a vehicle to assign to this delivery.",
variant: "destructive",
});
return; return;
} }
const vehicleLabel =
VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue;
const targetIds = bulkMode const targetIds = bulkMode
? selectedIds ? selectedIds
: [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter( : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
(id): id is string => Boolean(id),
);
if (!targetIds.length) return; if (!targetIds.length) return;
const targetSet = new Set(targetIds); const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue;
setJobs((current) =>
current.map((job) =>
targetSet.has(job.id)
? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel }
: job,
),
);
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
.then(() => {
toast({ toast({
title: "Vehicle assigned", title: "Vehicle assigned",
description: bulkMode description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel,
? `${targetIds.length} deliveries → ${vehicleLabel}`
: vehicleLabel,
}); });
if (bulkMode) setRowSelection({}); if (bulkMode) setRowSelection({});
closeAssign(); closeAssign();
})
.catch(() => void 0);
}; };
const handleAdvanceStatus = (job: LastMileJob) => { const handleAdvanceStatus = (record: LastMileRecord) => {
const next = NEXT_DELIVERY_STATUS[job.deliveryStatus]; const next = NEXT_STATUS[record.status];
if (!next) return; if (!next) return;
setJobs((current) => updateMutation.mutate(
current.map((item) => { id: record.id, data: { status: next } },
item.id === job.id ? { ...item, deliveryStatus: next } : item, {
), 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) => { const handlePrintTripSlip = (record: LastMileRecord) => {
setTripSlipJob(job); setTripSlipRecord(record);
setTripSlipOpen(true); setTripSlipOpen(true);
}; };
const printTripSlip = () => { const printTripSlip = () => {
if (!tripSlipJob) return; if (!tripSlipRecord) return;
const win = window.open("", "_blank", "width=820,height=920"); const win = window.open("", "_blank", "width=820,height=920");
if (!win) { if (!win) {
toast({ toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" });
title: "Pop-up blocked",
description: "Allow pop-ups to print the trip slip.",
variant: "destructive",
});
return; return;
} }
win.document.write(buildTripSlipHtml(tripSlipJob)); win.document.write(buildTripSlipHtml(tripSlipRecord));
win.document.close(); win.document.close();
}; };
const columns = useMemo((): ColumnDef<LastMileJob>[] => { const columns = useMemo((): ColumnDef<LastMileRecord>[] => {
const headerClassName = ruleEngineTable.headerCell; const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell; const cellClassName = ruleEngineTable.bodyCell;
return [ return [
@@ -567,9 +475,7 @@ const LastMilePage = () => {
<Checkbox <Checkbox
aria-label="Select all" aria-label="Select all"
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
indeterminate={ indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
}
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)} onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
/> />
), ),
@@ -586,67 +492,54 @@ const LastMilePage = () => {
id: "bookingRef", id: "bookingRef",
header: "Booking", header: "Booking",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => ( cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
<Text size="sm" fw={600}>
{row.original.bookingRef}
</Text>
),
}, },
{ {
id: "customer", id: "customer",
header: "Customer", header: "Customer",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.customer, cell: ({ row }) => customerName(row.original),
}, },
{ {
id: "destination", id: "destination",
header: "Destination", header: "Destination",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.destination, cell: ({ row }) => deliveryLocation(row.original),
}, },
{ {
id: "cargo", id: "cargo",
header: "Cargo", header: "Cargo",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.cargo, cell: ({ row }) => cargoDesc(row.original),
}, },
{ {
id: "price", id: "price",
header: "Price", header: "Price",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.price), cell: ({ row }) => formatPrice(priceAmount(row.original)),
}, },
{ {
id: "vehicle", id: "vehicle",
header: "Vehicle", header: "Vehicle",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed"></Text>,
row.original.assignedVehicle ?? <Text c="dimmed"></Text>,
},
{
id: "deliveryStatus",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = DELIVERY_STATUS_META[row.original.deliveryStatus];
return (
<Badge color={meta.color} variant="light" size="sm">
{meta.label}
</Badge>
);
},
}, },
{ {
id: "status", id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
},
},
{
id: "assignment",
header: "Assignment", header: "Assignment",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
color={row.original.status === "ASSIGNED" ? "green" : "orange"} {isAssigned(row.original) ? "Assigned" : "Unassigned"}
variant="light"
size="sm"
>
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge> </Badge>
), ),
}, },
@@ -655,11 +548,9 @@ const LastMilePage = () => {
header: "Actions", header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => { cell: ({ row }) => {
const isAssigned = row.original.status === "ASSIGNED"; const assigned = isAssigned(row.original);
const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus]; const nextStatus = NEXT_STATUS[row.original.status];
const canPrintTripSlip = const canPrint = row.original.status !== "PAYMENT_PENDING";
row.original.deliveryStatus === "READY_TO_TRANSIT" ||
row.original.deliveryStatus === "DELIVERED";
return ( return (
<Group gap={4} justify="flex-end" wrap="nowrap"> <Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal> <Menu position="bottom-end" width={200} withinPortal>
@@ -674,21 +565,19 @@ const LastMilePage = () => {
disabled={!nextStatus} disabled={!nextStatus}
onClick={() => handleAdvanceStatus(row.original)} onClick={() => handleAdvanceStatus(row.original)}
> >
{nextStatus {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
? `Mark ${DELIVERY_STATUS_META[nextStatus].label}`
: "Delivered"}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<Truck size={15} />} leftSection={<Truck size={15} />}
disabled={isAssigned} disabled={assigned}
onClick={() => openAssign(row.original.id)} onClick={() => openAssign(row.original.id)}
> >
Assign Assign
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<RefreshCw size={15} />} leftSection={<RefreshCw size={15} />}
disabled={!isAssigned} disabled={!assigned}
onClick={() => openAssign(row.original.id)} onClick={() => openAssign(row.original.id)}
> >
Reassign Reassign
@@ -696,11 +585,11 @@ const LastMilePage = () => {
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<Eye size={15} />} leftSection={<Eye size={15} />}
onClick={() => openDetail(row.original.id)} onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
> >
View detail View detail
</Menu.Item> </Menu.Item>
{canPrintTripSlip && ( {canPrint && (
<Menu.Item <Menu.Item
leftSection={<Printer size={15} />} leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)} onClick={() => handlePrintTripSlip(row.original)}
@@ -715,18 +604,12 @@ const LastMilePage = () => {
}, },
}, },
]; ];
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [vehicleOptions]);
const tableStatus = "success" as const;
return ( return (
<Stack gap="md"> <Stack gap="md">
<Card <Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
radius="lg"
padding={0}
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm"> <Stack gap="sm">
@@ -739,18 +622,11 @@ const LastMilePage = () => {
/> />
<Group gap="sm"> <Group gap="sm">
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<Button <Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
variant="light"
leftSection={<Truck size={16} />}
onClick={openBulkAssign}
>
Assign vehicle ({selectedIds.length}) Assign vehicle ({selectedIds.length})
</Button> </Button>
)} )}
<Button <Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
leftSection={<Truck size={16} />}
onClick={() => openAssign(null)}
>
Assign Mile Assign Mile
</Button> </Button>
</Group> </Group>
@@ -768,7 +644,7 @@ const LastMilePage = () => {
setPagination((p) => ({ ...p, pageIndex: 0 })); setPagination((p) => ({ ...p, pageIndex: 0 }));
}} }}
> >
{option.label} ({statusCounts[option.value]}) {option.label} ({statusCounts[option.value] ?? 0})
</Button> </Button>
); );
})} })}
@@ -778,14 +654,14 @@ const LastMilePage = () => {
<DataTable <DataTable
columns={columns} columns={columns}
data={pagedJobs} data={pagedRecords}
status={tableStatus} status={isLoading ? "loading" : "success"}
emptyMessage="No last-mile deliveries found" emptyMessage="No last-mile deliveries found"
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
pageCount, pageCount,
totalCount: filteredJobs.length, totalCount: filteredRecords.length,
}} }}
tableOptions={{ tableOptions={{
manualPagination: true, manualPagination: true,
@@ -797,17 +673,14 @@ const LastMilePage = () => {
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
}} }}
containerClassName="border-0 shadow-none bg-transparent" containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => ( footer={({ table, pagination: fp }) => (
<DataTableFooter <DataTableFooter table={table} pagination={fp} options={{ labels: { items: "deliveries" } }} />
table={table}
pagination={footerPagination}
options={{ labels: { items: "deliveries" } }}
/>
)} )}
/> />
</Stack> </Stack>
</Card> </Card>
{/* Assign / Reassign modal */}
<Modal <Modal
opened={assignOpen} opened={assignOpen}
onClose={closeAssign} onClose={closeAssign}
@@ -820,59 +693,54 @@ const LastMilePage = () => {
{bulkMode ? ( {bulkMode ? (
<Text size="sm"> <Text size="sm">
Assigning a vehicle to{" "} Assigning a vehicle to{" "}
<Text span fw={600}> <Text span fw={600}>{selectedIds.length}</Text>{" "}
{selectedIds.length}
</Text>{" "}
selected {selectedIds.length === 1 ? "delivery" : "deliveries"}. selected {selectedIds.length === 1 ? "delivery" : "deliveries"}.
</Text> </Text>
) : activeJob ? ( ) : activeRecord ? (
<BookingInfo job={activeJob} /> <BookingInfo record={activeRecord} />
) : ( ) : (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">No unassigned deliveries available.</Text>
No unassigned deliveries available.
</Text>
)} )}
<Divider /> <Divider />
<Select <Select
label="Vehicle" label="Vehicle"
placeholder="Select a vehicle" placeholder="Select a vehicle"
data={VEHICLE_OPTIONS} data={vehicleOptions}
value={vehicleValue} value={vehicleValue}
onChange={setVehicleValue} onChange={setVehicleValue}
searchable searchable
/> />
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}> <Button variant="default" onClick={closeAssign}>Cancel</Button>
Cancel
</Button>
<Button <Button
onClick={handleAssign} onClick={handleAssign}
disabled={bulkMode ? selectedIds.length === 0 : !activeJob} loading={updateMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
> >
{!bulkMode && activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"} {!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
{/* View detail modal */}
<Modal <Modal
opened={detailOpen} opened={detailOpen}
onClose={closeDetail} onClose={() => { setDetailOpen(false); setActiveId(null); }}
title={<Text fw={600}>Delivery Detail</Text>} title={<Text fw={600}>Delivery Detail</Text>}
size="lg" size="lg"
radius="lg" radius="lg"
centered centered
> >
<Stack gap="md"> <Stack gap="md">
{activeJob && <BookingInfo job={activeJob} />} {activeRecord && <BookingInfo record={activeRecord} />}
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closeDetail}> <Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
Close
</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
{/* Trip slip modal */}
<Modal <Modal
opened={tripSlipOpen} opened={tripSlipOpen}
onClose={() => setTripSlipOpen(false)} onClose={() => setTripSlipOpen(false)}
@@ -882,15 +750,11 @@ const LastMilePage = () => {
centered centered
> >
<Stack gap="md"> <Stack gap="md">
{tripSlipJob && <TripSlipDocument job={tripSlipJob} />} {tripSlipRecord && <TripSlipDocument record={tripSlipRecord} />}
<Divider /> <Divider />
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setTripSlipOpen(false)}> <Button variant="default" onClick={() => setTripSlipOpen(false)}>Close</Button>
Close <Button leftSection={<Printer size={16} />} onClick={printTripSlip}>Print</Button>
</Button>
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>
Print
</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -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<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
};

View File

@@ -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<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
api.patch<LastMileRecord>(LM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<LastMileRecord>(LM.ACCEPT(bookingReference)),
};