mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
mile
This commit is contained in:
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user