mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Truck detention on lastmile
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Truck detention support.
|
||||
* - last_mile.arrived_at / delivered_at: the detention window for an EDR
|
||||
* last-mile vehicle. The clock runs from arrival at destination; the customer
|
||||
* has a grace period (default 3h) to clear/return, after which detention
|
||||
* accrues per truck per day until delivered_at (or now, if still out).
|
||||
* - warehouse_fee_rules.free_hours: configurable grace window (hours) for a
|
||||
* TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default.
|
||||
*/
|
||||
export class AddTruckDetentionTiming2000000000000 implements MigrationInterface {
|
||||
name = 'AddTruckDetentionTiming2000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
import { CreateLastMileDto } from './create-last-mile.dto';
|
||||
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
|
||||
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
arrivedAt?: string;
|
||||
|
||||
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
deliveredAt?: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
|
||||
status!: LastMileStatus;
|
||||
|
||||
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
|
||||
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
|
||||
// between them beyond the rule's grace hours (default 3h), per truck per day.
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
advancedPayment!: number;
|
||||
|
||||
|
||||
@@ -282,6 +282,17 @@ export class LastMileService {
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
// Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
|
||||
// delivery when it reaches DELIVERED (first time only). Explicit dto values
|
||||
// below override the auto-stamp so staff can record the real times.
|
||||
...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
|
||||
? { arrivedAt: new Date() }
|
||||
: {}),
|
||||
...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
|
||||
? { deliveredAt: new Date() }
|
||||
: {}),
|
||||
...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
|
||||
...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
|
||||
} as any);
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -87,6 +87,12 @@ export class CreateFeeRuleDto {
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
freeHours?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: FEE_RULE_BASES,
|
||||
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
|
||||
|
||||
@@ -71,6 +71,11 @@ export class WarehouseFeeRule extends BaseEntity {
|
||||
@Column({ name: 'free_days', type: 'int', default: 0 })
|
||||
freeDays!: number;
|
||||
|
||||
// Truck detention only: grace window in HOURS before detention accrues
|
||||
// (contract default 3h). Null/0 → the 3-hour default.
|
||||
@Column({ name: 'free_hours', type: 'int', nullable: true })
|
||||
freeHours?: number | null;
|
||||
|
||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
ratePerDay!: number;
|
||||
|
||||
|
||||
@@ -407,12 +407,9 @@ export class WarehouseFeeService {
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
const byType: FeeRuleType[] = [
|
||||
'DEMURRAGE_FEE',
|
||||
'STORAGE_FEE',
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
'TRUCK_DETENTION_FEE',
|
||||
];
|
||||
// Truck detention is a per-truck last-mile charge, not a per-inventory fee —
|
||||
// it is computed separately via previewTruckDetention(), not here.
|
||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE'];
|
||||
return Promise.all(
|
||||
byType.map((type) =>
|
||||
this.compute(
|
||||
@@ -425,4 +422,111 @@ export class WarehouseFeeService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truck detention preview for an EDR last-mile leg. The vehicle should be
|
||||
* returned within the rule's grace window (default 3h) of arriving; beyond
|
||||
* that, detention accrues per truck per day (flat rate/day or progressive
|
||||
* tiers by detention day) until it is delivered/returned (or now, if open).
|
||||
*/
|
||||
async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT lm.arrived_at AS "arrivedAt",
|
||||
lm.delivered_at AS "deliveredAt",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
(SELECT count(*) FROM freight.last_mile_vehicle_assignments va
|
||||
WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount"
|
||||
FROM freight.last_mile lm
|
||||
LEFT JOIN freight.bookings b ON b.id = lm.booking_id
|
||||
WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
|
||||
[lastMileId],
|
||||
);
|
||||
if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||||
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
gateClearedAt: null,
|
||||
releaseDate: null,
|
||||
freightType: row.freightType ?? null,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
cargoTypeCode: null,
|
||||
containerTypeCode: null,
|
||||
inventoryQuantity: 1,
|
||||
bookingContainerCount: 1,
|
||||
cargoQuantity: 0,
|
||||
facilityId: null,
|
||||
warehouseId: null,
|
||||
yardId: null,
|
||||
zoneId: null,
|
||||
};
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const rule = this.bestRule(
|
||||
rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'),
|
||||
item,
|
||||
);
|
||||
return this.computeTruckDetention(rule, row, new Date(), billingCurrency);
|
||||
}
|
||||
|
||||
private async computeTruckDetention(
|
||||
rule: WarehouseFeeRule | null,
|
||||
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string },
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
): Promise<FeePreview> {
|
||||
const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3;
|
||||
const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1));
|
||||
const start = row.arrivedAt ? new Date(row.arrivedAt) : null;
|
||||
const end = row.deliveredAt ? new Date(row.deliveredAt) : now;
|
||||
const endIsOpen = !row.deliveredAt;
|
||||
|
||||
let chargeableDays = 0;
|
||||
if (start) {
|
||||
const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000;
|
||||
chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0;
|
||||
}
|
||||
|
||||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
const hasTiers = Boolean(rule?.tiers?.length);
|
||||
const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount);
|
||||
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount;
|
||||
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||||
const convertedRatePerDay = ruleCurrency
|
||||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||||
: 0;
|
||||
const convertedTiers = ruleCurrency
|
||||
? await Promise.all(
|
||||
tiered.tiers.map(async (tier) => ({
|
||||
...tier,
|
||||
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
||||
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
ruleType: 'TRUCK_DETENTION_FEE',
|
||||
basis: null,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays: 0,
|
||||
ratePerDay: convertedRatePerDay,
|
||||
currency: targetCurrency,
|
||||
ruleCurrency,
|
||||
billingCurrency: targetCurrency,
|
||||
startDate: start ? start.toISOString() : null,
|
||||
endDate: end.toISOString(),
|
||||
endIsOpen,
|
||||
elapsedDays: chargeableDays,
|
||||
chargeableDays,
|
||||
containerCount: truckCount, // reused as the per-truck count
|
||||
billableUnits,
|
||||
amount,
|
||||
tiers: hasTiers ? convertedTiers : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ export class WarehouseInvoiceController {
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
@Post('last-mile/:id/generate-truck-detention-invoice')
|
||||
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
|
||||
generateTruckDetention(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: GenerateInvoiceDto,
|
||||
) {
|
||||
return this.invoiceService.generateTruckDetentionInvoice(id, dto);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-invoices')
|
||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -269,6 +269,79 @@ export class WarehouseInvoiceService {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees
|
||||
* (per inventory item), detention is a per-truck charge on the last-mile leg, so
|
||||
* it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept
|
||||
* separate from the delivery-fee invoice. Returns the global Invoice.
|
||||
*/
|
||||
async generateTruckDetentionInvoice(
|
||||
lastMileId: string,
|
||||
opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {},
|
||||
): Promise<Invoice> {
|
||||
const [lm] = await this.dataSource.query(
|
||||
`SELECT lm.id,
|
||||
b.company_id AS "companyId",
|
||||
b.company_profile_id AS "companyProfileId",
|
||||
b.payment_currency AS "paymentCurrency"
|
||||
FROM freight.last_mile lm
|
||||
LEFT JOIN freight.bookings b ON b.id = lm.booking_id
|
||||
WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
|
||||
[lastMileId],
|
||||
);
|
||||
if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||||
if (!lm.companyId) {
|
||||
throw new BadRequestException(
|
||||
"Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.billing.findPayable(
|
||||
"last_mile" as Freight.InvoiceSource,
|
||||
lastMileId,
|
||||
"TRUCK_DETENTION_FEE",
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
"An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
const billingCurrency: "ETB" | "USD" =
|
||||
opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD");
|
||||
const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency);
|
||||
if (preview.amount <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException(
|
||||
"No truck detention is currently payable for this last-mile leg.",
|
||||
);
|
||||
}
|
||||
|
||||
const truckCount = preview.containerCount; // reused as the per-truck count
|
||||
const line: InvoiceLineInput = {
|
||||
chargeType: "TRUCK_DETENTION",
|
||||
description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`,
|
||||
quantity: preview.billableUnits,
|
||||
unitRate: preview.ratePerDay,
|
||||
amount: preview.amount,
|
||||
currency: preview.currency,
|
||||
metadata: {
|
||||
feeRuleId: preview.ruleId ?? null,
|
||||
chargeableDays: preview.chargeableDays ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return this.billing.generateInvoice({
|
||||
source: "last_mile" as Freight.InvoiceSource,
|
||||
sourceId: lastMileId,
|
||||
type: "TRUCK_DETENTION_FEE",
|
||||
companyId: lm.companyId,
|
||||
companyProfileId: lm.companyProfileId || "",
|
||||
currency: billingCurrency,
|
||||
lines: [line],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
|
||||
@@ -85,4 +85,13 @@ export class WarehouseRulesController {
|
||||
) {
|
||||
return this.feeService.previewForInventory(id, billingCurrency);
|
||||
}
|
||||
|
||||
@Get('last-mile/:id/truck-detention-preview')
|
||||
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
|
||||
truckDetentionPreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('billingCurrency') billingCurrency?: string,
|
||||
) {
|
||||
return this.feeService.previewTruckDetention(id, billingCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker } from '@mantine/dates';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface TruckDetentionModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: LastMileRecord | null;
|
||||
}
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
function Stat({ label, value, strong }: { label: string; value: React.ReactNode; strong?: boolean }) {
|
||||
return (
|
||||
<Paper withBorder p="sm" radius="md" style={{ flex: 1, minWidth: 120 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size={strong ? 'lg' : 'md'} fw={strong ? 800 : 600}>
|
||||
{value}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const [arrived, setArrived] = useState<Date | null>(null);
|
||||
const [delivered, setDelivered] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
||||
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['truck-detention-preview', id],
|
||||
queryFn: async () => (await lastMileService.truckDetentionPreview(id as string)).data,
|
||||
enabled: opened && Boolean(id),
|
||||
});
|
||||
const preview = previewQuery.data;
|
||||
|
||||
const saveTimes = useMutation({
|
||||
mutationFn: () =>
|
||||
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' }),
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () => lastMileService.generateTruckDetentionInvoice(id as string),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
toast({ title: 'Truck detention invoice generated' });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: 'Detention invoice failed', description, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
title={
|
||||
<Text fw={700}>
|
||||
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<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)}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label="Detention preview" labelPosition="left" />
|
||||
|
||||
{previewQuery.isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : !preview ? (
|
||||
<Alert color="gray" variant="light">
|
||||
No preview available.
|
||||
</Alert>
|
||||
) : !preview.ruleId ? (
|
||||
<Alert color="orange" variant="light">
|
||||
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
||||
(rule type "Truck Detention Cost").
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<Stat label="Chargeable days" value={preview.chargeableDays} />
|
||||
<Stat label="Trucks" value={preview.containerCount} />
|
||||
<Stat label="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.
|
||||
</Text>
|
||||
)}
|
||||
{preview.tiers && preview.tiers.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>From day</Table.Th>
|
||||
<Table.Th>To day</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.tiers.map((t, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{t.appliedFromDay}</Table.Td>
|
||||
<Table.Td>{t.appliedToDay}</Table.Td>
|
||||
<Table.Td>{t.days}</Table.Td>
|
||||
<Table.Td ta="right">{money(t.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(t.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Flat {money(preview.ratePerDay, preview.currency)} per truck per day after the grace window.
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="gray">
|
||||
{preview.ruleName ?? 'Detention rule'}
|
||||
</Badge>
|
||||
{preview.billableUnits > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{preview.billableUnits} billable truck-day(s)
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Receipt size={16} />}
|
||||
disabled={!preview || preview.amount <= 0}
|
||||
loading={generate.isPending}
|
||||
onClick={() => generate.mutate()}
|
||||
>
|
||||
Generate invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
|
||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||
@@ -544,6 +545,7 @@ const LastMilePage = () => {
|
||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
@@ -1360,6 +1362,16 @@ const LastMilePage = () => {
|
||||
>
|
||||
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
||||
</Menu.Item>
|
||||
{/* Truck detention: set/adjust arrival & return times, preview the
|
||||
per-truck-per-day charge, and generate its invoice. Available
|
||||
once the vehicle is en route/delivered (clock has a start). */}
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={15} />}
|
||||
disabled={!pastTransit}
|
||||
onClick={() => setDetentionRecord(row.original)}
|
||||
>
|
||||
Truck detention
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
@@ -2019,6 +2031,12 @@ const LastMilePage = () => {
|
||||
item={releaseItem}
|
||||
truckPrefill={releaseTruckPrefill}
|
||||
/>
|
||||
|
||||
<TruckDetentionModal
|
||||
opened={Boolean(detentionRecord)}
|
||||
onClose={() => setDetentionRecord(null)}
|
||||
record={detentionRecord}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -373,6 +373,7 @@ function FeeRules() {
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
currency: 'USD',
|
||||
@@ -385,6 +386,9 @@ function FeeRules() {
|
||||
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
||||
// no free days, no progressive tiers.
|
||||
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
|
||||
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
||||
// with day tiers. Uses "free hours" instead of "free days".
|
||||
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
@@ -396,6 +400,7 @@ function FeeRules() {
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
@@ -459,11 +464,12 @@ function FeeRules() {
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
// Double handling: flat basis × rate — no free days, no tiers.
|
||||
freeDays: isDoubleHandling ? 0 : form.freeDays,
|
||||
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||
freeDays: isDoubleHandling || isTruckDetention ? 0 : form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
@@ -668,6 +674,14 @@ function FeeRules() {
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
) : isTruckDetention ? (
|
||||
<NumberInput
|
||||
label="Free hours"
|
||||
description="Grace before detention accrues"
|
||||
min={0}
|
||||
value={form.freeHours}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeHours: numberValue(value) }))}
|
||||
/>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type { FeePreview } from '@/types/warehouse';
|
||||
|
||||
export const LAST_MILE_STATUSES = [
|
||||
'PAYMENT_PENDING',
|
||||
@@ -70,6 +71,9 @@ export interface LastMileRecord {
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
invoice?: { id: string; number: string; status: string } | null;
|
||||
/** Truck-detention clock: vehicle arrival + delivery/return times. */
|
||||
arrivedAt?: string | null;
|
||||
deliveredAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -85,7 +89,7 @@ export const lastMileService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) =>
|
||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean; arrivedAt?: string | null; deliveredAt?: string | null }) =>
|
||||
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
@@ -102,4 +106,12 @@ export const lastMileService = {
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||
generateInvoice: (id: string) =>
|
||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
||||
/** Generate a truck-detention invoice (per truck per day after the grace window). */
|
||||
generateTruckDetentionInvoice: (id: string) =>
|
||||
api.post<{ id: string; invoiceNumber?: string } | null>(
|
||||
`${LM.BASE}/${id}/generate-truck-detention-invoice`,
|
||||
),
|
||||
/** Preview the truck-detention charge for a last-mile leg. */
|
||||
truckDetentionPreview: (id: string) =>
|
||||
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
||||
};
|
||||
|
||||
@@ -779,6 +779,8 @@ export interface FeeRule {
|
||||
yardId?: string | null;
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
/** Truck detention only: grace window in hours (default 3). */
|
||||
freeHours?: number | null;
|
||||
ratePerDay: number;
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
|
||||
Reference in New Issue
Block a user