mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +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';
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
|
||||||
status!: LastMileStatus;
|
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 })
|
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
advancedPayment!: number;
|
advancedPayment!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,17 @@ export class LastMileService {
|
|||||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
...(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);
|
} as any);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ export class CreateFeeRuleDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
ratePerDay!: number;
|
ratePerDay!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
freeHours?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: FEE_RULE_BASES,
|
enum: FEE_RULE_BASES,
|
||||||
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
|
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 })
|
@Column({ name: 'free_days', type: 'int', default: 0 })
|
||||||
freeDays!: number;
|
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 })
|
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
ratePerDay!: number;
|
ratePerDay!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -407,12 +407,9 @@ export class WarehouseFeeService {
|
|||||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const byType: FeeRuleType[] = [
|
// Truck detention is a per-truck last-mile charge, not a per-inventory fee —
|
||||||
'DEMURRAGE_FEE',
|
// it is computed separately via previewTruckDetention(), not here.
|
||||||
'STORAGE_FEE',
|
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE'];
|
||||||
'DOUBLE_HANDLING_FEE',
|
|
||||||
'TRUCK_DETENTION_FEE',
|
|
||||||
];
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
byType.map((type) =>
|
byType.map((type) =>
|
||||||
this.compute(
|
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);
|
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')
|
@Get('warehouse-inventory/:id/fee-invoices')
|
||||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -269,6 +269,79 @@ export class WarehouseInvoiceService {
|
|||||||
return detail;
|
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 ────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────
|
||||||
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||||
const invoice = await this.loadWarehouseInvoice(id);
|
const invoice = await this.loadWarehouseInvoice(id);
|
||||||
|
|||||||
@@ -85,4 +85,13 @@ export class WarehouseRulesController {
|
|||||||
) {
|
) {
|
||||||
return this.feeService.previewForInventory(id, billingCurrency);
|
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 { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ratesService } from "@/services/rates.service";
|
import { ratesService } from "@/services/rates.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
|
|
||||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||||
@@ -544,6 +545,7 @@ const LastMilePage = () => {
|
|||||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
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.
|
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||||
const [vehicleRows, setVehicleRows] = useState<
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||||
@@ -1360,6 +1362,16 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
||||||
</Menu.Item>
|
</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 && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -2019,6 +2031,12 @@ const LastMilePage = () => {
|
|||||||
item={releaseItem}
|
item={releaseItem}
|
||||||
truckPrefill={releaseTruckPrefill}
|
truckPrefill={releaseTruckPrefill}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TruckDetentionModal
|
||||||
|
opened={Boolean(detentionRecord)}
|
||||||
|
onClose={() => setDetentionRecord(null)}
|
||||||
|
record={detentionRecord}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ function FeeRules() {
|
|||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
containerType: '',
|
containerType: '',
|
||||||
freeDays: 3,
|
freeDays: 3,
|
||||||
|
freeHours: 3,
|
||||||
ratePerDay: 0,
|
ratePerDay: 0,
|
||||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
@@ -385,6 +386,9 @@ function FeeRules() {
|
|||||||
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
||||||
// no free days, no progressive tiers.
|
// no free days, no progressive tiers.
|
||||||
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
|
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 = () =>
|
const resetForm = () =>
|
||||||
setForm({
|
setForm({
|
||||||
@@ -396,6 +400,7 @@ function FeeRules() {
|
|||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
containerType: '',
|
containerType: '',
|
||||||
freeDays: 3,
|
freeDays: 3,
|
||||||
|
freeHours: 3,
|
||||||
ratePerDay: 0,
|
ratePerDay: 0,
|
||||||
tiers: [],
|
tiers: [],
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
@@ -459,11 +464,12 @@ function FeeRules() {
|
|||||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||||
// Double handling: flat basis × rate — no free days, no tiers.
|
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||||
freeDays: isDoubleHandling ? 0 : form.freeDays,
|
freeDays: isDoubleHandling || isTruckDetention ? 0 : form.freeDays,
|
||||||
ratePerDay: form.ratePerDay,
|
ratePerDay: form.ratePerDay,
|
||||||
currency: form.currency || 'USD',
|
currency: form.currency || 'USD',
|
||||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||||
|
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
|
||||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -668,6 +674,14 @@ function FeeRules() {
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
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
|
<NumberInput
|
||||||
label="Free days"
|
label="Free days"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '../auth/http';
|
import { api } from '../auth/http';
|
||||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
import type { FeePreview } from '@/types/warehouse';
|
||||||
|
|
||||||
export const LAST_MILE_STATUSES = [
|
export const LAST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -70,6 +71,9 @@ export interface LastMileRecord {
|
|||||||
}>;
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
invoice?: { id: string; number: string; status: string } | null;
|
invoice?: { id: string; number: string; status: string } | null;
|
||||||
|
/** Truck-detention clock: vehicle arrival + delivery/return times. */
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
deliveredAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -85,7 +89,7 @@ export const lastMileService = {
|
|||||||
list: (pageSize = 1000) =>
|
list: (pageSize = 1000) =>
|
||||||
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||||
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
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),
|
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||||
accept: (bookingReference: string) =>
|
accept: (bookingReference: string) =>
|
||||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||||
@@ -102,4 +106,12 @@ export const lastMileService = {
|
|||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||||
generateInvoice: (id: string) =>
|
generateInvoice: (id: string) =>
|
||||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
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;
|
yardId?: string | null;
|
||||||
zoneId?: string | null;
|
zoneId?: string | null;
|
||||||
freeDays: number;
|
freeDays: number;
|
||||||
|
/** Truck detention only: grace window in hours (default 3). */
|
||||||
|
freeHours?: number | null;
|
||||||
ratePerDay: number;
|
ratePerDay: number;
|
||||||
tiers?: FeeRuleTier[];
|
tiers?: FeeRuleTier[];
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user