Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-07 09:09:10 +00:00
25 changed files with 767 additions and 37 deletions

View File

@@ -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`);
}
}

View File

@@ -250,6 +250,44 @@ export class ContractTransitionService {
return updated;
}
/**
* Reject one approval step (line staff / director / CEO). The rejecting
* approver must supply a reason. A rejection is terminal: the whole contract
* moves to REJECTED and the customer must create a new one — there is no
* resubmit of the same contract. The reason is recorded both on the step and
* as a REJECTION review note so it is visible to the customer and the rest of
* the approval chain.
*/
async rejectStep(
contractId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step) throw new BadRequestException('Approval step not found');
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'REJECTION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.rejected(updated, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,

View File

@@ -60,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto';
import {
ApproveStepDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -390,6 +391,27 @@ export class ContractsController {
);
}
@Post(':id/approval-steps/:stepId/reject')
@BookingStaff([
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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.',

View File

@@ -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;

View File

@@ -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 : [],
};
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -8,6 +8,7 @@ import {
Button,
Box,
Modal,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -30,6 +31,10 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectReason, setRejectReason] = useState("");
const steps = useMemo(
() =>
@@ -60,6 +65,28 @@ export function ContractApprovalStepsCard({
);
};
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectOpen(true);
};
const closeReject = () => {
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
};
const trimmedReason = rejectReason.trim();
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{ stepId: rejectStepRow.id, reason: trimmedReason },
{ onSuccess: () => closeReject() },
);
};
const subtitle =
summary.detail ||
(nextPending
@@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
}
onApprove={() => openApprove(step)}
onReject={() => openReject(step)}
/>
))}
</Stack>
@@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}
onClose={closeReject}
title="Reject this step?"
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
withAsterisk
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
@@ -158,11 +237,13 @@ function StepRow({
isNext,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
}) {
const statusColor =
step.status === "APPROVED"
@@ -222,15 +303,27 @@ function StepRow({
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={onApprove}
>
Approve
</Button>
<>
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={onApprove}
>
Approve
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<X size={14} />}
disabled={isPending}
onClick={onReject}
>
Reject
</Button>
</>
)}
<Badge
variant="light"

View File

@@ -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>
);
}

View File

@@ -162,6 +162,8 @@ export const URL_CONSTANTS = {
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/reject`,
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,

View File

@@ -181,6 +181,15 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to approve step"),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
@@ -210,6 +219,7 @@ export function useContractMutations(contractId: string) {
requestChanges.isPending ||
reject.isPending ||
approveStep.isPending ||
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
createBooking.isPending;
@@ -219,6 +229,7 @@ export function useContractMutations(contractId: string) {
requestChanges,
reject,
approveStep,
rejectStep,
generateContract,
signContract,
createBooking,

View File

@@ -90,11 +90,16 @@ export default function ContractClearanceDetailPage() {
].includes(contract.status),
);
const linkedBookingId = useMemo(() => {
// Prefer the clearance view's server-resolved linkedBookingId (same field the
// GL Djibouti page uses). The contract's clearanceCycles[cycle].bookingId can
// be null/stale for an export FCFS booking, which would disable
// useBookingMilestones → empty milestones → the export "Payment & wagon
// allocation" step reads FREIGHT_PAYMENT_SETTLED as not-done and stays stuck.
const cycle = contract?.clearanceCycles?.find(
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
);
return cycle?.bookingId ?? undefined;
}, [contract]);
return clearance?.linkedBookingId ?? cycle?.bookingId ?? undefined;
}, [clearance, contract]);
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
const canCreateBooking = ready && !bookingAlreadyCreated;
const reviewReadOnly = shipmentLocked;

View File

@@ -56,6 +56,7 @@ import {
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.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") =>
@@ -559,6 +560,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 }>
@@ -1370,6 +1372,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} />}
@@ -2048,6 +2060,12 @@ const LastMilePage = () => {
item={releaseItem}
truckPrefill={releaseTruckPrefill}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}
record={detentionRecord}
/>
</Stack>
);
};

View File

@@ -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"

View File

@@ -185,6 +185,16 @@ export const contractsService = {
requiredRole,
}),
rejectStep: ({
id,
stepId,
reason,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
// ── Contract document ──
generateContract: (id: string) =>
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),

View File

@@ -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',
@@ -72,6 +73,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;
}
@@ -87,7 +91,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))),
@@ -104,4 +108,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`),
};

View File

@@ -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;

View File

@@ -117,6 +117,18 @@ export function deriveContractCustomerAction(
};
}
// Saved-but-not-submitted contract — send the customer back into the wizard to
// finish editing and submit it for review.
if (contract.status === "DRAFT" || contract.status === "RENEWAL_DRAFT") {
return {
type: "navigate",
label: "Continue draft",
to: `/contracts/${id}/edit`,
primary: true,
icon: PencilLine,
};
}
const payable = findPayableBookingForContract(id, bookings);
if (payable) {
return {

View File

@@ -73,10 +73,6 @@ import {
MUTED,
} from "./contract-ui";
// Statuses where a customer may create a shipment booking themselves. Reached
// only after self-clearance is approved by Operations (Path A) or, for DOMESTIC,
// directly at counter-sign.
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
// Statuses where the customer uploads clearance documents on the contract. Used
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
// (non-customs IMPORT/EXPORT, Operations-reviewed).
@@ -263,10 +259,14 @@ export default function ContractDetailPage() {
);
}
// Staff returned the contract for changes — send the customer to the full edit
// wizard (edit any term + replace documents → resubmit) rather than the
// read-only detail.
if (contract.status === "CHANGES_REQUESTED") {
// Not yet submitted (customer saved a draft) or staff returned the contract for
// changes — send the customer to the full edit wizard (edit any term + replace
// documents → submit) rather than the read-only detail.
if (
contract.status === "DRAFT" ||
contract.status === "RENEWAL_DRAFT" ||
contract.status === "CHANGES_REQUESTED"
) {
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
}
@@ -305,9 +305,13 @@ export default function ContractDetailPage() {
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
const canBookShipment =
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
const bookingAction = getContractBookingAction(contract, contractBookings);
// Whether the customer may open a new self-service booking. Derived from the
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
// once a non-terminal booking exists on a ONE_TIME contract there is no free
// slot, so the action is "none" and no booking button is shown.
const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request";
// Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action.

View File

@@ -81,9 +81,10 @@ type PriceModalMode = "submit" | "draft";
/**
* The contract wizard, used both to create a new contract and — in `edit` mode —
* to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode
* hydrates the form from the saved contract, lets the customer change any term
* and replace documents, then runs the same update → price → submit flow.
* to continue an unsubmitted DRAFT or edit & resubmit a contract staff returned
* with CHANGES_REQUESTED. Edit mode hydrates the form from the saved contract,
* lets the customer change any term and replace documents, then runs the same
* update → price → submit flow.
*/
export default function NewContractPage({
mode = "create",