From 392038852e98a48a961b76dd2d1e4ceb1f0d4cf6 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 7 Jul 2026 08:45:51 +0000 Subject: [PATCH] Truck detention on lastmile --- .../2000000000000-AddTruckDetentionTiming.ts | 32 +++ .../last-mile/dto/update-last-mile.dto.ts | 17 +- .../last-mile/entities/last-mile.entity.ts | 9 + .../modules/last-mile/last-mile.service.ts | 11 + .../modules/warehouses/dto/fee-rule.dto.ts | 6 + .../entities/warehouse-fee-rule.entity.ts | 5 + .../warehouses/warehouse-fee.service.ts | 116 +++++++++- .../warehouse-invoice.controller.ts | 9 + .../warehouses/warehouse-invoice.service.ts | 73 ++++++ .../warehouses/warehouse-rules.controller.ts | 9 + .../operations/TruckDetentionModal.tsx | 215 ++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 18 ++ .../pages/warehouses/WarehouseRulesPage.tsx | 18 +- .../src/services/last-mile.service.ts | 14 +- .../backoffice/src/types/warehouse.ts | 2 + 15 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts new file mode 100644 index 000000000..ce7bfd326 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts index 9d0c4262d..405273a54 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1f8bda8fc..5dca86cc4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 22f25a6aa..9c6433b11 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 1213b1177..680eb2591 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -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.', diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index a8bc7bf23..f6ad7ec8f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 16eca7849..c322482b2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { + 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 { + 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 : [], + }; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 8b16e3bec..a1c5db837 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index b7cd3628e..4683b3bda 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -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 { + 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 { const invoice = await this.loadWarehouseInvoice(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 715080612..d35587d9e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -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); + } } diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx new file mode 100644 index 000000000..1a0d96f96 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -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 ( + + + {label} + + + {value} + + + ); +} + +/** + * 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(null); + const [delivered, setDelivered] = useState(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 ( + + Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} + + } + > + + + setArrived(v ? new Date(v) : null)} + clearable + /> + setDelivered(v ? new Date(v) : null)} + clearable + /> + + + + + + + + {previewQuery.isLoading ? ( + + + + ) : !preview ? ( + + No preview available. + + ) : !preview.ruleId ? ( + + No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules + (rule type "Truck Detention Cost"). + + ) : ( + + + + + + + {preview.endIsOpen && ( + + Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. + + )} + {preview.tiers && preview.tiers.length > 0 ? ( + + + + From day + To day + Days + Rate / truck / day + Amount + + + + {preview.tiers.map((t, i) => ( + + {t.appliedFromDay} + {t.appliedToDay} + {t.days} + {money(t.ratePerDay, preview.currency)} + {money(t.amount, preview.currency)} + + ))} + +
+ ) : ( + + Flat {money(preview.ratePerDay, preview.currency)} per truck per day after the grace window. + + )} + + + {preview.ruleName ?? 'Detention rule'} + + {preview.billableUnits > 0 && ( + + {preview.billableUnits} billable truck-day(s) + + )} + +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 4b5cec080..c136e67db 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -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(null); const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); + const [detentionRecord, setDetentionRecord] = useState(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"} + {/* 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). */} + } + disabled={!pastTransit} + onClick={() => setDetentionRecord(row.original)} + > + Truck detention + {canPrint && ( } @@ -2019,6 +2031,12 @@ const LastMilePage = () => { item={releaseItem} truckPrefill={releaseTruckPrefill} /> + + setDetentionRecord(null)} + record={detentionRecord} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 458e71de6..0e91407dc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -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 ? ( + setForm((f) => ({ ...f, freeHours: numberValue(value) }))} + /> ) : ( ; /** 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(`${LM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(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(LM.BY_ID(id), data), accept: (bookingReference: string) => api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), @@ -102,4 +106,12 @@ export const lastMileService = { ) => api.post(`${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(`${LM.BASE}/${id}/truck-detention-preview`), }; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 70c68aa11..9ac2c2efd 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -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;