Truck detention was timed once per delivery, so every truck on a multi-truck last mile was billed the same number of days regardless of when it actually arrived or was released. Each truck now carries its own detention clock (destination arrival → release) with its own rule match, chargeable days and amount; the modal records and displays the window per truck, and the summary shows the longest detention plus the combined total. Also corrected a false no detention rule matches warning that appeared whenever more than one truck was assigned.

This commit is contained in:
Hagernesh
2026-07-27 08:05:12 +00:00
parent 3e2ae80e5a
commit 500668a415
11 changed files with 512 additions and 86 deletions

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck detention clocks. Detention was timed once per last-mile leg
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
* delivery shared one window and was billed identical days — wrong the moment
* two trucks arrive or return at different times.
*
* Deliberately NEW columns rather than reusing the existing per-truck
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
* events stamped by release(), whereas detention runs from arrival at the
* DESTINATION until the truck is released/returned.
*
* Both nullable — a truck without its own window falls back to the leg-level
* timestamps, so legacy legs keep billing exactly as before.
*/
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
name = 'AddPerTruckDetentionWindow2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS returned_at,
DROP COLUMN IF EXISTS destination_arrived_at;
`);
}
}

View File

@@ -0,0 +1,29 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
/**
* One truck's detention window. Each truck reaches the destination and is
* released at its own time, so detention days differ between trucks on the
* same delivery. Null clears the value (falls back to the leg-level pair).
*/
export class TruckDetentionTimeInput {
@IsUUID()
vehicleId!: string;
/** Detention clock start — this truck reached the destination. */
@IsOptional()
@IsDateString()
destinationArrivedAt?: string | null;
/** Detention clock end — this truck was released/returned. Omit = still out. */
@IsOptional()
@IsDateString()
returnedAt?: string | null;
}
export class SetDetentionTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckDetentionTimeInput)
trucks!: TruckDetentionTimeInput[];
}

View File

@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) @Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null; departedAt?: Date | null;
/**
* Detention clock START for THIS truck: reached the delivery destination.
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
* leg-level `last_mile.arrived_at`.
*/
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
destinationArrivedAt?: Date | null;
/**
* Detention clock END for THIS truck: released / returned by the customer.
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
*/
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
returnedAt?: Date | null;
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */ /** Weighed gross on exit, in TONNES (not kg — see the migration note). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
grossWeightTons?: number | null; grossWeightTons?: number | null;

View File

@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto'; import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileStatus } from './entities/last-mile.entity';
@@ -131,6 +132,18 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
} }
@Post(':id/detention-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
})
async setDetentionTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDetentionTimesDto,
) {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery') @Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update) @BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())

View File

@@ -869,6 +869,46 @@ export class LastMileService {
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed * sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step. * client-side. Does NOT generate an invoice — that's a separate explicit step.
*/ */
/**
* Per-truck detention windows. Each truck reaches the destination and is
* released at its own time, so every truck gets its own clock (and therefore
* its own chargeable days). Locked once the detention invoice exists.
*/
async setDetentionTimes(
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Detention times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
const end = t.returnedAt ? new Date(t.returnedAt) : null;
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(
'A truck cannot be returned before it arrived — check the detention times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ destinationArrivedAt: start, returnedAt: end },
);
}
return this.findById(id);
}
async setDistances( async setDistances(
id: string, id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>, distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -0,0 +1,82 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Detention is per truck: two trucks on the same delivery with different
* windows must produce different chargeable days and amounts (the old
* leg-level clock billed them identically).
*/
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeTruckDetention: (
rule: Record<string, unknown> | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
now: Date,
billingCurrency: string,
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
calculateTieredAmount: unknown;
};
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
// 3h grace, 50/truck/day, no tiers.
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
const now = new Date('2026-07-25T12:00:00Z');
describe('per-truck detention', () => {
it('bills each truck on its own window', async () => {
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
const a = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
const b = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
expect(a.chargeableDays).toBe(2);
expect(b.chargeableDays).toBe(4);
expect(a.amount).toBe(100);
expect(b.amount).toBe(200);
// The whole point: same delivery, different bills.
expect(a.amount).not.toBe(b.amount);
});
it('charges nothing inside the grace window', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
now,
'USD',
);
expect(out.chargeableDays).toBe(0);
expect(out.amount).toBe(0);
});
it('keeps accruing against now when a truck has not returned', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
now,
'USD',
);
expect(out.endIsOpen).toBe(true);
expect(out.chargeableDays).toBe(2);
});
});

View File

@@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => {
inventoryWeight: 25, inventoryWeight: 25,
bookingContainerCount: 0, bookingContainerCount: 0,
cargoUnitOfMeasure: null, cargoUnitOfMeasure: null,
// Double handling now bills only when staff answered Yes after unloading;
// these quantity-basis cases assume that answer (the gate itself is covered
// in double-handling-gate.spec.ts).
doubleHandling: true,
facilityId: null, facilityId: null,
warehouseId: null, warehouseId: null,
yardId: null, yardId: null,

View File

@@ -92,10 +92,20 @@ export interface FeePreview {
ratePerDay: number; ratePerDay: number;
amount: number; amount: number;
}>; }>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ /**
* Truck detention: one row PER TRUCK — each truck has its own detention
* window (it arrives and is released at its own time) and its own matching
* rule by truck type, so days and amount differ between trucks.
*/
groups?: Array<{ groups?: Array<{
assignmentId: string | null;
vehicleId: string | null;
plateNumber: string | null;
vehicleType: string | null; vehicleType: string | null;
truckCount: number; truckCount: number;
startDate: string | null;
endDate: string | null;
endIsOpen: boolean;
chargeableDays: number; chargeableDays: number;
ratePerDay: number; ratePerDay: number;
amount: number; amount: number;
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
}; };
} }
// Group the leg's vehicles by CANONICAL truck type so each type is billed // One row PER TRUCK: each truck has its own detention window (it reaches the
// by its own matching rule (rates differ by truck type). The FK to // destination and is released at its own time) and resolves its own rule by
// truck_types is the source of truth — renaming a type's label no longer // CANONICAL truck type — the truck_types FK is the source of truth, with the
// silently unmatches its rule; the normalized legacy vehicle_type code is // normalized legacy vehicle_type code as fallback so FK-less vehicles keep
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed // billing. Per-truck timestamps fall back to the leg-level pair for legacy
// instead of dropping them). Falls back to one untyped group. // legs recorded before per-truck tracking.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = const truckRows: Array<{
await this.dataSource.query( assignmentId: string;
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", vehicleId: string;
count(*)::int AS "truckCount" plateNumber: string | null;
FROM freight.last_mile_vehicle_assignments va vehicleType: string | null;
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL startAt: Date | string | null;
LEFT JOIN freight.truck_types t endAt: Date | string | null;
ON t.id = v.truck_type_id AND t.deleted_at IS NULL }> = await this.dataSource.query(
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL `SELECT va.id AS "assignmentId",
GROUP BY 1`, va.vehicle_id AS "vehicleId",
[lastMileId], COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
); COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
ORDER BY va.created_at ASC`,
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
);
// No trucks assigned yet: keep the leg-level single-truck estimate so the
// preview still tells the operator what detention would cost.
const trucks = truckRows.length
? truckRows
: [
{
assignmentId: null as string | null,
vehicleId: null as string | null,
plateNumber: null as string | null,
vehicleType: null as string | null,
startAt: leg.arrivedAt ?? null,
endAt: leg.deliveredAt ?? null,
},
];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency); const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all( const computed = await Promise.all(
groups.map(async (g) => { trucks.map(async (t) => {
const item: ItemAttributes = { const item: ItemAttributes = {
arrivedAt: null, arrivedAt: null,
gateClearedAt: null, gateClearedAt: null,
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
tradeDirection: leg.tradeDirection ?? null, tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null, cargoTypeCode: null,
containerTypeCode: null, containerTypeCode: null,
vehicleType: g.vehicleType ?? null, vehicleType: t.vehicleType ?? null,
inventoryQuantity: 1, inventoryQuantity: 1,
inventoryWeight: 0, inventoryWeight: 0,
bookingContainerCount: 1, bookingContainerCount: 1,
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
zoneId: null, zoneId: null,
}; };
const rule = this.bestRule(detentionRules, item); const rule = this.bestRule(detentionRules, item);
// truckCount 1 — this row IS one truck.
const c = await this.computeTruckDetention( const c = await this.computeTruckDetention(
rule, rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
now, now,
billingCurrency, billingCurrency,
); );
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; return { ...t, c };
}), }),
); );
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); const totalTrucks = computed.length;
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 0; // Header days: the worst truck — a single number can't represent per-truck
// windows, and the longest detention is the one operations must act on.
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
const single = computed.length === 1 ? computed[0].c : null; const single = computed.length === 1 ? computed[0].c : null;
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
const earliestStart = computed
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
.filter((n): n is number => n != null)
.sort((a, b) => a - b)[0];
const anyOpen = computed.some((x) => x.c.endIsOpen);
return { return {
ruleType: 'TRUCK_DETENTION_FEE', ruleType: 'TRUCK_DETENTION_FEE',
basis: null, basis: null,
unitLabel: 'truck', unitLabel: 'truck',
ruleId: single?.ruleId ?? null, ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
freeDays: 0, freeDays: 0,
ratePerDay: single?.ratePerDay ?? 0, ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency, currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null, ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency, billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), endDate: (anyOpen ? now : new Date(Math.max(
endIsOpen: !leg.deliveredAt, ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
))).toISOString(),
endIsOpen: anyOpen,
elapsedDays: chargeableDays, elapsedDays: chargeableDays,
chargeableDays, chargeableDays,
containerCount: totalTrucks, containerCount: totalTrucks,
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
amount: totalAmount, amount: totalAmount,
tiers: single ? single.tiers : [], tiers: single ? single.tiers : [],
groups: computed.map((x) => ({ groups: computed.map((x) => ({
assignmentId: x.assignmentId,
vehicleId: x.vehicleId,
plateNumber: x.plateNumber,
vehicleType: x.vehicleType, vehicleType: x.vehicleType,
truckCount: x.truckCount, truckCount: 1,
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
endDate: x.c.endDate,
endIsOpen: x.c.endIsOpen,
chargeableDays: x.c.chargeableDays, chargeableDays: x.c.chargeableDays,
ratePerDay: x.c.ratePerDay, ratePerDay: x.c.ratePerDay,
amount: x.c.amount, amount: x.c.amount,

View File

@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
); );
} }
type TruckRow = {
vehicleId: string;
label: string;
arrived: Date | null;
returned: Date | null;
};
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
/** /**
* View/override the detention clock (arrival + delivery/return) for a last-mile * Detention is PER TRUCK: every truck reaches the destination and is released at
* leg, preview the per-truck-per-day charge, and generate the detention invoice. * its own time, so each row carries its own clock, days and amount. Legs with no
* trucks assigned fall back to the single leg-level window.
*/ */
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) { export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const id = record?.id ?? null; const id = record?.id ?? null;
const assignments = record?.vehicleAssignments ?? [];
const perTruck = assignments.length > 0;
const [rows, setRows] = useState<TruckRow[]>([]);
// Leg-level fallback (no trucks assigned yet).
const [arrived, setArrived] = useState<Date | null>(null); const [arrived, setArrived] = useState<Date | null>(null);
const [delivered, setDelivered] = useState<Date | null>(null); const [delivered, setDelivered] = useState<Date | null>(null);
useEffect(() => { useEffect(() => {
setRows(
assignments.map((a) => ({
vehicleId: a.vehicleId,
label: plateOf(a),
// Fall back to the leg-level pair so a truck without its own window
// shows what it is actually being billed on today.
arrived: a.destinationArrivedAt
? new Date(a.destinationArrivedAt)
: record?.arrivedAt
? new Date(record.arrivedAt)
: null,
returned: a.returnedAt
? new Date(a.returnedAt)
: record?.deliveredAt
? new Date(record.deliveredAt)
: null,
})),
);
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null); setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null); setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
const previewQuery = useQuery({ const previewQuery = useQuery({
queryKey: ['truck-detention-preview', id], queryKey: ['truck-detention-preview', id],
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
enabled: opened && Boolean(id), enabled: opened && Boolean(id),
}); });
const preview = previewQuery.data; const preview = previewQuery.data;
// With several trucks the header rule is null by design (each truck resolves
// its own) — only warn when NO truck matched a rule.
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
const saveTimes = useMutation({ const saveTimes = useMutation({
mutationFn: () => mutationFn: () =>
lastMileService.update(id as string, { perTruck
arrivedAt: arrived ? arrived.toISOString() : null, ? lastMileService.setDetentionTimes(
deliveredAt: delivered ? delivered.toISOString() : null, id as string,
}), rows.map((r) => ({
vehicleId: r.vehicleId,
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
returnedAt: r.returned ? r.returned.toISOString() : null,
})),
)
: lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void previewQuery.refetch(); void previewQuery.refetch();
toast({ title: 'Detention times saved' }); toast({ title: 'Detention times saved' });
}, },
onError: () => toast({ title: 'Save failed', variant: 'destructive' }), onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: 'Save failed', description, variant: 'destructive' });
},
}); });
const generate = useMutation({ const generate = useMutation({
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
}, },
}); });
const handleSave = () => {
const values = perTruck
? rows.flatMap((r) => [r.arrived, r.returned])
: [arrived, delivered];
// No backdating: detention times are recorded as they happen.
if (values.some((v) => isBackdated(v))) {
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
return;
}
const reversed = perTruck
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
: arrived && delivered && delivered < arrived
? { label: 'this delivery' }
: undefined;
if (reversed) {
toast({
variant: 'destructive',
title: 'Return time is before arrival',
description: `Check the times for ${reversed.label}.`,
});
return;
}
saveTimes.mutate();
};
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
return ( return (
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
centered centered
size="lg" size="xl"
title={ title={
<Text fw={700}> <Text fw={700}>
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
} }
> >
<Stack gap="md"> <Stack gap="md">
<Group grow align="flex-start"> {perTruck ? (
<DateTimePicker <Stack gap="xs">
label="Arrived at" <Text size="sm" c="dimmed">
description="Detention clock start" Each truck has its own detention clock record when it reached the destination and
value={arrived} when it was released. Days and charges are calculated per truck.
onChange={(v) => setArrived(v ? new Date(v) : null)} </Text>
minDate={new Date()} {rows.map((r) => {
clearable const g = byVehicle.get(r.vehicleId);
/> return (
<DateTimePicker <Paper key={r.vehicleId} withBorder p="sm" radius="md">
label="Delivered / returned at" <Group justify="space-between" mb={6} wrap="nowrap">
description="Clock end (blank = still out)" <Group gap={8}>
value={delivered} <Text size="sm" fw={600}>
onChange={(v) => setDelivered(v ? new Date(v) : null)} {r.label}
minDate={new Date()} </Text>
clearable {g?.vehicleType && (
/> <Badge size="xs" variant="light" color="gray">
</Group> {g.vehicleType}
</Badge>
)}
</Group>
{g && (
<Group gap={10} wrap="nowrap">
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
{g.endIsOpen ? ' · still out' : ''}
</Text>
<Text size="sm" fw={700}>
{money(g.amount, preview?.currency ?? 'USD')}
</Text>
</Group>
)}
</Group>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at destination"
description="Detention clock start"
value={r.arrived}
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Released / returned at"
description="Clock end (blank = still out)"
value={r.returned}
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
minDate={new Date()}
clearable
/>
</Group>
{g && !g.ruleId && (
<Text size="xs" c="red" mt={4}>
No detention rule matches this truck type it will not be billed.
</Text>
)}
</Paper>
);
})}
</Stack>
) : (
<>
<Text size="sm" c="dimmed">
No trucks assigned yet this records the delivery-level detention window. Assign
trucks to track each one separately.
</Text>
<Group grow align="flex-start">
<DateTimePicker
label="Arrived at"
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<DateTimePicker
label="Delivered / returned at"
description="Clock end (blank = still out)"
value={delivered}
onChange={(v) => setDelivered(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
</Group>
</>
)}
<Group justify="flex-end"> <Group justify="flex-end">
<Button <Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
variant="light"
loading={saveTimes.isPending}
onClick={() => {
// No backdating: detention times are recorded as they happen.
if (isBackdated(arrived) || isBackdated(delivered)) {
toast({
variant: 'destructive',
title: 'Detention times cannot be in the past',
});
return;
}
saveTimes.mutate();
}}
>
Save times Save times
</Button> </Button>
</Group> </Group>
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
<Alert color="gray" variant="light"> <Alert color="gray" variant="light">
No preview available. No preview available.
</Alert> </Alert>
) : !preview.ruleId ? ( ) : !hasAnyRule ? (
<Alert color="orange" variant="light"> <Alert color="orange" variant="light">
No active Truck Detention rule matches this booking. Create one under Warehouse Fee rules No active Truck Detention rule matches this booking. Create one under Warehouse Fee rules
(rule type "Truck Detention Cost"). (rule type "Truck Detention Cost").
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
<Group grow> <Group grow>
<Stat label="Chargeable days" value={preview.chargeableDays} /> <Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
<Stat label="Trucks" value={preview.containerCount} /> <Stat label="Trucks" value={preview.containerCount} />
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong /> <Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
</Group> </Group>
{preview.endIsOpen && ( {preview.endIsOpen && (
<Text size="xs" c="orange"> <Text size="xs" c="orange">
Still accruing no delivery/return time yet. The amount grows until the vehicle is returned. Still accruing at least one truck has no release time yet. The amount grows until
every truck is returned.
</Text> </Text>
)} )}
{preview.groups && preview.groups.length > 1 ? ( {preview.groups && preview.groups.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs"> <Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Truck type</Table.Th> <Table.Th>Truck</Table.Th>
<Table.Th>Trucks</Table.Th> <Table.Th>Type</Table.Th>
<Table.Th>Days</Table.Th> <Table.Th>Days</Table.Th>
<Table.Th ta="right">Rate / truck / day</Table.Th> <Table.Th ta="right">Rate / day</Table.Th>
<Table.Th ta="right">Amount</Table.Th> <Table.Th ta="right">Amount</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{preview.groups.map((g, i) => ( {preview.groups.map((g, i) => (
<Table.Tr key={i}> <Table.Tr key={g.assignmentId ?? i}>
<Table.Td> <Table.Td>
{g.vehicleType ?? 'Unknown'} {g.plateNumber ?? 'Unassigned'}
{!g.ruleId && ( {!g.ruleId && (
<Text span size="xs" c="red"> <Text span size="xs" c="red">
{' '}· no rule {' '}· no rule
</Text> </Text>
)} )}
</Table.Td> </Table.Td>
<Table.Td>{g.truckCount}</Table.Td> <Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td> <Table.Td>
{g.chargeableDays}
{g.endIsOpen && (
<Text span size="xs" c="orange">
{' '}· open
</Text>
)}
</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td> <Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td> <Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr> </Table.Tr>

View File

@@ -75,6 +75,9 @@ export interface LastMileRecord {
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */ /** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
arrivedAt?: string | null; arrivedAt?: string | null;
departedAt?: string | null; departedAt?: string | null;
/** This truck's own detention window (destination arrival → released). */
destinationArrivedAt?: string | null;
returnedAt?: string | null;
grossWeightTons?: number | null; grossWeightTons?: number | null;
netWeightTons?: number | null; netWeightTons?: number | null;
vehicle?: LastMileVehicle | null; vehicle?: LastMileVehicle | null;
@@ -143,4 +146,13 @@ export const lastMileService = {
/** Preview the truck-detention charge for a last-mile leg. */ /** Preview the truck-detention charge for a last-mile leg. */
truckDetentionPreview: (id: string) => truckDetentionPreview: (id: string) =>
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`), api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
/** Per-truck detention windows — each truck has its own clock. */
setDetentionTimes: (
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
}; };

View File

@@ -856,10 +856,16 @@ export interface FeePreview {
billableUnits: number; billableUnits: number;
amount: number; amount: number;
tiers?: FeePreviewTier[]; tiers?: FeePreviewTier[];
/** Truck detention: per-vehicle-type breakdown. */ /** Truck detention: one row per truck — each has its own window and rule. */
groups?: Array<{ groups?: Array<{
assignmentId?: string | null;
vehicleId?: string | null;
plateNumber?: string | null;
vehicleType: string | null; vehicleType: string | null;
truckCount: number; truckCount: number;
startDate?: string | null;
endDate?: string | null;
endIsOpen?: boolean;
chargeableDays: number; chargeableDays: number;
ratePerDay: number; ratePerDay: number;
amount: number; amount: number;