mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge branch 'dev' into alpha
This commit is contained in:
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal file
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
|
||||
@ApiTags('warehouse-inventory')
|
||||
@ApiBearerAuth()
|
||||
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
|
||||
constructor(
|
||||
private readonly inventoryService: WarehouseInventoryService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
private readonly handoverService: HandoverService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -312,6 +314,12 @@ export class WarehouseInventoryController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
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). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
@@ -342,6 +343,7 @@ export class WarehouseInventoryService {
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -2080,9 +2082,14 @@ export class WarehouseInventoryService {
|
||||
[item.bookingId],
|
||||
);
|
||||
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(
|
||||
'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`,
|
||||
[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(
|
||||
{
|
||||
@@ -2388,7 +2405,9 @@ export class WarehouseInventoryService {
|
||||
|
||||
return {
|
||||
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 {
|
||||
bookingId,
|
||||
inventoryId: item.id,
|
||||
@@ -2589,7 +2612,9 @@ export class WarehouseInventoryService {
|
||||
|
||||
return {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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 deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
||||
const weight = Number(item.weight) || 0;
|
||||
@@ -2645,6 +2693,27 @@ export class WarehouseInventoryService {
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.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 { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInspectionReport,
|
||||
WarehouseAllocationRule,
|
||||
WarehouseFeeRule,
|
||||
BookingHandover,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseSchedulingAdapterService,
|
||||
WarehouseReleaseDocumentService,
|
||||
SchedulingReadFacade,
|
||||
HandoverService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
@@ -2421,7 +2421,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Dispatch
|
||||
Truck_dispatch
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
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 { useToast } from '@/hooks/use-toast';
|
||||
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
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 [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
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 isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
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)}
|
||||
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
|
||||
label="Registered first / last-mile truck"
|
||||
placeholder="Select truck or type plate manually below"
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
data={truckSelectOptions}
|
||||
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) => {
|
||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
}}
|
||||
/>
|
||||
<Group grow>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -67,6 +69,12 @@ const cleanParams = (params: object) =>
|
||||
);
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
list: (filter?: WarehouseFilter) =>
|
||||
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
||||
|
||||
Reference in New Issue
Block a user