mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
mile
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
import { IsArray, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LastMileContainerAllocationDto {
|
||||
@IsUUID()
|
||||
containerId!: string;
|
||||
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
}
|
||||
|
||||
export class AllocateLastMileContainersDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LastMileContainerAllocationDto)
|
||||
allocations!: LastMileContainerAllocationDto[];
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class LastMileService {
|
||||
const [data, total] = await this.lastMileRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
},
|
||||
@@ -173,7 +173,7 @@ export class LastMileService {
|
||||
async findById(id: string): Promise<LastMile> {
|
||||
const record = await this.lastMileRepository.findById(id, {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
},
|
||||
|
||||
@@ -22,8 +22,10 @@ export interface LastMileContainerRow {
|
||||
qty: number;
|
||||
}
|
||||
|
||||
/** One vehicle (with trailer) carries at most this many containers. */
|
||||
const CONTAINERS_PER_VEHICLE = 2;
|
||||
|
||||
export interface LastMileContainerAllocationTableProps {
|
||||
lastMileId: string;
|
||||
containers: LastMileContainerRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
@@ -33,7 +35,6 @@ export interface LastMileContainerAllocationTableProps {
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function LastMileContainerAllocationTable({
|
||||
lastMileId,
|
||||
containers,
|
||||
onSave,
|
||||
}: LastMileContainerAllocationTableProps) {
|
||||
@@ -43,7 +44,8 @@ export function LastMileContainerAllocationTable({
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "free"],
|
||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
|
||||
queryFn: () =>
|
||||
vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
@@ -85,13 +87,32 @@ export function LastMileContainerAllocationTable({
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
const allAllocated = allocatedCount === containers.length;
|
||||
|
||||
// Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap.
|
||||
const loadByVehicle = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const c of containers) {
|
||||
const v = allocations[c.id];
|
||||
if (v) map[v] = (map[v] ?? 0) + (c.qty || 1);
|
||||
}
|
||||
return map;
|
||||
}, [allocations, containers]);
|
||||
|
||||
/** Options for a given row: a vehicle is disabled if assigning this container
|
||||
* to it would exceed its 2-container capacity. */
|
||||
const optionsForRow = (row: LastMileContainerRow) =>
|
||||
vehicleOptions.map((o) => {
|
||||
const already = loadByVehicle[o.value] ?? 0;
|
||||
const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0;
|
||||
const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE;
|
||||
return { ...o, disabled: over };
|
||||
});
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p="xl">
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +147,7 @@ export function LastMileContainerAllocationTable({
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
data={optionsForRow(container)}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
@@ -148,7 +169,8 @@ export function LastMileContainerAllocationTable({
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated
|
||||
{allocatedCount} of {containers.length} containers allocated · max{" "}
|
||||
{CONTAINERS_PER_VEHICLE} per vehicle
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
@@ -109,17 +110,37 @@ const requiredVehicles = (record: LastMileRecord) => {
|
||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
||||
};
|
||||
|
||||
/** Column summary: the primary vehicle, plus a "+N more" when multi-truck. */
|
||||
const vehiclesSummary = (record: LastMileRecord) => {
|
||||
const assigns = record.vehicleAssignments ?? [];
|
||||
if (assigns.length > 1) {
|
||||
const first = assigns[0]?.vehicle;
|
||||
const firstLabel = first
|
||||
? [first.code, first.plateNumber].filter(Boolean).join(" · ")
|
||||
: "Vehicle";
|
||||
return `${firstLabel} +${assigns.length - 1} more`;
|
||||
/** Container rows for the per-container→vehicle allocation table. */
|
||||
const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] =>
|
||||
(record.booking?.bookingContainers ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
type:
|
||||
c.containerNumber ??
|
||||
c.containerType?.code ??
|
||||
c.containerType?.label ??
|
||||
c.containerType?.name ??
|
||||
(c.containerSize || "Container"),
|
||||
qty: c.quantity || 1,
|
||||
}));
|
||||
|
||||
/** Container badges for a booking: the container number when known, else the
|
||||
* type × quantity. */
|
||||
const containerLabels = (record: LastMileRecord): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const c of record.booking?.bookingContainers ?? []) {
|
||||
const size = c.containerSize ? ` · ${c.containerSize}` : "";
|
||||
if (c.containerNumber) {
|
||||
out.push(`${c.containerNumber}${size}`);
|
||||
} else {
|
||||
const type =
|
||||
c.containerType?.code ??
|
||||
c.containerType?.label ??
|
||||
c.containerType?.name ??
|
||||
(c.containerSize || "Container");
|
||||
out.push(`${type} × ${c.quantity}`);
|
||||
}
|
||||
}
|
||||
return vehicleLabel(record);
|
||||
return out;
|
||||
};
|
||||
|
||||
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
||||
@@ -587,10 +608,10 @@ const LastMilePage = () => {
|
||||
|
||||
const allocateMutation = useMutation({
|
||||
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
|
||||
api.post(`/last-mile/${activeId}/allocate-containers`, data),
|
||||
api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Containers allocated", variant: "default" });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
closeAllocation();
|
||||
},
|
||||
@@ -848,16 +869,16 @@ const LastMilePage = () => {
|
||||
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
||||
|
||||
if (!targetIds.length) return;
|
||||
if (!ids.length) {
|
||||
toast({ title: "Select a vehicle", description: "Choose at least one vehicle to assign.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids })))
|
||||
.then(() => {
|
||||
toast({
|
||||
title: ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
||||
description: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`,
|
||||
title: ids.length === 0 ? "Vehicles unassigned" : ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
||||
description:
|
||||
ids.length === 0
|
||||
? bulkMode ? `${targetIds.length} deliveries` : undefined
|
||||
: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`,
|
||||
});
|
||||
if (bulkMode) setRowSelection({});
|
||||
closeAssign();
|
||||
@@ -971,8 +992,24 @@ const LastMilePage = () => {
|
||||
id: "vehicle",
|
||||
header: "Vehicle",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) =>
|
||||
vehiclesSummary(row.original) ?? <Text c="dimmed">Unassigned</Text>,
|
||||
cell: ({ row }) => {
|
||||
const assigns = row.original.vehicleAssignments ?? [];
|
||||
if (assigns.length > 1) {
|
||||
const first = assigns[0]?.vehicle;
|
||||
const firstLabel = first
|
||||
? [first.code, first.plateNumber].filter(Boolean).join(" · ")
|
||||
: "Vehicle";
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>{firstLabel}</Text>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
+{assigns.length - 1}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "exactKm",
|
||||
@@ -1089,6 +1126,29 @@ const LastMilePage = () => {
|
||||
>
|
||||
Reassign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<X size={15} />}
|
||||
disabled={!assigned || delivered}
|
||||
onClick={() =>
|
||||
setVehiclesMutation.mutate(
|
||||
{ id: row.original.id, vehicleIds: [] },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Unassign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Boxes size={15} />}
|
||||
disabled={allocationRowsFor(row.original).length === 0 || delivered}
|
||||
onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))}
|
||||
>
|
||||
Allocate to trucks
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!canArrive}
|
||||
@@ -1404,10 +1464,17 @@ const LastMilePage = () => {
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">No unassigned deliveries available.</Text>
|
||||
)}
|
||||
{!bulkMode && activeRecord && requiredVehicles(activeRecord) > 0 && (() => {
|
||||
{!bulkMode && activeRecord && (() => {
|
||||
const containers = containerCount(activeRecord);
|
||||
const needed = requiredVehicles(activeRecord);
|
||||
const picked = vehicleValues.filter(Boolean).length;
|
||||
if (needed === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
||||
No container count on this booking — assign trucks as needed.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const ok = picked === needed;
|
||||
return (
|
||||
<Alert
|
||||
@@ -1421,6 +1488,20 @@ const LastMilePage = () => {
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
{!bulkMode && activeRecord && containerLabels(activeRecord).length > 0 && (
|
||||
<Card withBorder padding="xs" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Text size="xs" fw={600} c="dimmed" mb={4}>
|
||||
Containers ({containerLabels(activeRecord).length})
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{containerLabels(activeRecord).map((label, i) => (
|
||||
<Badge key={i} size="sm" variant="outline" color="gray">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
{vehicleValues.map((val, i) => (
|
||||
@@ -1711,14 +1792,13 @@ const LastMilePage = () => {
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Text size="sm" fw={500}>One vehicle per container</Text>
|
||||
<Text size="sm" fw={500}>Up to 2 containers per vehicle (trailer)</Text>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LastMileContainerAllocationTable
|
||||
lastMileId={activeId ?? ""}
|
||||
containers={allocationContainers}
|
||||
onSave={async (mappings) => {
|
||||
await allocateMutation.mutateAsync(mappings);
|
||||
|
||||
@@ -23,7 +23,13 @@ export interface LastMileBooking {
|
||||
destinationYard?: { id: string; name?: string; label?: string } | null;
|
||||
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
|
||||
/** Container lines — total container count drives how many trucks are needed. */
|
||||
bookingContainers?: Array<{ id: string; quantity: number }>;
|
||||
bookingContainers?: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
containerNumber?: string | null;
|
||||
containerSize?: string | null;
|
||||
containerType?: { id: string; name?: string; label?: string; code?: string } | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LastMileVehicle {
|
||||
|
||||
Reference in New Issue
Block a user