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 })
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). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
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 { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -131,6 +132,18 @@ export class LastMileController {
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')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -869,6 +869,46 @@ export class LastMileService {
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* 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(
id: string,
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,
bookingContainerCount: 0,
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,
warehouseId: null,
yardId: null,

View File

@@ -92,10 +92,20 @@ export interface FeePreview {
ratePerDay: 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<{
assignmentId: string | null;
vehicleId: string | null;
plateNumber: string | null;
vehicleType: string | null;
truckCount: number;
startDate: string | null;
endDate: string | null;
endIsOpen: boolean;
chargeableDays: number;
ratePerDay: number;
amount: number;
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
count(*)::int AS "truckCount"
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
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
// One row PER TRUCK: each truck has its own detention window (it reaches the
// destination and is released at its own time) and resolves its own rule by
// CANONICAL truck type — the truck_types FK is the source of truth, with the
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
// legs recorded before per-truck tracking.
const truckRows: Array<{
assignmentId: string;
vehicleId: string;
plateNumber: string | null;
vehicleType: string | null;
startAt: Date | string | null;
endAt: Date | string | null;
}> = await this.dataSource.query(
`SELECT va.id AS "assignmentId",
va.vehicle_id AS "vehicleId",
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
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 detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
vehicleType: t.vehicleType ?? null,
inventoryQuantity: 1,
inventoryWeight: 0,
bookingContainerCount: 1,
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
// truckCount 1 — this row IS one truck.
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
now,
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 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 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 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 {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
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,
ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
endDate: (anyOpen ? now : new Date(Math.max(
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
))).toISOString(),
endIsOpen: anyOpen,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: totalTrucks,
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
assignmentId: x.assignmentId,
vehicleId: x.vehicleId,
plateNumber: x.plateNumber,
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,
ratePerDay: x.c.ratePerDay,
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
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
* Detention is PER TRUCK: every truck reaches the destination and is released at
* 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) {
const { toast } = useToast();
const qc = useQueryClient();
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 [delivered, setDelivered] = useState<Date | null>(null);
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);
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({
queryKey: ['truck-detention-preview', id],
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
enabled: opened && Boolean(id),
});
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({
mutationFn: () =>
lastMileService.update(id as string, {
arrivedAt: arrived ? arrived.toISOString() : null,
deliveredAt: delivered ? delivered.toISOString() : null,
}),
perTruck
? lastMileService.setDetentionTimes(
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: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void previewQuery.refetch();
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({
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
size="xl"
title={
<Text fw={700}>
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
}
>
<Stack gap="md">
<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>
{perTruck ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
Each truck has its own detention clock record when it reached the destination and
when it was released. Days and charges are calculated per truck.
</Text>
{rows.map((r) => {
const g = byVehicle.get(r.vehicleId);
return (
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8}>
<Text size="sm" fw={600}>
{r.label}
</Text>
{g?.vehicleType && (
<Badge size="xs" variant="light" color="gray">
{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">
<Button
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();
}}
>
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
Save times
</Button>
</Group>
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
<Alert color="gray" variant="light">
No preview available.
</Alert>
) : !preview.ruleId ? (
) : !hasAnyRule ? (
<Alert color="orange" variant="light">
No active Truck Detention rule matches this booking. Create one under Warehouse Fee rules
(rule type "Truck Detention Cost").
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
) : (
<Stack gap="sm">
<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="Amount" value={money(preview.amount, preview.currency)} strong />
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
</Group>
{preview.endIsOpen && (
<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>
)}
{preview.groups && preview.groups.length > 1 ? (
{preview.groups && preview.groups.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Type</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.Tr>
</Table.Thead>
<Table.Tbody>
{preview.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Tr key={g.assignmentId ?? i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{g.plateNumber ?? 'Unassigned'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td>{g.vehicleType ?? 'Unknown'}</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.amount, preview.currency)}</Table.Td>
</Table.Tr>

View File

@@ -75,6 +75,9 @@ export interface LastMileRecord {
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
arrivedAt?: string | null;
departedAt?: string | null;
/** This truck's own detention window (destination arrival → released). */
destinationArrivedAt?: string | null;
returnedAt?: string | null;
grossWeightTons?: number | null;
netWeightTons?: number | null;
vehicle?: LastMileVehicle | null;
@@ -143,4 +146,13 @@ export const lastMileService = {
/** Preview the truck-detention charge for a last-mile leg. */
truckDetentionPreview: (id: string) =>
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;
amount: number;
tiers?: FeePreviewTier[];
/** Truck detention: per-vehicle-type breakdown. */
/** Truck detention: one row per truck — each has its own window and rule. */
groups?: Array<{
assignmentId?: string | null;
vehicleId?: string | null;
plateNumber?: string | null;
vehicleType: string | null;
truckCount: number;
startDate?: string | null;
endDate?: string | null;
endIsOpen?: boolean;
chargeableDays: number;
ratePerDay: number;
amount: number;