mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
|
||||||
|
* which containers ride which truck) is distinct from the container actually
|
||||||
|
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
|
||||||
|
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
|
||||||
|
* shown. Backfills containers on already-departed trucks (they left loaded).
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckContainerLoadedAt2050000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers
|
||||||
|
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.customer_truck_containers ctc
|
||||||
|
SET loaded_at = a.departed_at
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
WHERE a.id = ctc.assignment_id
|
||||||
|
AND a.departed_at IS NOT NULL
|
||||||
|
AND ctc.deleted_at IS NULL
|
||||||
|
AND ctc.loaded_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1424,12 +1424,14 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
schedule?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A generated-but-unsigned handover means the customer must approve delivery.
|
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||||
// Surfaced so the portal shows "Approve delivery" as soon as the handover
|
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||||
// exists, independent of the truck-arrival flag.
|
// handovers are per delivering truck and signed by the receiver at the door,
|
||||||
|
// so they never surface the portal "Approve delivery" action.
|
||||||
const [pendingHandover] = await this.dataSource.query(
|
const [pendingHandover] = await this.dataSource.query(
|
||||||
`SELECT 1 FROM freight.booking_handovers
|
`SELECT 1 FROM freight.booking_handovers
|
||||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
|
AND mile_type = 'SELF_HAUL'
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -312,6 +312,13 @@ export class CustomerTruckService {
|
|||||||
if (assignment.departedAt) {
|
if (assignment.departedAt) {
|
||||||
throw new ConflictException('This truck has already left — its load is locked');
|
throw new ConflictException('This truck has already left — its load is locked');
|
||||||
}
|
}
|
||||||
|
// Containers can only be loaded after the truck has physically arrived at the
|
||||||
|
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||||
|
if (!assignment.arrivedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
@@ -333,12 +340,16 @@ export class CustomerTruckService {
|
|||||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
// Operator loading the truck: stamp loaded_at so these containers move to
|
||||||
|
// the LOADED stage (customer assignment alone leaves loaded_at null).
|
||||||
|
const loadedAt = new Date();
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
requested.map((containerNumber) =>
|
requested.map((containerNumber) =>
|
||||||
manager.getRepository(CustomerTruckContainer).create({
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
loadedAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the container was actually loaded onto the truck by the operator.
|
||||||
|
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
|
||||||
|
* this to be set, so customer assignment alone does not mark a container loaded.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
loadedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/** One physical container under a booking line — entered at booking time. */
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
export class CreateContainerUnitDto {
|
export class CreateContainerUnitDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -361,6 +361,16 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.requestSignature(bookingId);
|
return this.handoverService.requestSignature(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/handover-document')
|
||||||
|
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||||
|
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/container-items')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
|||||||
@@ -2603,12 +2603,13 @@ export class WarehouseInventoryService {
|
|||||||
Array<{
|
Array<{
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
goods: string | null;
|
goods: string | null;
|
||||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
grnNumber: string | null;
|
grnNumber: string | null;
|
||||||
truckAssignmentId: string | null;
|
truckAssignmentId: string | null;
|
||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2624,6 +2625,7 @@ export class WarehouseInventoryService {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2637,6 +2639,7 @@ export class WarehouseInventoryService {
|
|||||||
a.plate_number AS "truckPlate",
|
a.plate_number AS "truckPlate",
|
||||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||||
|
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.contract_id AS "contractId",
|
b.contract_id AS "contractId",
|
||||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||||
@@ -2666,22 +2669,28 @@ export class WarehouseInventoryService {
|
|||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
goods: r.goods,
|
goods: r.goods,
|
||||||
|
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||||||
|
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||||||
|
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||||||
stage: r.delivered
|
stage: r.delivered
|
||||||
? 'DELIVERED'
|
? 'DELIVERED'
|
||||||
: r.truckLeft
|
: r.truckLeft
|
||||||
? 'LEFT'
|
? 'LEFT'
|
||||||
: r.truckAssignmentId
|
: r.loaded
|
||||||
? 'LOADED'
|
? 'LOADED'
|
||||||
: r.grnNumber
|
: r.truckAssignmentId
|
||||||
? 'GRN'
|
? 'ASSIGNED'
|
||||||
: r.received
|
: r.grnNumber
|
||||||
? 'RECEIVED'
|
? 'GRN'
|
||||||
: 'PENDING',
|
: r.received
|
||||||
|
? 'RECEIVED'
|
||||||
|
: 'PENDING',
|
||||||
grnNumber: r.grnNumber,
|
grnNumber: r.grnNumber,
|
||||||
truckAssignmentId: r.truckAssignmentId,
|
truckAssignmentId: r.truckAssignmentId,
|
||||||
truckPlate: r.truckPlate,
|
truckPlate: r.truckPlate,
|
||||||
truckArrived: r.truckArrived,
|
truckArrived: r.truckArrived,
|
||||||
truckLeft: r.truckLeft,
|
truckLeft: r.truckLeft,
|
||||||
|
loaded: r.loaded,
|
||||||
bookingReference: r.bookingReference,
|
bookingReference: r.bookingReference,
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
hasLastMile: r.hasLastMile,
|
hasLastMile: r.hasLastMile,
|
||||||
@@ -3016,6 +3025,21 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||||
|
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!inv) {
|
||||||
|
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||||
|
}
|
||||||
|
return this.handoverDocument(inv.id);
|
||||||
|
}
|
||||||
|
|
||||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
@@ -3242,7 +3266,22 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||||
|
// vehicle that carried this item's container so each truck gets its own
|
||||||
|
// handover (falls back to a booking-level one when unresolvable).
|
||||||
|
let truckPlate: string | null = null;
|
||||||
|
if (item.containerId) {
|
||||||
|
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||||
|
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||||
|
FROM freight.last_mile_container_allocations lca
|
||||||
|
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||||
|
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[item.containerId],
|
||||||
|
);
|
||||||
|
truckPlate = veh?.plate ?? null;
|
||||||
|
}
|
||||||
|
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'ALL', label: 'All' },
|
{ value: 'ALL', label: 'All' },
|
||||||
{ value: 'RECEIVED', label: 'Received' },
|
{ value: 'RECEIVED', label: 'Received' },
|
||||||
{ value: 'GRN', label: "GRN'd" },
|
{ value: 'GRN', label: "GRN'd" },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
{ value: 'LOADED', label: 'Loaded' },
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
{ value: 'LEFT', label: 'Left' },
|
{ value: 'LEFT', label: 'Left' },
|
||||||
{ value: 'DELIVERED', label: 'Delivered' },
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
@@ -46,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
|||||||
PENDING: 'gray',
|
PENDING: 'gray',
|
||||||
RECEIVED: 'blue',
|
RECEIVED: 'blue',
|
||||||
GRN: 'teal',
|
GRN: 'teal',
|
||||||
|
ASSIGNED: 'indigo',
|
||||||
LOADED: 'grape',
|
LOADED: 'grape',
|
||||||
LEFT: 'orange',
|
LEFT: 'orange',
|
||||||
DELIVERED: 'green',
|
DELIVERED: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Loadable = not yet on a truck (before LOADED). */
|
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
const isLoadable = (i: ContainerItem) =>
|
||||||
|
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||||
|
|
||||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||||
[items, tab],
|
[items, tab],
|
||||||
);
|
);
|
||||||
|
// Only arrived, not-yet-departed trucks can be loaded.
|
||||||
const truckOptions = trucks
|
const truckOptions = trucks
|
||||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||||
|
!(t as { departedAt?: string }).departedAt,
|
||||||
|
)
|
||||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||||
|
|
||||||
const loadMutation = useMutation({
|
const loadMutation = useMutation({
|
||||||
@@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
{i.truckAssignmentId && (
|
{i.loaded && i.truckAssignmentId && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||||
disabled={i.handoverSigned}
|
disabled={i.handoverSigned}
|
||||||
|
|||||||
@@ -347,8 +347,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
placeholder="Select the assigned truck"
|
placeholder="Select the assigned truck"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
|
// Enabled at arrival so the operator picks which assigned truck came;
|
||||||
|
// only locked on the exit (leaving) step once identity is captured.
|
||||||
|
disabled={isEntranceLocked}
|
||||||
data={truckSelectOptions}
|
data={truckSelectOptions}
|
||||||
disabled={isTruckIdentityLocked}
|
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import type {
|
|||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
|
|
||||||
export interface ContainerItem {
|
export interface ContainerItem {
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -75,6 +75,8 @@ export interface ContainerItem {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Button, type ButtonProps } from "@mantine/core";
|
import { Button, type ButtonProps } from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import type { MouseEvent } from "react";
|
import { type MouseEvent, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
|
||||||
|
|
||||||
type ApproveDeliveryButtonProps = ButtonProps & {
|
type ApproveDeliveryButtonProps = ButtonProps & {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
|
|||||||
onApproved?: () => void;
|
onApproved?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorMessage = (error: unknown) => {
|
|
||||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
|
||||||
?.response?.data;
|
|
||||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
|
||||||
if (data?.message) return data.message;
|
|
||||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob, filename: string) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ApproveDeliveryButton({
|
export function ApproveDeliveryButton({
|
||||||
bookingId,
|
bookingId,
|
||||||
stopPropagation,
|
stopPropagation,
|
||||||
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
|
|||||||
variant = "filled",
|
variant = "filled",
|
||||||
...props
|
...props
|
||||||
}: ApproveDeliveryButtonProps) {
|
}: ApproveDeliveryButtonProps) {
|
||||||
const navigate = useNavigate();
|
const [opened, setOpened] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
...api.bookings.approveDelivery.mutationOptions(),
|
|
||||||
onSuccess: async (result) => {
|
|
||||||
try {
|
|
||||||
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
|
|
||||||
downloadBlob(blob, `handover-${bookingId}.pdf`);
|
|
||||||
toast.success("Delivery approved and signed handover downloaded");
|
|
||||||
} catch {
|
|
||||||
toast.success("Delivery approved and handover signed");
|
|
||||||
toast.error("Signed handover document could not be downloaded");
|
|
||||||
}
|
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
|
||||||
]);
|
|
||||||
onApproved?.();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const message = errorMessage(error);
|
|
||||||
toast.error(message);
|
|
||||||
if (message.toLowerCase().includes("save your signature")) {
|
|
||||||
navigate("/signature");
|
|
||||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
|
||||||
navigate("/billing");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
if (stopPropagation) event.stopPropagation();
|
if (stopPropagation) event.stopPropagation();
|
||||||
mutation.mutate({ id: bookingId });
|
setOpened(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
{...props}
|
<Button
|
||||||
size={size}
|
{...props}
|
||||||
variant={variant}
|
size={size}
|
||||||
color="edr-green"
|
variant={variant}
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
color="edr-green"
|
||||||
loading={mutation.isPending || handoverMutation.isPending}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
Approve delivery
|
Approve delivery
|
||||||
</Button>
|
</Button>
|
||||||
|
<ApproveDeliveryModal
|
||||||
|
bookingId={bookingId}
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => setOpened(false)}
|
||||||
|
onApproved={onApproved}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, Info } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
|
type ApproveDeliveryModalProps = {
|
||||||
|
bookingId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApproved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve-delivery flow: open the handover document for the customer to review,
|
||||||
|
* then apply their saved signature (approve) and hand back the signed PDF.
|
||||||
|
*/
|
||||||
|
export function ApproveDeliveryModal({
|
||||||
|
bookingId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onApproved,
|
||||||
|
}: ApproveDeliveryModalProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: docBlob,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["booking-handover-doc", bookingId],
|
||||||
|
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!docBlob) {
|
||||||
|
setPdfUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(docBlob);
|
||||||
|
setPdfUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [docBlob]);
|
||||||
|
|
||||||
|
const handoverMutation = useMutation(
|
||||||
|
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const approve = useMutation({
|
||||||
|
...api.bookings.approveDelivery.mutationOptions(),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
try {
|
||||||
|
const signed = await handoverMutation.mutateAsync({
|
||||||
|
inventoryId: result.inventoryId,
|
||||||
|
});
|
||||||
|
downloadBlob(signed, `handover-${bookingId}.pdf`);
|
||||||
|
toast.success("Delivery approved and signed handover downloaded");
|
||||||
|
} catch {
|
||||||
|
toast.success("Delivery approved and handover signed");
|
||||||
|
toast.error("Signed handover document could not be downloaded");
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||||
|
]);
|
||||||
|
onApproved?.();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
toast.error(message);
|
||||||
|
if (message.toLowerCase().includes("save your signature")) {
|
||||||
|
onClose();
|
||||||
|
navigate("/signature");
|
||||||
|
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||||
|
onClose();
|
||||||
|
navigate("/billing");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = approve.isPending || handoverMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Approve delivery — review & sign the handover"
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||||
|
<Text size="sm">
|
||||||
|
Review the handover document below. Approving applies your saved signature
|
||||||
|
and confirms you received the goods.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading handover document…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : isError || !pdfUrl ? (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Could not load the handover document. It may not be generated yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
title="Handover document"
|
||||||
|
src={pdfUrl}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "60vh",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={isLoading || isError}
|
||||||
|
onClick={() => approve.mutate({ id: bookingId })}
|
||||||
|
>
|
||||||
|
Approve & sign delivery
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1151,6 +1151,8 @@ function ContainerLineEditor({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())}
|
||||||
|
maxLength={11}
|
||||||
label={u === 0 ? "Container number *" : undefined}
|
label={u === 0 ? "Container number *" : undefined}
|
||||||
placeholder="e.g. MSCU1234567"
|
placeholder="e.g. MSCU1234567"
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
|
|||||||
@@ -26,8 +26,20 @@ export interface ShipmentValidationContext {
|
|||||||
requiresDate?: boolean;
|
requiresDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISO 6346: 4 letters (owner + category) + 6 serial digits + 1 check digit.
|
||||||
|
// Enforced at booking input so the number stays a clean reference in the warehouse.
|
||||||
|
const ISO_CONTAINER_RE = /^[A-Z]{4}\d{7}$/;
|
||||||
|
|
||||||
const containerUnitSchema = z.object({
|
const containerUnitSchema = z.object({
|
||||||
containerNumber: z.string().min(1, "Container number is required."),
|
containerNumber: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => v.trim().toUpperCase())
|
||||||
|
.pipe(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.min(1, "Container number is required.")
|
||||||
|
.regex(ISO_CONTAINER_RE, "Use ISO format: 4 letters + 7 digits, e.g. ABCU1234567."),
|
||||||
|
),
|
||||||
sealNumber: z.string().default(""),
|
sealNumber: z.string().default(""),
|
||||||
vgmTons: z
|
vgmTons: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -264,6 +264,13 @@ export const api = {
|
|||||||
bookingsService.downloadHandoverDocument(inventoryId),
|
bookingsService.downloadHandoverDocument(inventoryId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
|
||||||
|
"bookings",
|
||||||
|
"downloadBookingHandoverDocument",
|
||||||
|
({ bookingId }) =>
|
||||||
|
bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||||
|
),
|
||||||
|
|
||||||
create: endpoint<
|
create: endpoint<
|
||||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||||
Freight.IBooking
|
Freight.IBooking
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
|
||||||
|
const { data } = await client.get(
|
||||||
|
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||||
return data.data;
|
return data.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user