Merge pull request #464 from Tria-plc/CutomerTruckAssign

Cutomer truck assign
This commit is contained in:
Hagernesh Tadesse
2026-07-04 12:57:51 +03:00
committed by GitHub
21 changed files with 1419 additions and 119 deletions

View File

@@ -107,6 +107,9 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},
CONTRACTS: {

View File

@@ -1,15 +1,30 @@
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Divider,
Group,
Loader,
MultiSelect,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { Download, Lock, Truck } from "lucide-react";
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { customerTrucksService } from "@/services/customer-trucks.service";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
URL.revokeObjectURL(url);
};
const errorMessage = (error: unknown, fallback: string) => {
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 : fallback;
};
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
@@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({
booking: Freight.IBooking;
onAssigned: () => void;
}) {
const assigned = Boolean(booking.customerTruckAssignedAt);
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
booking.customerTruckContainerNumber ?? "",
);
const queryClient = useQueryClient();
const trucksKey = ["customer-trucks", booking.id];
const { data: trucks = [], isLoading } = useQuery({
queryKey: trucksKey,
queryFn: () => customerTrucksService.list(booking.id),
});
const [plateNumber, setPlateNumber] = useState("");
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// Physical container numbers on this booking — the customer picks which one to
// load onto the truck instead of typing it. Falls back to free entry when the
// booking has no container numbers recorded.
const containerOptions = booking.containerNumbers ?? [];
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
);
const availableContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n),
);
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
// staff register + weigh what was loaded when the truck leaves.
const isExport = booking.tradeDirection === "EXPORT";
const submit = async () => {
const payload = {
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
};
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
setError("All truck assignment fields are required.");
return;
}
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
setError("Container number must match ISO format, e.g. ABCD1234567.");
return;
}
const resetForm = () => {
setPlateNumber("");
setDriverName("");
setTruckType("");
setContainers([]);
setError(null);
await assignMutation.mutateAsync({ id: booking.id, payload });
onAssigned();
};
const addMutation = useMutation({
mutationFn: () =>
customerTrucksService.add(booking.id, {
truckPlateNumber: plateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
// Import: containers are registered + weighed on departure, not here.
containerNumbers: isExport ? containers : [],
}),
onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list);
resetForm();
onAssigned();
toast.success("Truck added");
},
onError: (e) => setError(errorMessage(e, "Could not add truck")),
});
const removeMutation = useMutation({
mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId),
onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list);
onAssigned();
},
onError: (e) => toast.error(errorMessage(e, "Could not remove truck")),
});
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
const downloadFreightOrder = async () => {
const blob = await downloadMutation.mutateAsync({ id: booking.id });
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
};
const submitAdd = () => {
if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) {
setError("Plate number, driver name and truck type are required.");
return;
}
if (isExport && (containers.length < 1 || containers.length > 2)) {
setError("Select 1 or 2 container numbers for this truck.");
return;
}
setError(null);
addMutation.mutate();
};
return (
<SectionCard>
<Stack gap="md">
@@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({
<Truck size={18} color="#0a9f6a" />
<CardTitle>External Truck Assignment</CardTitle>
</Group>
{assigned && (
<Group gap={6} c="#0a9f6a">
<Lock size={14} />
<Text size="sm" fw={700}>
Truck Assigned
</Text>
</Group>
{trucks.length > 0 && (
<Text size="sm" fw={700} c="#0a9f6a">
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
</Text>
)}
</Group>
{/* Assigned trucks */}
{isLoading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
trucks.map((t) => (
<Group
key={t.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
>
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={700} c="#10202F">
{t.plateNumber}
</Text>
{t.arrivedAt ? (
<Badge color="green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Arrived
</Badge>
) : (
<Badge color="orange" variant="light" leftSection={<Clock size={12} />}>
Awaiting arrival
</Badge>
)}
</Group>
<Text fz="12.5px" c="#6B7C8E">
{t.driverName} · {t.truckType}
</Text>
<Group gap={6}>
{(t.containers ?? []).map((c) => (
<Badge key={c.id} variant="outline" color="gray">
{c.containerNumber}
</Badge>
))}
</Group>
</Stack>
{!t.arrivedAt && (
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove truck"
onClick={() => removeMutation.mutate(t.id)}
loading={removeMutation.isPending}
>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
))
)}
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
{assignMutation.isError && (
<Alert color="red" variant="light">
{assignMutation.error instanceof Error
? assignMutation.error.message
: "Truck assignment failed."}
</Alert>
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
{(isExport ? availableContainers.length > 0 : true) ? (
<>
<Divider label="Add a truck" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
label="Truck Plate Number"
required
value={plateNumber}
onChange={(e) => setPlateNumber(e.currentTarget.value.toUpperCase())}
/>
<TextInput
label="Driver Name"
required
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
/>
<Select
label="Truck Type"
required
data={TRUCK_TYPES}
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
/>
{isExport && (
<MultiSelect
label="Containers to load (12)"
required
placeholder="Select container numbers"
data={availableContainers}
value={containers}
onChange={setContainers}
maxValues={2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
</SimpleGrid>
<Group justify="flex-end">
<Button
leftSection={<Plus size={16} />}
color="edr-green"
onClick={submitAdd}
loading={addMutation.isPending}
>
Add truck
</Button>
</Group>
</>
) : (
trucks.length > 0 && (
<Text fz="12.5px" c="#9AA8B5">
All containers on this booking have been assigned to a truck.
</Text>
)
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
label="Truck Plate Number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={assigned}
/>
<TextInput
label="Driver Name"
required
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
readOnly={assigned}
/>
<Select
label="Truck Type"
required
data={TRUCK_TYPES}
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
disabled={assigned}
/>
{containerOptions.length > 0 ? (
<Select
label="Container Number to Load"
required
placeholder="Select a container from this booking"
data={containerOptions}
value={containerNumberToLoad || null}
onChange={(value) => setContainerNumberToLoad(value ?? "")}
searchable
disabled={assigned}
nothingFoundMessage="No matching container"
/>
) : (
<TextInput
label="Container Number to Load"
required
value={containerNumberToLoad}
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
readOnly={assigned}
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{assigned ? (
{trucks.length > 0 && (
<Group justify="flex-end">
<Button
variant="light"
leftSection={<Download size={16} />}
color="edr-green"
onClick={downloadFreightOrder}
@@ -158,12 +275,8 @@ export function CustomerTruckAssignmentCard({
>
Generate Freight Order Copies
</Button>
) : (
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
Verify & Submit Assignment
</Button>
)}
</Group>
</Group>
)}
</Stack>
</SectionCard>
);

View File

@@ -0,0 +1,33 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
/**
* Multi-truck self-haul assignment for a booking (no EDR first/last mile).
* Each truck carries 12 of the booking's containers and tracks its own arrival.
*/
export const customerTrucksService = {
list: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId));
return data.data ?? data;
},
add: async (
bookingId: string,
payload: Freight.AddCustomerTruckPayload,
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload);
return data.data ?? data;
},
remove: async (
bookingId: string,
assignmentId: string,
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId));
return data.data ?? data;
},
};