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:
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
|
||||
* from the booking's container number when present, else entered by the operator
|
||||
* at assignment time.
|
||||
*/
|
||||
export class AddLastMileAssignmentContainerNumber1890000000009
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLastMileAssignmentContainerNumber1890000000009";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS container_number varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS container_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { IsArray, IsUUID } from 'class-validator';
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/** Replace the full set of vehicles assigned to a last-mile delivery. */
|
||||
export class LastMileVehicleInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||
export class SetVehiclesDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
vehicleIds!: string[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LastMileVehicleInput)
|
||||
vehicles!: LastMileVehicleInput[];
|
||||
}
|
||||
|
||||
@@ -27,4 +27,9 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle;
|
||||
|
||||
/** Container this truck carries — auto-filled from the booking's container
|
||||
* number when known, else entered manually at assignment time. */
|
||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,6 @@ export class LastMileController {
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetVehiclesDto,
|
||||
) {
|
||||
return this.lastMileService.setVehicles(id, dto.vehicleIds);
|
||||
return this.lastMileService.setVehicles(id, dto.vehicles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,19 +362,37 @@ export class LastMileService {
|
||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||
*/
|
||||
async setVehicles(id: string, vehicleIds: string[]): Promise<LastMile> {
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
): Promise<LastMile> {
|
||||
const existing = await this.findById(id);
|
||||
const desired = [...new Set(vehicleIds.filter(Boolean))];
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(LastMileVehicleAssignment, {
|
||||
where: { lastMileId: id },
|
||||
});
|
||||
const currentIds = current.map((a) => a.vehicleId);
|
||||
const currentSet = new Set(currentIds);
|
||||
const desiredSet = new Set(desired);
|
||||
const added = desired.filter((v) => !currentSet.has(v));
|
||||
const removed = currentIds.filter((v) => !desiredSet.has(v));
|
||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||
// old single-vehicle path has no junction row but must still be freed.
|
||||
const releaseIds = [...new Set(
|
||||
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
|
||||
)];
|
||||
const added = desired.filter((v) => !junctionSet.has(v));
|
||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||
// Vehicles that stay but whose container number changed.
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
@@ -384,7 +402,18 @@ export class LastMileService {
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId });
|
||||
await tx.insert(LastMileVehicleAssignment, {
|
||||
lastMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
});
|
||||
}
|
||||
for (const row of changed) {
|
||||
await tx.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -110,6 +110,12 @@ const requiredVehicles = (record: LastMileRecord) => {
|
||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
||||
};
|
||||
|
||||
/** Container numbers on a booking, in line order (skips lines without one). */
|
||||
const bookingContainerNumbers = (record: LastMileRecord): string[] =>
|
||||
(record.booking?.bookingContainers ?? [])
|
||||
.map((c) => c.containerNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
|
||||
/** Container rows for the per-container→vehicle allocation table. */
|
||||
const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] =>
|
||||
(record.booking?.bookingContainers ?? []).map((c) => ({
|
||||
@@ -473,8 +479,10 @@ const LastMilePage = () => {
|
||||
const [tripSlipOpen, setTripSlipOpen] = useState(false);
|
||||
const [tripSlipRecord, setTripSlipRecord] = useState<LastMileRecord | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
// Multi-vehicle assign: one entry per selected vehicle (null = empty picker).
|
||||
const [vehicleValues, setVehicleValues] = useState<(string | null)[]>([null]);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
|
||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
@@ -569,8 +577,13 @@ const LastMilePage = () => {
|
||||
});
|
||||
|
||||
const setVehiclesMutation = useMutation({
|
||||
mutationFn: ({ id, vehicleIds }: { id: string; vehicleIds: string[] }) =>
|
||||
lastMileService.setVehicles(id, vehicleIds),
|
||||
mutationFn: ({
|
||||
id,
|
||||
vehicles,
|
||||
}: {
|
||||
id: string;
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>;
|
||||
}) => lastMileService.setVehicles(id, vehicles),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
@@ -645,7 +658,8 @@ const LastMilePage = () => {
|
||||
items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)),
|
||||
);
|
||||
if (vehicleIds.length) {
|
||||
await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicleIds)));
|
||||
const vehicles = vehicleIds.map((v) => ({ vehicleId: v }));
|
||||
await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicles)));
|
||||
}
|
||||
return created;
|
||||
},
|
||||
@@ -837,21 +851,28 @@ const LastMilePage = () => {
|
||||
const openAssign = (id: string | null) => {
|
||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||
const rec = records.find((r) => r.id === resolved);
|
||||
const existing = rec?.vehicleAssignments?.length
|
||||
? rec.vehicleAssignments.map((a) => a.vehicleId)
|
||||
: rec?.vehicleId
|
||||
? [rec.vehicleId]
|
||||
: [];
|
||||
// Prefill each row's container number from the booking's container numbers
|
||||
// (by order) when the assignment doesn't already carry one.
|
||||
const nums = rec ? bookingContainerNumbers(rec) : [];
|
||||
const rows =
|
||||
rec?.vehicleAssignments?.length
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleValues(existing.length ? existing : [null]);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const openBulkAssign = () => {
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleValues([null]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -859,11 +880,16 @@ const LastMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleValues([null]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const ids = [...new Set(vehicleValues.filter((v): v is string => Boolean(v)))];
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
|
||||
@@ -871,14 +897,14 @@ const LastMilePage = () => {
|
||||
if (!targetIds.length) return;
|
||||
|
||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids })))
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||
.then(() => {
|
||||
toast({
|
||||
title: ids.length === 0 ? "Vehicles unassigned" : ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
||||
title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned",
|
||||
description:
|
||||
ids.length === 0
|
||||
count === 0
|
||||
? bulkMode ? `${targetIds.length} deliveries` : undefined
|
||||
: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`,
|
||||
: `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`,
|
||||
});
|
||||
if (bulkMode) setRowSelection({});
|
||||
closeAssign();
|
||||
@@ -1132,7 +1158,7 @@ const LastMilePage = () => {
|
||||
disabled={!assigned || delivered}
|
||||
onClick={() =>
|
||||
setVehiclesMutation.mutate(
|
||||
{ id: row.original.id, vehicleIds: [] },
|
||||
{ id: row.original.id, vehicles: [] },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }),
|
||||
@@ -1467,7 +1493,7 @@ const LastMilePage = () => {
|
||||
{!bulkMode && activeRecord && (() => {
|
||||
const containers = containerCount(activeRecord);
|
||||
const needed = requiredVehicles(activeRecord);
|
||||
const picked = vehicleValues.filter(Boolean).length;
|
||||
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
||||
if (needed === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
||||
@@ -1504,34 +1530,40 @@ const LastMilePage = () => {
|
||||
)}
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
{vehicleValues.map((val, i) => (
|
||||
{vehicleRows.map((row, i) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Vehicles" : undefined}
|
||||
style={{ flex: 1.4 }}
|
||||
label={i === 0 ? "Vehicle" : undefined}
|
||||
placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||
description={
|
||||
i === 0 && assignVehicleOptions.length === 0
|
||||
? "No free vehicles available — free up a vehicle in Fleet › Vehicles first."
|
||||
: undefined
|
||||
}
|
||||
data={assignVehicleOptions.filter(
|
||||
(o) => o.value === val || !vehicleValues.includes(o.value),
|
||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||
)}
|
||||
value={val}
|
||||
value={row.vehicleId}
|
||||
onChange={(v) =>
|
||||
setVehicleValues((prev) => prev.map((x, idx) => (idx === i ? v : x)))
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
{vehicleValues.length > 1 && (
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder="Container number"
|
||||
value={row.containerNumber}
|
||||
onChange={(e) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: e.currentTarget.value } : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove vehicle"
|
||||
onClick={() => setVehicleValues((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
@@ -1542,11 +1574,20 @@ const LastMilePage = () => {
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() => setVehicleValues((prev) => [...prev, null])}
|
||||
onClick={() =>
|
||||
setVehicleRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
},
|
||||
])
|
||||
}
|
||||
disabled={
|
||||
assignVehicleOptions.length === 0 ||
|
||||
vehicleValues.some((v) => !v) ||
|
||||
vehicleValues.filter(Boolean).length >= assignVehicleOptions.length
|
||||
vehicleRows.some((r) => !r.vehicleId) ||
|
||||
vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length
|
||||
}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,12 @@ export interface LastMileRecord {
|
||||
booking?: LastMileBooking | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
/** Full set of vehicles serving this delivery (multi-truck). */
|
||||
vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>;
|
||||
vehicleAssignments?: Array<{
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -79,6 +84,8 @@ export const lastMileService = {
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
remove: (id: string) =>
|
||||
api.delete<void>(LM.BY_ID(id)),
|
||||
setVehicles: (id: string, vehicleIds: string[]) =>
|
||||
api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicleIds }),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user