mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user