Warehouse and customer truck edit and KG to Tons

This commit is contained in:
Hagernesh
2026-07-08 05:19:25 +00:00
parent 22a2a770d2
commit ba821b06ca
5 changed files with 257 additions and 57 deletions

View File

@@ -350,6 +350,21 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto); 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') @Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck( async removeCustomerTruck(

View File

@@ -140,6 +140,76 @@ export class CustomerTruckService {
return this.listTrucks(bookingId); 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}: 12 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<CustomerTruckAssignment[]> {
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 * Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross. * actually loaded (replacing any provisional list) and its weighed gross.

View File

@@ -16,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react'; import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -30,6 +30,8 @@ import {
useDeleteAllocationRule, useDeleteAllocationRule,
useDeleteFeeRule, useDeleteFeeRule,
useFeeRules, useFeeRules,
useUpdateAllocationRule,
useUpdateFeeRule,
} from '@/hooks/useWarehouses'; } from '@/hooks/useWarehouses';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { import {
@@ -38,6 +40,8 @@ import {
FEE_RULE_TYPES, FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS, FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES, VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis, type FeeRuleBasis,
type FeeRuleType, type FeeRuleType,
} from '@/types/warehouse'; } from '@/types/warehouse';
@@ -121,8 +125,10 @@ function AllocationRules() {
const { data, isLoading } = useAllocationRules(); const { data, isLoading } = useAllocationRules();
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards(); const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
const create = useCreateAllocationRule(); const create = useCreateAllocationRule();
const update = useUpdateAllocationRule();
const remove = useDeleteAllocationRule(); const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
priority: 100, priority: 100,
@@ -142,7 +148,8 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`, label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
})); }));
const resetForm = () => const resetForm = () => {
setEditingId(null);
setForm({ setForm({
name: '', name: '',
priority: 100, priority: 100,
@@ -153,6 +160,22 @@ function AllocationRules() {
targetYardCode: '', targetYardCode: '',
storageType: '', 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 () => { const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) { if (!form.name.trim() || !form.targetYardCode.trim()) {
@@ -160,7 +183,7 @@ function AllocationRules() {
return; return;
} }
await create.mutateAsync({ const payload = {
name: form.name.trim(), name: form.name.trim(),
priority: form.priority, priority: form.priority,
freightType: clean(form.freightType) ?? null, freightType: clean(form.freightType) ?? null,
@@ -170,10 +193,20 @@ function AllocationRules() {
targetYardCode: form.targetYardCode.trim(), targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null, storageType: clean(form.storageType) ?? null,
isActive: true, isActive: true,
} as never); };
toast({ title: 'Allocation rule created' }); try {
setOpen(false); if (editingId) {
resetForm(); 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 ( return (
@@ -182,7 +215,7 @@ function AllocationRules() {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority {rules.length} rule(s) matched by ascending priority
</Text> </Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}> <Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule New allocation rule
</Button> </Button>
</Group> </Group>
@@ -229,14 +262,19 @@ function AllocationRules() {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td ta="right"> <Table.Td ta="right">
<ActionIcon <Group gap={4} justify="flex-end" wrap="nowrap">
variant="subtle" <ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
color="red" <Pencil size={16} />
onClick={() => remove.mutate(rule.id)} </ActionIcon>
title="Delete" <ActionIcon
> variant="subtle"
<Trash2 size={16} /> color="red"
</ActionIcon> onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -245,7 +283,7 @@ function AllocationRules() {
</Table.ScrollContainer> </Table.ScrollContainer>
)} )}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg"> <Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
<Stack gap="md"> <Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="gray.0"> <Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}> <Stack gap={4}>
@@ -340,11 +378,11 @@ function AllocationRules() {
/> />
</Group> </Group>
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}> <Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel Cancel
</Button> </Button>
<Button loading={create.isPending} onClick={submit}> <Button loading={create.isPending || update.isPending} onClick={submit}>
Create {editingId ? 'Save changes' : 'Create'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -363,8 +401,10 @@ function FeeRules() {
api.containerTypes.list.queryOptions({ staleTime: Infinity }), api.containerTypes.list.queryOptions({ staleTime: Infinity }),
); );
const create = useCreateFeeRule(); const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule(); const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType, ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
@@ -394,7 +434,8 @@ function FeeRules() {
// Double handling + truck detention apply to IMPORT only — trade direction is locked. // Double handling + truck detention apply to IMPORT only — trade direction is locked.
const isImportOnly = isDoubleHandling || isTruckDetention; const isImportOnly = isDoubleHandling || isTruckDetention;
const resetForm = () => const resetForm = () => {
setEditingId(null);
setForm({ setForm({
name: '', name: '',
ruleType: 'DEMURRAGE_FEE', ruleType: 'DEMURRAGE_FEE',
@@ -410,6 +451,27 @@ function FeeRules() {
tiers: [], tiers: [],
currency: 'USD', 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 = () => const addTier = () =>
setForm((f) => { setForm((f) => {
@@ -479,12 +541,17 @@ function FeeRules() {
}; };
try { try {
await create.mutateAsync(payload as never); if (editingId) {
toast({ title: 'Fee rule created' }); 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); setOpen(false);
resetForm(); resetForm();
} catch (error) { } catch (error) {
if (tiers.length && isUnknownTiersError(error)) { if (!editingId && tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit<typeof payload, 'tiers'> = { const legacyPayload: Omit<typeof payload, 'tiers'> = {
name: payload.name, name: payload.name,
ruleType: payload.ruleType, ruleType: payload.ruleType,
@@ -505,7 +572,7 @@ function FeeRules() {
resetForm(); resetForm();
return; 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() {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies {rules.length} rule(s) - most specific match applies
</Text> </Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}> <Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule New fee rule
</Button> </Button>
</Group> </Group>
@@ -577,14 +644,19 @@ function FeeRules() {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td ta="right"> <Table.Td ta="right">
<ActionIcon <Group gap={4} justify="flex-end" wrap="nowrap">
variant="subtle" <ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
color="red" <Pencil size={16} />
onClick={() => remove.mutate(rule.id)} </ActionIcon>
title="Delete" <ActionIcon
> variant="subtle"
<Trash2 size={16} /> color="red"
</ActionIcon> onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
@@ -593,7 +665,7 @@ function FeeRules() {
</Table.ScrollContainer> </Table.ScrollContainer>
)} )}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg"> <Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
<Stack gap="sm"> <Stack gap="sm">
<Group grow> <Group grow>
<TextInput <TextInput
@@ -775,11 +847,11 @@ function FeeRules() {
</Stack> </Stack>
)} )}
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}> <Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel Cancel
</Button> </Button>
<Button loading={create.isPending} onClick={submit}> <Button loading={create.isPending || update.isPending} onClick={submit}>
Create {editingId ? 'Save changes' : 'Create'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -15,7 +15,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types"; 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 { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -63,14 +63,19 @@ export function CustomerTruckAssignmentCard({
const [driverName, setDriverName] = useState(""); const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState(""); const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]); const [containers, setContainers] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Container numbers on the booking that aren't already loaded onto a truck. // Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set( const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), 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( 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. // Both import and export specify the containers each truck carries.
@@ -79,24 +84,38 @@ export function CustomerTruckAssignmentCard({
setDriverName(""); setDriverName("");
setTruckType(""); setTruckType("");
setContainers([]); 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); setError(null);
}; };
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: () => mutationFn: () => {
customerTrucksService.add(booking.id, { const payload = {
truckPlateNumber: plateNumber.trim().toUpperCase(), truckPlateNumber: plateNumber.trim().toUpperCase(),
driverName: driverName.trim(), driverName: driverName.trim(),
truckType: truckType.trim(), truckType: truckType.trim(),
containerNumbers: containers, containerNumbers: containers,
}), };
return editingId
? customerTrucksService.update(booking.id, editingId, payload)
: customerTrucksService.add(booking.id, payload);
},
onSuccess: (list) => { onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list); queryClient.setQueryData(trucksKey, list);
toast.success(editingId ? "Truck updated" : "Truck added");
resetForm(); resetForm();
onAssigned(); 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({ const removeMutation = useMutation({
@@ -183,15 +202,25 @@ export function CustomerTruckAssignmentCard({
</Group> </Group>
</Stack> </Stack>
{!t.arrivedAt && ( {!t.arrivedAt && (
<ActionIcon <Group gap={4} wrap="nowrap">
variant="subtle" <ActionIcon
color="red" variant="subtle"
aria-label="Remove truck" color="blue"
onClick={() => removeMutation.mutate(t.id)} aria-label="Edit truck"
loading={removeMutation.isPending} onClick={() => startEdit(t)}
> >
<Trash2 size={16} /> <Pencil size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove truck"
onClick={() => removeMutation.mutate(t.id)}
loading={removeMutation.isPending}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
)} )}
</Group> </Group>
)) ))
@@ -206,7 +235,7 @@ export function CustomerTruckAssignmentCard({
{/* Add-truck form — both directions assign the containers each truck carries. */} {/* Add-truck form — both directions assign the containers each truck carries. */}
{availableContainers.length > 0 ? ( {availableContainers.length > 0 ? (
<> <>
<Divider label="Add a truck" labelPosition="center" /> <Divider label={editingId ? "Edit truck" : "Add a truck"} labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput <TextInput
label="Truck Plate Number" label="Truck Plate Number"
@@ -241,13 +270,18 @@ export function CustomerTruckAssignmentCard({
/> />
</SimpleGrid> </SimpleGrid>
<Group justify="flex-end"> <Group justify="flex-end">
{editingId && (
<Button variant="default" onClick={resetForm} disabled={addMutation.isPending}>
Cancel
</Button>
)}
<Button <Button
leftSection={<Plus size={16} />} leftSection={editingId ? <Pencil size={16} /> : <Plus size={16} />}
color="edr-green" color="edr-green"
onClick={submitAdd} onClick={submitAdd}
loading={addMutation.isPending} loading={addMutation.isPending}
> >
Add truck {editingId ? "Save changes" : "Add truck"}
</Button> </Button>
</Group> </Group>
</> </>

View File

@@ -23,6 +23,15 @@ export const customerTrucksService = {
return data.data ?? data; return data.data ?? data;
}, },
update: async (
bookingId: string,
assignmentId: string,
payload: Freight.AddCustomerTruckPayload,
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.patch(B.CUSTOMER_TRUCK(bookingId, assignmentId), payload);
return data.data ?? data;
},
remove: async ( remove: async (
bookingId: string, bookingId: string,
assignmentId: string, assignmentId: string,