mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
Merge pull request #464 from Tria-plc/CutomerTruckAssign
Cutomer truck assign
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 (1–2)"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 1–2 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user