diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..79d94f3de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -350,6 +350,21 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Patch(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + async updateCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.updateTruck(id, assignmentId, dto); + } + @Delete(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 43c14c7f6..693b60e09 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -140,6 +140,76 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Edit a truck assignment — plate/driver/type and the containers it carries. + * Allowed only until the truck has arrived (same guard as removal). Container + * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already + * on another truck, and a 40ft container fills the truck (max 1). + */ + async updateTruck( + bookingId: string, + assignmentId: string, + dto: AddCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot edit a truck that has already arrived'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + // Exclude THIS truck's own containers so re-saving the same set is allowed. + const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (assignedElsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }); + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + }); + + return this.listTrucks(bookingId); + } + /** * Register an IMPORT self-haul truck leaving the port: the containers it * actually loaded (replacing any provisional list) and its weighed gross. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 41a97110f..81633f9f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -16,7 +16,7 @@ import { Text, TextInput, } from '@mantine/core'; -import { Info, Plus, Trash2 } from 'lucide-react'; +import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -30,6 +30,8 @@ import { useDeleteAllocationRule, useDeleteFeeRule, useFeeRules, + useUpdateAllocationRule, + useUpdateFeeRule, } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; import { @@ -38,6 +40,8 @@ import { FEE_RULE_TYPES, FEE_RULE_TYPE_LABELS, VEHICLE_TYPES, + type AllocationRule, + type FeeRule, type FeeRuleBasis, type FeeRuleType, } from '@/types/warehouse'; @@ -121,8 +125,10 @@ function AllocationRules() { const { data, isLoading } = useAllocationRules(); const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards(); const create = useCreateAllocationRule(); + const update = useUpdateAllocationRule(); const remove = useDeleteAllocationRule(); const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const [form, setForm] = useState({ name: '', priority: 100, @@ -142,7 +148,8 @@ function AllocationRules() { label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`, })); - const resetForm = () => + const resetForm = () => { + setEditingId(null); setForm({ name: '', priority: 100, @@ -153,6 +160,22 @@ function AllocationRules() { targetYardCode: '', storageType: '', }); + }; + + const startEdit = (rule: AllocationRule) => { + setForm({ + name: rule.name, + priority: rule.priority ?? 100, + freightType: rule.freightType ?? '', + tradeDirection: rule.tradeDirection ?? '', + cargoTypeCode: rule.cargoTypeCode ?? '', + containerStatus: rule.containerStatus ?? '', + targetYardCode: rule.targetYardCode ?? '', + storageType: rule.storageType ?? '', + }); + setEditingId(rule.id); + setOpen(true); + }; const submit = async () => { if (!form.name.trim() || !form.targetYardCode.trim()) { @@ -160,7 +183,7 @@ function AllocationRules() { return; } - await create.mutateAsync({ + const payload = { name: form.name.trim(), priority: form.priority, freightType: clean(form.freightType) ?? null, @@ -170,10 +193,20 @@ function AllocationRules() { targetYardCode: form.targetYardCode.trim(), storageType: clean(form.storageType) ?? null, isActive: true, - } as never); - toast({ title: 'Allocation rule created' }); - setOpen(false); - resetForm(); + }; + try { + if (editingId) { + await update.mutateAsync({ id: editingId, payload: payload as never }); + toast({ title: 'Allocation rule updated' }); + } else { + await create.mutateAsync(payload as never); + toast({ title: 'Allocation rule created' }); + } + setOpen(false); + resetForm(); + } catch (error) { + toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) }); + } }; return ( @@ -182,7 +215,7 @@ function AllocationRules() { {rules.length} rule(s) matched by ascending priority - @@ -229,14 +262,19 @@ function AllocationRules() { - remove.mutate(rule.id)} - title="Delete" - > - - + + startEdit(rule)} title="Edit"> + + + remove.mutate(rule.id)} + title="Delete" + > + + + ))} @@ -245,7 +283,7 @@ function AllocationRules() { )} - setOpen(false)} title="New allocation rule" centered size="lg"> + { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg"> @@ -340,11 +378,11 @@ function AllocationRules() { /> - - @@ -363,8 +401,10 @@ function FeeRules() { api.containerTypes.list.queryOptions({ staleTime: Infinity }), ); const create = useCreateFeeRule(); + const update = useUpdateFeeRule(); const remove = useDeleteFeeRule(); const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const [form, setForm] = useState({ name: '', ruleType: 'DEMURRAGE_FEE' as FeeRuleType, @@ -394,7 +434,8 @@ function FeeRules() { // Double handling + truck detention apply to IMPORT only — trade direction is locked. const isImportOnly = isDoubleHandling || isTruckDetention; - const resetForm = () => + const resetForm = () => { + setEditingId(null); setForm({ name: '', ruleType: 'DEMURRAGE_FEE', @@ -410,6 +451,27 @@ function FeeRules() { tiers: [], currency: 'USD', }); + }; + + const startEdit = (rule: FeeRule) => { + setForm({ + name: rule.name, + ruleType: rule.ruleType, + basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER', + freightType: rule.freightType ?? '', + tradeDirection: rule.tradeDirection ?? '', + cargoTypeCode: rule.cargoTypeCode ?? '', + containerType: rule.containerType ?? '', + vehicleType: rule.vehicleType ?? '', + freeDays: rule.freeDays ?? 3, + freeHours: rule.freeHours ?? 3, + ratePerDay: rule.ratePerDay ?? 0, + tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })), + currency: rule.currency ?? 'USD', + }); + setEditingId(rule.id); + setOpen(true); + }; const addTier = () => setForm((f) => { @@ -479,12 +541,17 @@ function FeeRules() { }; try { - await create.mutateAsync(payload as never); - toast({ title: 'Fee rule created' }); + if (editingId) { + await update.mutateAsync({ id: editingId, payload: payload as never }); + toast({ title: 'Fee rule updated' }); + } else { + await create.mutateAsync(payload as never); + toast({ title: 'Fee rule created' }); + } setOpen(false); resetForm(); } catch (error) { - if (tiers.length && isUnknownTiersError(error)) { + if (!editingId && tiers.length && isUnknownTiersError(error)) { const legacyPayload: Omit = { name: payload.name, ruleType: payload.ruleType, @@ -505,7 +572,7 @@ function FeeRules() { resetForm(); return; } - toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) }); } }; @@ -515,7 +582,7 @@ function FeeRules() { {rules.length} rule(s) - most specific match applies - @@ -577,14 +644,19 @@ function FeeRules() { - remove.mutate(rule.id)} - title="Delete" - > - - + + startEdit(rule)} title="Edit"> + + + remove.mutate(rule.id)} + title="Delete" + > + + + ))} @@ -593,7 +665,7 @@ function FeeRules() { )} - setOpen(false)} title="New fee rule" centered size="lg"> + { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg"> )} - - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 67840c133..a8cee69eb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -63,14 +63,19 @@ export function CustomerTruckAssignmentCard({ const [driverName, setDriverName] = useState(""); const [truckType, setTruckType] = useState(""); const [containers, setContainers] = useState([]); + const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), ); + // When editing a truck, its own containers stay selectable. + const editingOwn = new Set( + (trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber), + ); const availableContainers = (booking.containerNumbers ?? []).filter( - (n) => !assignedNumbers.has(n), + (n) => !assignedNumbers.has(n) || editingOwn.has(n), ); // Both import and export specify the containers each truck carries. @@ -79,24 +84,38 @@ export function CustomerTruckAssignmentCard({ setDriverName(""); setTruckType(""); setContainers([]); + setEditingId(null); + setError(null); + }; + + const startEdit = (t: Freight.ICustomerTruck) => { + setPlateNumber(t.plateNumber ?? ""); + setDriverName(t.driverName ?? ""); + setTruckType(t.truckType ?? ""); + setContainers((t.containers ?? []).map((c) => c.containerNumber)); + setEditingId(t.id); setError(null); }; const addMutation = useMutation({ - mutationFn: () => - customerTrucksService.add(booking.id, { + mutationFn: () => { + const payload = { truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), containerNumbers: containers, - }), + }; + return editingId + ? customerTrucksService.update(booking.id, editingId, payload) + : customerTrucksService.add(booking.id, payload); + }, onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); + toast.success(editingId ? "Truck updated" : "Truck added"); resetForm(); onAssigned(); - toast.success("Truck added"); }, - onError: (e) => setError(errorMessage(e, "Could not add truck")), + onError: (e) => setError(errorMessage(e, editingId ? "Could not update truck" : "Could not add truck")), }); const removeMutation = useMutation({ @@ -183,15 +202,25 @@ export function CustomerTruckAssignmentCard({ {!t.arrivedAt && ( - removeMutation.mutate(t.id)} - loading={removeMutation.isPending} - > - - + + startEdit(t)} + > + + + removeMutation.mutate(t.id)} + loading={removeMutation.isPending} + > + + + )} )) @@ -206,7 +235,7 @@ export function CustomerTruckAssignmentCard({ {/* Add-truck form — both directions assign the containers each truck carries. */} {availableContainers.length > 0 ? ( <> - + + {editingId && ( + + )} diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts index ee317e204..2663d43ec 100644 --- a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -23,6 +23,15 @@ export const customerTrucksService = { return data.data ?? data; }, + update: async ( + bookingId: string, + assignmentId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.patch(B.CUSTOMER_TRUCK(bookingId, assignmentId), payload); + return data.data ?? data; + }, + remove: async ( bookingId: string, assignmentId: string,