Merge pull request #431 from Tria-plc/freight/feature/first_mile_invoice

Freight/feature/first mile invoice
This commit is contained in:
yaschalew10
2026-07-03 22:59:10 +03:00
committed by GitHub
6 changed files with 151 additions and 35 deletions

View File

@@ -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);