Files
edr-platform/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx
natib21 8d17d9111e mile
2026-07-03 19:44:27 +00:00

187 lines
5.6 KiB
TypeScript

import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
/** One vehicle (with trailer) carries at most this many containers. */
const CONTAINERS_PER_VEHICLE = 2;
export interface LastMileContainerAllocationTableProps {
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () =>
vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).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 (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No free vehicles available. Free up or add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={optionsForRow(container)}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated · max{" "}
{CONTAINERS_PER_VEHICLE} per vehicle
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}