mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Merge pull request #431 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user