This commit is contained in:
natib21
2026-07-03 19:17:45 +00:00
parent 2550172480
commit a1dfb439ff
9 changed files with 289 additions and 131 deletions

View File

@@ -0,0 +1,8 @@
import { IsArray, IsUUID } from 'class-validator';
/** Replace the full set of vehicles assigned to a last-mile delivery. */
export class SetVehiclesDto {
@IsArray()
@IsUUID('4', { each: true })
vehicleIds!: string[];
}

View File

@@ -18,6 +18,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
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 { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service'; import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileInvoiceService } from './last-mile-invoice.service';
@@ -136,4 +137,14 @@ export class LastMileController {
) { ) {
return this.lastMileService.allocateContainers(id, dto.allocations); return this.lastMileService.allocateContainers(id, dto.allocations);
} }
@Post(':id/vehicles')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetVehiclesDto,
) {
return this.lastMileService.setVehicles(id, dto.vehicleIds);
}
} }

View File

@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module'; import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity'; import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileController } from './last-mile.controller'; import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository'; import { LastMileRepository } from './last-mile.repository';
@@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
BillingModule, BillingModule,
forwardRef(() => BookingsModule), forwardRef(() => BookingsModule),
VehiclesModule, VehiclesModule,

View File

@@ -10,6 +10,7 @@ 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';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository'; import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service'; import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
@@ -149,8 +150,9 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({ const [data, total] = await this.lastMileRepository.findAndCount({
where, where,
relations: { relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
vehicle: true, vehicle: true,
vehicleAssignments: { vehicle: true },
}, },
order: { [sortBy]: sortOrder }, order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
@@ -171,8 +173,9 @@ 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: { relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
vehicle: true, vehicle: true,
vehicleAssignments: { vehicle: true },
}, },
}); });
@@ -353,6 +356,70 @@ export class LastMileService {
await this.vehiclesService.releaseIfUnused(vehicleIds); await this.vehiclesService.releaseIfUnused(vehicleIds);
} }
/**
* Replace the full set of vehicles serving a last-mile delivery (multi-truck).
* Diffs against the current junction rows, syncing availability + audit history
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
* `vehicleId` column for back-compat with single-vehicle readers.
*/
async setVehicles(id: string, vehicleIds: string[]): Promise<LastMile> {
const existing = await this.findById(id);
const desired = [...new Set(vehicleIds.filter(Boolean))];
const manager = this.dataSource.manager;
const current = await manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
});
const currentIds = current.map((a) => a.vehicleId);
const currentSet = new Set(currentIds);
const desiredSet = new Set(desired);
const added = desired.filter((v) => !currentSet.has(v));
const removed = currentIds.filter((v) => !desiredSet.has(v));
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
await tx.delete(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId });
}
});
// Legacy primary vehicle = first of the set (null when cleared).
await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of added) {
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
void this.notifyDriverAssignment(vehicleId, existing);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
lastMileId: id,
driverId: info.driverId,
label: existing.status,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of removed) {
await this.vehiclesService.releaseIfUnused([vehicleId]);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
lastMileId: id,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return this.findById(id);
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> { private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try { try {
const vehicle = await this.vehiclesService.findById(vehicleId); const vehicle = await this.vehiclesService.findById(vehicleId);

View File

@@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce
// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat').
const RABBITMQ_URL =
process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672";
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
@@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "SMS_SERVICE", name: "SMS_SERVICE",
transport: Transport.RMQ, transport: Transport.RMQ,
options: { options: {
urls: [process.env.RABBITMQ_URL as string], urls: [RABBITMQ_URL],
queue: process.env.SMS_QUEUE ?? "sms_queue", queue: process.env.SMS_QUEUE ?? "sms_queue",
queueOptions: { durable: true }, queueOptions: { durable: true },
}, },
@@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "EMAIL_SERVICE", name: "EMAIL_SERVICE",
transport: Transport.RMQ, transport: Transport.RMQ,
options: { options: {
urls: [process.env.RABBITMQ_URL as string], urls: [RABBITMQ_URL],
queue: process.env.EMAIL_QUEUE ?? "email_queue", queue: process.env.EMAIL_QUEUE ?? "email_queue",
queueOptions: { durable: true }, queueOptions: { durable: true },
}, },

View File

@@ -3,6 +3,7 @@ import { WagonStatus } from '@edr/types';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity'; import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company } from '../modules/companies/entities/company.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
@@ -116,6 +117,11 @@ export class MarshallingDemoTrainsSeeder {
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null; : null;
// bookings.company_id is NOT NULL — reuse any seeded company for the demo.
const company = await this.dataSource
.getRepository(Company)
.findOne({ where: {}, order: { createdAt: 'ASC' } });
const missing = [ const missing = [
!djiboutiYard ? 'Djibouti yard' : '', !djiboutiYard ? 'Djibouti yard' : '',
!ethiopiaYard ? 'Ethiopia yard' : '', !ethiopiaYard ? 'Ethiopia yard' : '',
@@ -124,6 +130,7 @@ export class MarshallingDemoTrainsSeeder {
!warehouse ? 'INDODE_OPEN warehouse' : '', !warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '', !warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '', !warehouseZone ? 'warehouse zone' : '',
!company ? 'company' : '',
].filter(Boolean); ].filter(Boolean);
if (missing.length) { if (missing.length) {
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
@@ -141,6 +148,7 @@ export class MarshallingDemoTrainsSeeder {
warehouse: warehouse!, warehouse: warehouse!,
warehouseYard: warehouseYard!, warehouseYard: warehouseYard!,
warehouseZone: warehouseZone!, warehouseZone: warehouseZone!,
company: company!,
}); });
if (created) seeded += 1; if (created) seeded += 1;
} }
@@ -164,6 +172,7 @@ export class MarshallingDemoTrainsSeeder {
warehouse: Warehouse; warehouse: Warehouse;
warehouseYard: WarehouseYard; warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone; warehouseZone: WarehouseZone;
company: Company;
}, },
): Promise<boolean> { ): Promise<boolean> {
const bookingRepo = this.dataSource.getRepository(Booking); const bookingRepo = this.dataSource.getRepository(Booking);
@@ -231,6 +240,7 @@ export class MarshallingDemoTrainsSeeder {
const booking = await bookingRepo.save( const booking = await bookingRepo.save(
bookingRepo.create({ bookingRepo.create({
reference: bookingReference, reference: bookingReference,
companyId: refs.company.id,
originYardId: originYard.id, originYardId: originYard.id,
destinationYardId: destinationYard.id, destinationYardId: destinationYard.id,
serviceTypeId: refs.serviceType.id, serviceTypeId: refs.serviceType.id,

View File

@@ -755,30 +755,6 @@ const FirstMilePage = () => {
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => customerName(row.original), cell: ({ row }) => customerName(row.original),
}, },
{
id: "pickup",
header: "Pickup",
meta: { headerClassName, cellClassName },
cell: ({ row }) => pickupLocation(row.original),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => destinationYardName(row.original),
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => cargoDesc(row.original),
},
{
id: "advancedPayment",
header: "Advanced Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.advancedPayment),
},
{ {
id: "postPayment", id: "postPayment",
header: "Post Payment", header: "Post Payment",
@@ -789,13 +765,8 @@ const FirstMilePage = () => {
id: "vehicle", id: "vehicle",
header: "Vehicle", header: "Vehicle",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed"></Text>, cell: ({ row }) =>
}, vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>,
{
id: "estimatedKm",
header: "Est. Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed"></Text>,
}, },
{ {
id: "exactKm", id: "exactKm",
@@ -849,16 +820,6 @@ const FirstMilePage = () => {
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>; return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
}, },
}, },
{
id: "assignment",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
</Badge>
),
},
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",

View File

@@ -3,18 +3,21 @@ import {
ArrowRight, ArrowRight,
Eye, Eye,
MoreHorizontal, MoreHorizontal,
Plus,
Printer, Printer,
Receipt, Receipt,
RefreshCw, RefreshCw,
Ruler, Ruler,
Trash, Trash,
Truck, Truck,
X,
} from "lucide-react"; } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 {
ActionIcon, ActionIcon,
Alert,
Badge, Badge,
Box, Box,
Button, Button,
@@ -24,6 +27,7 @@ import {
Group, Group,
Menu, Menu,
Modal, Modal,
MultiSelect,
NumberInput, NumberInput,
ScrollArea, ScrollArea,
Select, Select,
@@ -92,6 +96,32 @@ const vehicleLabel = (record: LastMileRecord) => {
return parts.join(" · "); return parts.join(" · ");
}; };
/** One vehicle (with trailer) carries two containers. */
const CONTAINERS_PER_VEHICLE = 2;
const containerCount = (record: LastMileRecord) =>
(record.booking?.bookingContainers ?? []).reduce(
(sum, c) => sum + (Number(c.quantity) || 0),
0,
);
/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */
const requiredVehicles = (record: LastMileRecord) => {
const n = containerCount(record);
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
};
/** Column summary: the primary vehicle, plus a "+N more" when multi-truck. */
const vehiclesSummary = (record: LastMileRecord) => {
const assigns = record.vehicleAssignments ?? [];
if (assigns.length > 1) {
const first = assigns[0]?.vehicle;
const firstLabel = first
? [first.code, first.plateNumber].filter(Boolean).join(" · ")
: "Vehicle";
return `${firstLabel} +${assigns.length - 1} more`;
}
return vehicleLabel(record);
};
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const fmtStamp = (iso?: string | null) => { const fmtStamp = (iso?: string | null) => {
@@ -422,13 +452,14 @@ const LastMilePage = () => {
const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null); const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null); // Multi-vehicle assign: one entry per selected vehicle (null = empty picker).
const [vehicleValues, setVehicleValues] = useState<(string | null)[]>([null]);
// 2-step "Assign Mile" accept modal (arrival queue → vehicle) // 2-step "Assign Mile" accept modal (arrival queue → vehicle)
const [acceptOpen, setAcceptOpen] = useState(false); const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1); const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
const [selectedArrivalItems, setSelectedArrivalItems] = useState<ArrivalQueueItem[]>([]); const [selectedArrivalItems, setSelectedArrivalItems] = useState<ArrivalQueueItem[]>([]);
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null); const [acceptVehicleValues, setAcceptVehicleValues] = useState<string[]>([]);
const [arrivalSearch, setArrivalSearch] = useState(""); const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false); const [distanceOpen, setDistanceOpen] = useState(false);
@@ -516,6 +547,18 @@ const LastMilePage = () => {
}, },
}); });
const setVehiclesMutation = useMutation({
mutationFn: ({ id, vehicleIds }: { id: string; vehicleIds: string[] }) =>
lastMileService.setVehicles(id, vehicleIds),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
onError: () => {
toast({ title: "Assign failed", variant: "destructive" });
},
});
const updateDistanceMutation = useMutation({ const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
@@ -576,12 +619,12 @@ const LastMilePage = () => {
}, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]);
const acceptMutation = useMutation({ const acceptMutation = useMutation({
mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { mutationFn: async ({ items, vehicleIds }: { items: ArrivalQueueItem[]; vehicleIds: string[] }) => {
const created = await Promise.all( const created = await Promise.all(
items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)),
); );
if (vehicleId) { if (vehicleIds.length) {
await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId }))); await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicleIds)));
} }
return created; return created;
}, },
@@ -603,7 +646,7 @@ const LastMilePage = () => {
setAcceptOpen(true); setAcceptOpen(true);
setAcceptStep(1); setAcceptStep(1);
setSelectedArrivalItems([]); setSelectedArrivalItems([]);
setAcceptVehicleValue(null); setAcceptVehicleValues([]);
setArrivalSearch(""); setArrivalSearch("");
}; };
@@ -611,7 +654,7 @@ const LastMilePage = () => {
setAcceptOpen(false); setAcceptOpen(false);
setAcceptStep(1); setAcceptStep(1);
setSelectedArrivalItems([]); setSelectedArrivalItems([]);
setAcceptVehicleValue(null); setAcceptVehicleValues([]);
setArrivalSearch(""); setArrivalSearch("");
}; };
@@ -625,7 +668,7 @@ const LastMilePage = () => {
const handleAcceptConfirm = () => { const handleAcceptConfirm = () => {
if (!selectedArrivalItems.length) return; if (!selectedArrivalItems.length) return;
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); acceptMutation.mutate({ items: selectedArrivalItems, vehicleIds: acceptVehicleValues });
}; };
const openDistance = (id: string) => { const openDistance = (id: string) => {
@@ -688,6 +731,27 @@ const LastMilePage = () => {
[records, activeId], [records, activeId],
); );
// Vehicle picker options for the assign modal = free vehicles PLUS the ones
// already on this record (which are BUSY, so absent from the free list) so a
// reassign shows its current trucks selected instead of blank.
const assignVehicleOptions = useMemo(() => {
const opts = [...vehicleOptions];
const seen = new Set(opts.map((o) => o.value));
const current = [
...(activeRecord?.vehicleAssignments?.map((a) => a.vehicle) ?? []),
activeRecord?.vehicle,
];
for (const v of current) {
if (v && !seen.has(v.id)) {
seen.add(v.id);
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
opts.push({ value: v.id, label: parts.join(" · ") });
}
}
return opts;
}, [vehicleOptions, activeRecord]);
const pickupReadyByBooking = useMemo(() => { const pickupReadyByBooking = useMemo(() => {
const map = new Map<string, ImportUnloadedItem>(); const map = new Map<string, ImportUnloadedItem>();
for (const row of pickupReadyRows) { for (const row of pickupReadyRows) {
@@ -751,16 +815,22 @@ const LastMilePage = () => {
const openAssign = (id: string | null) => { const openAssign = (id: string | null) => {
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
const rec = records.find((r) => r.id === resolved);
const existing = rec?.vehicleAssignments?.length
? rec.vehicleAssignments.map((a) => a.vehicleId)
: rec?.vehicleId
? [rec.vehicleId]
: [];
setBulkMode(false); setBulkMode(false);
setActiveId(resolved); setActiveId(resolved);
setVehicleValue(null); setVehicleValues(existing.length ? existing : [null]);
setAssignOpen(true); setAssignOpen(true);
}; };
const openBulkAssign = () => { const openBulkAssign = () => {
setBulkMode(true); setBulkMode(true);
setActiveId(null); setActiveId(null);
setVehicleValue(null); setVehicleValues([null]);
setAssignOpen(true); setAssignOpen(true);
}; };
@@ -768,28 +838,26 @@ const LastMilePage = () => {
setAssignOpen(false); setAssignOpen(false);
setBulkMode(false); setBulkMode(false);
setActiveId(null); setActiveId(null);
setVehicleValue(null); setVehicleValues([null]);
}; };
const handleAssign = () => { const handleAssign = () => {
if (!vehicleValue) { const ids = [...new Set(vehicleValues.filter((v): v is string => Boolean(v)))];
toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
return;
}
const targetIds = bulkMode const targetIds = bulkMode
? selectedIds ? selectedIds
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.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; if (!targetIds.length) return;
if (!ids.length) {
toast({ title: "Select a vehicle", description: "Choose at least one vehicle to assign.", variant: "destructive" });
return;
}
const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids })))
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
.then(() => { .then(() => {
toast({ toast({
title: "Vehicle assigned", title: ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned",
description: bulkMode ? `${targetIds.length} deliveries ${selectedLabel}` : selectedLabel, description: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`,
}); });
if (bulkMode) setRowSelection({}); if (bulkMode) setRowSelection({});
closeAssign(); closeAssign();
@@ -893,30 +961,6 @@ const LastMilePage = () => {
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => customerName(row.original), cell: ({ row }) => customerName(row.original),
}, },
{
id: "pickup",
header: "Pickup",
meta: { headerClassName, cellClassName },
cell: ({ row }) => originYardName(row.original),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => deliveryLocation(row.original),
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => cargoDesc(row.original),
},
{
id: "advancedPayment",
header: "Advanced Payment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.advancedPayment),
},
{ {
id: "postPayment", id: "postPayment",
header: "Post Payment", header: "Post Payment",
@@ -927,13 +971,8 @@ const LastMilePage = () => {
id: "vehicle", id: "vehicle",
header: "Vehicle", header: "Vehicle",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed"></Text>, cell: ({ row }) =>
}, vehiclesSummary(row.original) ?? <Text c="dimmed">Unassigned</Text>,
{
id: "estimatedKm",
header: "Est. Distance (KM)",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed"></Text>,
}, },
{ {
id: "exactKm", id: "exactKm",
@@ -987,16 +1026,6 @@ const LastMilePage = () => {
return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>; return <Badge color={meta.color} variant="light" size="sm">{meta.label}</Badge>;
}, },
}, },
{
id: "assignment",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={isAssigned(row.original) ? "green" : "orange"} variant="light" size="sm">
{isAssigned(row.original) ? "Assigned" : "Unassigned"}
</Badge>
),
},
{ {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
@@ -1315,19 +1344,26 @@ const LastMilePage = () => {
</Stack> </Stack>
</Card> </Card>
<Divider /> <Divider />
<Select <MultiSelect
label="Assign Vehicle (optional)" label="Assign Vehicles (optional)"
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"} placeholder={
vehicleOptions.length === 0
? "No free vehicles"
: acceptVehicleValues.length === 0
? "Add vehicles"
: undefined
}
description={ description={
vehicleOptions.length === 0 vehicleOptions.length === 0
? "No free vehicles available — you can still accept and assign a vehicle later." ? "No free vehicles available — you can still accept and assign vehicles later."
: undefined : "Pick one or more trucks for this delivery."
} }
data={vehicleOptions} data={vehicleOptions}
value={acceptVehicleValue} value={acceptVehicleValues}
onChange={setAcceptVehicleValue} onChange={setAcceptVehicleValues}
searchable searchable
clearable clearable
hidePickedOptions
disabled={vehicleOptions.length === 0} disabled={vehicleOptions.length === 0}
/> />
<Group justify="space-between" gap="sm"> <Group justify="space-between" gap="sm">
@@ -1368,29 +1404,82 @@ const LastMilePage = () => {
) : ( ) : (
<Text size="sm" c="dimmed">No unassigned deliveries available.</Text> <Text size="sm" c="dimmed">No unassigned deliveries available.</Text>
)} )}
{!bulkMode && activeRecord && requiredVehicles(activeRecord) > 0 && (() => {
const containers = containerCount(activeRecord);
const needed = requiredVehicles(activeRecord);
const picked = vehicleValues.filter(Boolean).length;
const ok = picked === needed;
return (
<Alert
variant="light"
color={ok ? "green" : "yellow"}
title={`${containers} container${containers === 1 ? "" : "s"} · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
>
One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers.
{picked > 0 && !ok &&
` You've selected ${picked}${picked < needed ? "add more" : "that's more than needed"}.`}
</Alert>
);
})()}
<Divider /> <Divider />
<Select <Stack gap="xs">
label="Vehicle" {vehicleValues.map((val, i) => (
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"} <Group key={i} gap="xs" wrap="nowrap" align="flex-end">
description={ <Select
vehicleOptions.length === 0 style={{ flex: 1 }}
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet Vehicles first." label={i === 0 ? "Vehicles" : undefined}
: undefined placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
} description={
data={vehicleOptions} i === 0 && assignVehicleOptions.length === 0
value={vehicleValue} ? "No free vehicles available — free up a vehicle in Fleet Vehicles first."
onChange={setVehicleValue} : undefined
searchable }
disabled={vehicleOptions.length === 0} data={assignVehicleOptions.filter(
/> (o) => o.value === val || !vehicleValues.includes(o.value),
)}
value={val}
onChange={(v) =>
setVehicleValues((prev) => prev.map((x, idx) => (idx === i ? v : x)))
}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
/>
{vehicleValues.length > 1 && (
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove vehicle"
onClick={() => setVehicleValues((prev) => prev.filter((_, idx) => idx !== i))}
>
<X size={16} />
</ActionIcon>
)}
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => setVehicleValues((prev) => [...prev, null])}
disabled={
assignVehicleOptions.length === 0 ||
vehicleValues.some((v) => !v) ||
vehicleValues.filter(Boolean).length >= assignVehicleOptions.length
}
style={{ alignSelf: "flex-start" }}
>
Add vehicle
</Button>
</Stack>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button> <Button variant="default" onClick={closeAssign}>Cancel</Button>
<Button <Button
onClick={handleAssign} onClick={handleAssign}
loading={updateMutation.isPending} loading={setVehiclesMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord} disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
> >
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"} {!bulkMode && activeRecord && isAssigned(activeRecord) ? "Update vehicles" : "Assign"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -22,6 +22,8 @@ export interface LastMileBooking {
originYard?: { id: string; name?: string; label?: string } | null; originYard?: { id: string; name?: string; label?: string } | null;
destinationYard?: { id: string; name?: string; label?: string } | null; destinationYard?: { id: string; name?: string; label?: string } | null;
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null; cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{ id: string; quantity: number }>;
} }
export interface LastMileVehicle { export interface LastMileVehicle {
@@ -48,6 +50,8 @@ export interface LastMileRecord {
vehicleId?: string | null; vehicleId?: string | null;
booking?: LastMileBooking | null; booking?: LastMileBooking | null;
vehicle?: LastMileVehicle | null; vehicle?: LastMileVehicle | null;
/** Full set of vehicles serving this delivery (multi-truck). */
vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -69,4 +73,6 @@ export const lastMileService = {
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))), api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
remove: (id: string) => remove: (id: string) =>
api.delete<void>(LM.BY_ID(id)), api.delete<void>(LM.BY_ID(id)),
setVehicles: (id: string, vehicleIds: string[]) =>
api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicleIds }),
}; };