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,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[];
}

View File

@@ -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 },
},

View File

@@ -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"

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

View File

@@ -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 {

Submodule apps/edr-freight-web/backoffice/user-management deleted from e3a50a7c91