mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix last first mile
This commit is contained in:
@@ -55,6 +55,7 @@ export class CreateFirstMileDto {
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||
@IsUUID()
|
||||
vehicleId?: string | null;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<FirstMile> {
|
||||
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<FirstMile> {
|
||||
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) {
|
||||
|
||||
@@ -55,6 +55,7 @@ export class CreateLastMileDto {
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||
@IsUUID()
|
||||
vehicleId?: string | null;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<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<{
|
||||
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<LastMile> {
|
||||
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) {
|
||||
|
||||
@@ -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<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: {
|
||||
ROOT: ["rule-engine"] as const,
|
||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<FirstMileApiStatus, { label: string; color: string }> = {
|
||||
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<Record<PickupStatus, PickupStatus>> = {
|
||||
PAYMENT_PENDING: "READY_FOR_PICKUP",
|
||||
READY_FOR_PICKUP: "PICKED_UP",
|
||||
const NEXT_STATUS: Partial<Record<FirstMileApiStatus, FirstMileApiStatus>> = {
|
||||
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 }) => (
|
||||
<Stack gap={2}>
|
||||
@@ -159,57 +109,51 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const BookingInfo = ({ job }: { job: FirstMileJob }) => (
|
||||
const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{job.bookingRef}</Text>
|
||||
<Text fw={600}>{bookingRef(record)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
color={PICKUP_STATUS_META[job.pickupStatus].color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{PICKUP_STATUS_META[job.pickupStatus].label}
|
||||
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||
{STATUS_META[record.status].label}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={job.status === "ASSIGNED" ? "green" : "orange"}
|
||||
color={isAssigned(record) ? "green" : "orange"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
|
||||
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={job.customer} />
|
||||
<InfoRow label="Service type" value={job.serviceType} />
|
||||
<InfoRow label="Pickup location" value={job.pickup} />
|
||||
<InfoRow label="Destination yard" value={job.destinationYard} />
|
||||
<InfoRow label="Cargo" value={job.cargo} />
|
||||
<InfoRow label="Weight" value={job.weight} />
|
||||
<InfoRow label="Price" value={formatPrice(job.price)} />
|
||||
<InfoRow label="Contact" value={job.contactName} />
|
||||
<InfoRow label="Phone" value={job.contactPhone} />
|
||||
<InfoRow label="Requested date" value={job.requestedDate} />
|
||||
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||
<InfoRow label="Destination yard" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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}
|
||||
</Text>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
Name:
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">Name:</Text>
|
||||
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
||||
</Group>
|
||||
<Group gap="xs" align="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
Signature:
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">Signature:</Text>
|
||||
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
|
||||
</Group>
|
||||
{stamp && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
top: 22,
|
||||
opacity: 0.85,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Box style={{ position: "absolute", right: 4, top: 22, opacity: 0.85, pointerEvents: "none" }}>
|
||||
{stamp}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const TripSlipDocument = ({ job }: { job: FirstMileJob }) => (
|
||||
const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => (
|
||||
<Stack gap="md">
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700}>EDR Freight</Text>
|
||||
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>
|
||||
First Mile Trip Slip
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>First Mile Trip Slip</Text>
|
||||
</Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{job.bookingRef}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{job.requestedDate}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>{bookingRef(record)}</Text>
|
||||
<Text size="sm" c="dimmed">{requestedDate(record)}</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{tripSlipRows(job).map(([label, value]) => (
|
||||
{tripSlipRows(record).map(([label, value]) => (
|
||||
<InfoRow key={label} label={label} value={value} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -318,32 +244,22 @@ const TripSlipDocument = ({ job }: { job: FirstMileJob }) => (
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escapeHtml = (v: string) =>
|
||||
v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const buildTripSlipHtml = (job: FirstMileJob) => {
|
||||
const rows = tripSlipRows(job)
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td class="lbl">${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`,
|
||||
)
|
||||
const buildTripSlipHtml = (record: FirstMileRecord) => {
|
||||
const rows = tripSlipRows(record)
|
||||
.map(([l, v]) => `<tr><td class="lbl">${escapeHtml(l)}</td><td>${escapeHtml(v)}</td></tr>`)
|
||||
.join("");
|
||||
const signature = (title: string, withStamp: boolean) => `
|
||||
const sig = (title: string, withStamp: boolean) => `
|
||||
<div class="sign-col">
|
||||
<div class="sign-title">${title}</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>
|
||||
${
|
||||
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>`;
|
||||
return `<!doctype html><html><head><meta charset="utf-8" />
|
||||
<title>Trip Slip ${escapeHtml(job.bookingRef)}</title>
|
||||
<title>Trip Slip ${escapeHtml(bookingRef(record))}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
|
||||
@@ -368,18 +284,18 @@ const buildTripSlipHtml = (job: FirstMileJob) => {
|
||||
</style></head>
|
||||
<body onload="window.print()">
|
||||
<div class="head"><h1>EDR Freight</h1><p>First 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>
|
||||
<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>`;
|
||||
};
|
||||
|
||||
const FirstMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const [jobs, setJobs] = useState<FirstMileJob[]>(PLACEHOLDER_JOBS);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
@@ -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<FirstMileJob | null>(null);
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const [tripSlipRecord, setTripSlipRecord] = useState<FirstMileRecord | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [vehicleValue, setVehicleValue] = useState<string | null>(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<StatusFilter, number> = {
|
||||
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<FirstMileJob>[] => {
|
||||
const columns = useMemo((): ColumnDef<FirstMileRecord>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
@@ -567,9 +490,7 @@ const FirstMilePage = () => {
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={
|
||||
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
|
||||
}
|
||||
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
|
||||
/>
|
||||
),
|
||||
@@ -586,67 +507,54 @@ const FirstMilePage = () => {
|
||||
id: "bookingRef",
|
||||
header: "Booking",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.bookingRef}
|
||||
</Text>
|
||||
),
|
||||
cell: ({ row }) => <Text size="sm" fw={600}>{bookingRef(row.original)}</Text>,
|
||||
},
|
||||
{
|
||||
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 ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "pickupStatus",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const meta = PICKUP_STATUS_META[row.original.pickupStatus];
|
||||
return (
|
||||
<Badge color={meta.color} variant="light" size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
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",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
color={row.original.status === "ASSIGNED" ? "green" : "orange"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
|
||||
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
|
||||
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -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 (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -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}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={isAssigned}
|
||||
disabled={assigned}
|
||||
onClick={() => openAssign(row.original.id)}
|
||||
>
|
||||
Assign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
disabled={!isAssigned}
|
||||
disabled={!assigned}
|
||||
onClick={() => openAssign(row.original.id)}
|
||||
>
|
||||
Reassign
|
||||
@@ -696,11 +600,11 @@ const FirstMilePage = () => {
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() => openDetail(row.original.id)}
|
||||
onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }}
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
{canPrintTripSlip && (
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
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 (
|
||||
<Stack gap="md">
|
||||
<Card
|
||||
radius="lg"
|
||||
padding={0}
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Stack gap="sm">
|
||||
@@ -739,18 +637,11 @@ const FirstMilePage = () => {
|
||||
/>
|
||||
<Group gap="sm">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={openBulkAssign}
|
||||
>
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
||||
Assign vehicle ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={() => openAssign(null)}
|
||||
>
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -768,7 +659,7 @@ const FirstMilePage = () => {
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
{option.label} ({statusCounts[option.value]})
|
||||
{option.label} ({statusCounts[option.value] ?? 0})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -778,14 +669,14 @@ const FirstMilePage = () => {
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={pagedJobs}
|
||||
status={tableStatus}
|
||||
data={pagedRecords}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No first-mile pickups found"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredJobs.length,
|
||||
totalCount: filteredRecords.length,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -797,17 +688,14 @@ const FirstMilePage = () => {
|
||||
onRowSelectionChange: setRowSelection,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{ labels: { items: "pickups" } }}
|
||||
/>
|
||||
footer={({ table, pagination: fp }) => (
|
||||
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "pickups" } }} />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Assign / Reassign modal */}
|
||||
<Modal
|
||||
opened={assignOpen}
|
||||
onClose={closeAssign}
|
||||
@@ -820,59 +708,54 @@ const FirstMilePage = () => {
|
||||
{bulkMode ? (
|
||||
<Text size="sm">
|
||||
Assigning a vehicle to{" "}
|
||||
<Text span fw={600}>
|
||||
{selectedIds.length}
|
||||
</Text>{" "}
|
||||
<Text span fw={600}>{selectedIds.length}</Text>{" "}
|
||||
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
|
||||
</Text>
|
||||
) : activeJob ? (
|
||||
<BookingInfo job={activeJob} />
|
||||
) : activeRecord ? (
|
||||
<BookingInfo record={activeRecord} />
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No unassigned pickups available.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
|
||||
)}
|
||||
<Divider />
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select a vehicle"
|
||||
data={VEHICLE_OPTIONS}
|
||||
data={vehicleOptions}
|
||||
value={vehicleValue}
|
||||
onChange={setVehicleValue}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
<Button
|
||||
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>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* View detail modal */}
|
||||
<Modal
|
||||
opened={detailOpen}
|
||||
onClose={closeDetail}
|
||||
onClose={() => { setDetailOpen(false); setActiveId(null); }}
|
||||
title={<Text fw={600}>Pickup Detail</Text>}
|
||||
size="lg"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeJob && <BookingInfo job={activeJob} />}
|
||||
{activeRecord && <BookingInfo record={activeRecord} />}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDetail}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Trip slip modal */}
|
||||
<Modal
|
||||
opened={tripSlipOpen}
|
||||
onClose={() => setTripSlipOpen(false)}
|
||||
@@ -882,15 +765,11 @@ const FirstMilePage = () => {
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{tripSlipJob && <TripSlipDocument job={tripSlipJob} />}
|
||||
{tripSlipRecord && <TripSlipDocument record={tripSlipRecord} />}
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setTripSlipOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>
|
||||
Print
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => setTripSlipOpen(false)}>Close</Button>
|
||||
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>Print</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
};
|
||||
@@ -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)),
|
||||
};
|
||||
Reference in New Issue
Block a user