separte handover edr lastmile and customer last mile flow

This commit is contained in:
Hagernesh
2026-07-06 12:27:58 +00:00
parent 8b41e80ecc
commit fe9503638b
9 changed files with 367 additions and 9 deletions

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Structured import handover records. Replaces the ad-hoc handover notes so a
* booking can carry one handover (single truck) or several (one per truck when
* multiple trucks are used). Timing differs by mile type:
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
*/
export class AddBookingHandovers1980000000000 implements MigrationInterface {
name = 'AddBookingHandovers1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
truck_plate varchar(32),
mile_type varchar(20) NOT NULL,
reference varchar(100) NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
signed_at timestamptz,
signed_by_user_id uuid,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
);
// At most one live handover per (booking, customer truck). EDR trucks (which
// aren't customer_truck_assignments) and per-booking handovers are de-duped
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
ON freight.booking_handovers (booking_id, truck_assignment_id)
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
}
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
/**
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
export class BookingHandover extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** Customer self-haul truck this handover belongs to; null = per-booking. */
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
mileType!: HandoverMileType;
@Column({ name: 'reference', type: 'varchar', length: 100 })
reference!: string;
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
generatedAt!: Date;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
}

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(private readonly dataSource: DataSource) {}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}

View File

@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade'; import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseInventoryService } from './warehouse-inventory.service';
import { HandoverService } from './handover.service';
@ApiTags('warehouse-inventory') @ApiTags('warehouse-inventory')
@ApiBearerAuth() @ApiBearerAuth()
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
constructor( constructor(
private readonly inventoryService: WarehouseInventoryService, private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade, private readonly scheduling: SchedulingReadFacade,
private readonly handoverService: HandoverService,
) {} ) {}
@Get() @Get()
@@ -312,6 +314,12 @@ export class WarehouseInventoryController {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
} }
@Get('bookings/:bookingId/handovers')
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post(':id/deliver') @Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */ /** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -342,6 +343,7 @@ export class WarehouseInventoryService {
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService, private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
) {} ) {}
/** /**
@@ -2080,9 +2082,14 @@ export class WarehouseInventoryService {
[item.bookingId], [item.bookingId],
); );
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { // Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
(await this.handover.isFullySigned(item.bookingId)) ||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
if (usesCustomerTruck && !handoverSigned) {
throw new BadRequestException( throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated', 'Customer must sign the handover before the exit paper can be generated',
); );
} }
} }
@@ -2137,6 +2144,16 @@ export class WarehouseInventoryService {
AND deleted_at IS NULL`, AND deleted_at IS NULL`,
[item.bookingId], [item.bookingId],
); );
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
} }
await this.activityLog.record( await this.activityLog.record(
{ {
@@ -2388,7 +2405,9 @@ export class WarehouseInventoryService {
return { return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Generic render — NOT the release-order fallback (would mislabel the GRN
// as a "Gate Clearance / Release Order" when Chromium is unavailable).
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Goods Received Note'),
}; };
} }
@@ -2462,6 +2481,10 @@ export class WarehouseInventoryService {
); );
}); });
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
return { return {
bookingId, bookingId,
inventoryId: item.id, inventoryId: item.id,
@@ -2589,7 +2612,9 @@ export class WarehouseInventoryService {
return { return {
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Generic render — NOT the release-order fallback (would mislabel the
// handover as a "Gate Clearance / Release Order" when Chromium is down).
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Import Goods Handover'),
}; };
} }
@@ -2601,6 +2626,29 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before the goods can be delivered'); throw new BadRequestException('A release order must be issued before the goods can be delivered');
} }
// Self-haul: the customer's own truck delivers — deliver only after the
// handover is signed AND the truck has left the warehouse holding the goods.
if (item.bookingId) {
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "assignedAt"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (sh?.assignedAt) {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
}
}
const receiverName = dto.receiverName.trim(); const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
const weight = Number(item.weight) || 0; const weight = Number(item.weight) || 0;
@@ -2645,6 +2693,27 @@ export class WarehouseInventoryService {
}, },
manager, manager,
); );
// Handover on delivery. EDR last-mile generates its handover HERE (after
// exit, on delivery). Self-haul handovers were generated on arrival —
// stamp them delivered.
if (item.bookingId) {
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
`SELECT customer_truck_assigned_at AS "selfHaul"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (b?.selfHaul) {
await manager.query(
`UPDATE freight.booking_handovers
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
} else {
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
}
}
}); });
return this.findById(id); return this.findById(id);

View File

@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { BookingHandover } from './entities/booking-handover.entity';
import { HandoverService } from './handover.service';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport, WarehouseInspectionReport,
WarehouseAllocationRule, WarehouseAllocationRule,
WarehouseFeeRule, WarehouseFeeRule,
BookingHandover,
]), ]),
BillingModule, BillingModule,
DocumentsModule, DocumentsModule,
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseSchedulingAdapterService, WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService, WarehouseReleaseDocumentService,
SchedulingReadFacade, SchedulingReadFacade,
HandoverService,
], ],
exports: [ exports: [
WarehousesService, WarehousesService,

View File

@@ -2421,7 +2421,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
loading={busyId === r.id} loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
> >
Dispatch Truck_dispatch
</Button> </Button>
)} )}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react'; import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const bookingId = item?.booking?.id;
// Customer self-haul trucks assigned to this booking via the portal.
const { data: customerTrucks = [] } = useQuery({
queryKey: ['release-customer-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState(''); const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isEntranceLocked = isExitStep; const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
? [
{
value: truckPrefill.truckPlateNumber,
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
},
]
: []),
...customerTrucks.map((t) => ({
value: t.plateNumber,
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked} readOnly={isEntranceLocked}
/> />
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select <Select
label="Registered first / last-mile truck" label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below" placeholder="Select truck or type plate manually below"
searchable searchable
clearable clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS} data={truckSelectOptions}
disabled={isTruckIdentityLocked} disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => { onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value); const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? ''); setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? ''); setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}} }}
/> />
<Group grow> <Group grow>

View File

@@ -1,3 +1,5 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http'; import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from '@/constants/URLS';
@@ -67,6 +69,12 @@ const cleanParams = (params: object) =>
); );
export const warehouseService = { export const warehouseService = {
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
getCustomerTrucks: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
const { data } = await apiClient.get(`/api/bookings/${bookingId}/customer-trucks`);
return data?.data ?? data ?? [];
},
// ── Warehouses ────────────────────────────────────────────────────────── // ── Warehouses ──────────────────────────────────────────────────────────
list: (filter?: WarehouseFilter) => list: (filter?: WarehouseFilter) =>
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, { apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {