mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
@@ -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(
|
||||
|
||||
@@ -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<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
|
||||
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||
|
||||
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
@@ -158,6 +158,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
*/
|
||||
private async notifyScheduleBookings(
|
||||
schedule: TrainSchedule,
|
||||
event: 'dispatched' | 'arrived',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||
const destination =
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(ids) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking clearance milestones for every booking on a
|
||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||
@@ -1545,6 +1574,7 @@ export class TrainSchedulingService {
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2550,6 +2580,7 @@ export class TrainSchedulingService {
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/**
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
@@ -13,7 +15,32 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
||||
export class HandoverService {
|
||||
private readonly logger = new Logger(HandoverService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Handover — signature needed',
|
||||
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
list(bookingId: string): Promise<BookingHandover[]> {
|
||||
return this.dataSource.getRepository(BookingHandover).find({
|
||||
@@ -54,6 +81,7 @@ export class HandoverService {
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||
void this.notifySignNeeded(bookingId, reference);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
@@ -383,8 +385,39 @@ export class WarehouseInventoryService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
private async notifyTruckAssignmentNeeded(booking: {
|
||||
companyId?: string | null;
|
||||
reference?: string | null;
|
||||
hasFirstMile?: boolean;
|
||||
hasLastMile?: boolean;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}, bookingId: string): Promise<void> {
|
||||
if (!booking.companyId) return;
|
||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — final terminal release / gate clearance.
|
||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||
@@ -866,7 +899,10 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -1002,6 +1038,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
// In-app deep-link to pay the fee from the booking.
|
||||
if (invoice.customerId && invoice.bookingId) {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: invoice.customerId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: "Warehouse fee due",
|
||||
body:
|
||||
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||
link: `/bookings/${invoice.bookingId}`,
|
||||
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -229,14 +262,19 @@ function AllocationRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -245,7 +283,7 @@ function AllocationRules() {
|
||||
</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">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
@@ -340,11 +378,11 @@ function AllocationRules() {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -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<string | null>(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<typeof payload, 'tiers'> = {
|
||||
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() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -577,14 +644,19 @@ function FeeRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -593,7 +665,7 @@ function FeeRules() {
|
||||
</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">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -775,11 +847,11 @@ function FeeRules() {
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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({
|
||||
</Group>
|
||||
</Stack>
|
||||
{!t.arrivedAt && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove truck"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
aria-label="Edit truck"
|
||||
onClick={() => startEdit(t)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove truck"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
@@ -206,7 +235,7 @@ export function CustomerTruckAssignmentCard({
|
||||
{/* Add-truck form — both directions assign the containers each truck carries. */}
|
||||
{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">
|
||||
<TextInput
|
||||
label="Truck Plate Number"
|
||||
@@ -241,13 +270,18 @@ export function CustomerTruckAssignmentCard({
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
{editingId && (
|
||||
<Button variant="default" onClick={resetForm} disabled={addMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
leftSection={editingId ? <Pencil size={16} /> : <Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={submitAdd}
|
||||
loading={addMutation.isPending}
|
||||
>
|
||||
Add truck
|
||||
{editingId ? "Save changes" : "Add truck"}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,15 @@ export const customerTrucksService = {
|
||||
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 (
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
|
||||
Reference in New Issue
Block a user