mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Warehouse and customer truck edit and KG to Tons
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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}: 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<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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user