resolve conflict

This commit is contained in:
Marshal
2026-07-07 21:46:25 +00:00
213 changed files with 15163 additions and 1998 deletions

View File

@@ -1,5 +1,7 @@
# Copy to .env for local/docker compose (not committed).
PORT=3001
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
GT06_TCP_PORT=5023
DB_HOST=localhost
DB_PORT=5433
DB_USER=postgres

View File

@@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
EXPOSE 5023
CMD ["node", "dist/main.js"]

View File

@@ -84,6 +84,10 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
@@ -172,6 +176,10 @@ import { LoggerMiddleware } from "./logger.middleware";
DriversModule,
FuelModule,
MaintenanceModule,
ComplianceModule,
IncidentsModule,
ProcurementModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Vehicle Compliance & Expiry Alerts.
* - Adds expiry-tracking columns to freight.vehicles.
* - Creates freight.compliance_records for per-document compliance tracking.
*/
export class AddVehicleCompliance1950000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Vehicle expiry / compliance columns.
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS vin VARCHAR,
ADD COLUMN IF NOT EXISTS ownership VARCHAR,
ADD COLUMN IF NOT EXISTS insurance_expiry DATE,
ADD COLUMN IF NOT EXISTS registration_expiry DATE,
ADD COLUMN IF NOT EXISTS next_inspection_date DATE;
`);
// Compliance records table.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.compliance_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id),
type VARCHAR NOT NULL,
document_number VARCHAR,
issued_date DATE,
expiry_date DATE NOT NULL,
status VARCHAR NOT NULL DEFAULT 'VALID',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS vin,
DROP COLUMN IF EXISTS ownership,
DROP COLUMN IF EXISTS insurance_expiry,
DROP COLUMN IF EXISTS registration_expiry,
DROP COLUMN IF EXISTS next_inspection_date;
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Accident & Incident register for the fleet. Tracks accidents, breakdowns,
* traffic violations, thefts and other incidents against a vehicle, driver
* and/or booking, with severity, damage estimate, insurance claim tracking and
* a lifecycle status. Queried by driver_id for per-driver incident history.
*/
export class AddIncidents1960000000000 implements MigrationInterface {
name = 'AddIncidents1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
driver_id uuid,
booking_id uuid,
type varchar NOT NULL,
severity varchar NOT NULL,
occurred_at timestamptz NOT NULL,
location varchar,
description text NOT NULL,
damage_estimate numeric(14,2),
status varchar NOT NULL DEFAULT 'REPORTED',
insurance_claim_number varchar,
reported_by varchar
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER"
ON freight.incidents (driver_id, occurred_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE"
ON freight.incidents (vehicle_id, occurred_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
}
}

View File

@@ -0,0 +1,93 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
name = 'AddMaintenanceDepth1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status VARCHAR NOT NULL DEFAULT 'OPEN',
priority VARCHAR NOT NULL DEFAULT 'MEDIUM',
assigned_to VARCHAR,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
labor_cost NUMERIC(14, 2),
parts_cost NUMERIC(14, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR NOT NULL,
sku VARCHAR,
category VARCHAR,
quantity_in_stock INT NOT NULL DEFAULT 0,
reorder_level INT NOT NULL DEFAULT 0,
unit_cost NUMERIC(14, 2),
location VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warranties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
component VARCHAR NOT NULL,
provider VARCHAR,
start_date DATE,
expiry_date DATE NOT NULL,
coverage_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`,
);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.work_orders
ADD CONSTRAINT "FK_work_orders_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warranties
ADD CONSTRAINT "FK_warranties_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProcurement1980000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.vendors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
name varchar NOT NULL,
type varchar,
contact_person varchar,
phone varchar,
email varchar,
address varchar,
is_active boolean NOT NULL DEFAULT true
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_acquisitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
vendor_id uuid,
acquisition_type varchar NOT NULL,
acquisition_date date NOT NULL,
cost numeric(14,2),
useful_life_months integer,
salvage_value numeric(14,2),
lease_start date,
lease_end date,
monthly_payment numeric(14,2),
status varchar NOT NULL DEFAULT 'ACTIVE',
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date
ON freight.asset_acquisitions(vehicle_id, acquisition_date);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_disposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid NOT NULL,
disposal_date date NOT NULL,
method varchar NOT NULL,
sale_price numeric(14,2),
buyer varchar,
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date
ON freight.asset_disposals(vehicle_id, disposal_date);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double-handling fee support. warehouse_fee_rules.basis: how a
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
* unit of measure), so no new booking column is needed.
*/
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
);
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
// if a prior version of this migration added it.
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's
* quoted in (ETB | USD, default ETB).
*/
export class AddVehiclePricePerKm1990000000000 implements MigrationInterface {
name = "AddVehiclePricePerKm1990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2),
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS price_per_km,
DROP COLUMN IF EXISTS currency
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
* device for the live map) + append-only fix history (gps_positions).
*/
export class AddGpsTracking2000000000000 implements MigrationInterface {
name = "AddGpsTracking2000000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
}
}

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

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER /
* TANKER / FLATBED / …), so different truck types carry different detention
* rates. Null = applies to any truck type.
*/
export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface {
name = 'AddFeeRuleVehicleType2010000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
}
}

View File

@@ -1,6 +1,16 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
import {
PdfColor,
assembleSinglePagePdf,
lineOp,
rectOp,
sealOp,
textOp,
textOpRight,
wrapText,
} from "./styled-pdf.util";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} ${kindLabel}`,
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
}),
};
}
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
* document. Single A4 page; long summaries / line lists are capped to fit.
*/
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
const currency = (cur?: string | null) =>
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
const money = (amount: unknown, cur?: string | null) =>
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const showCategory = Boolean(model.categoryHeader);
const ops: string[] = [];
// ── Header ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
const titleSize = heading.length > 34 ? 18 : 22;
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
ops.push(
textOpRight(
`Status ${model.status}`,
559,
748,
8.5,
"F1",
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
),
);
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
// ── Seal ──────────────────────────────────────────────────────────────
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
// ── Summary grid (two columns) ────────────────────────────────────────
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
});
y -= 34;
// ── Line-item table ───────────────────────────────────────────────────
const qtyR = 402;
const rateR = 486;
const amtR = 555;
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
if (showCategory) {
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
}
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
y -= 18;
const descChars = showCategory ? 44 : 66;
for (const item of model.lines) {
if (y < 190) break; // leave room for totals + footer
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
const rowH = Math.max(18, descLines.length * 10 + 8);
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
descLines.forEach((line, k) => {
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
});
if (showCategory) {
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
}
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
y -= rowH;
}
// ── Totals ────────────────────────────────────────────────────────────
let ty = y - 16;
for (const total of model.totals) {
if (ty < 88) break;
if (total.grand) {
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
ty -= 24;
} else {
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
ty -= 17;
}
}
// ── Footer ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
return assembleSinglePagePdf(ops);
}
/** Truncate to `max` chars with an ellipsis. */
private clip(value: string, max: number): string {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")

View File

@@ -0,0 +1,322 @@
/**
* Minimal hand-built PDF primitives shared by the Chromium-less document
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
* rules, right-aligned money, a round seal — so a document still looks like a
* real document when headless Chromium is unavailable, instead of degrading to
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
*/
export const MIN_VALID_PDF_BYTES = 2_000;
/** Colours as PDF "r g b" triples in the 0..1 range. */
export const PdfColor = {
teal: "0.06 0.46 0.43",
dark: "0.06 0.09 0.16",
gray: "0.39 0.45 0.55",
line: "0.80 0.84 0.89",
shade: "0.96 0.97 0.98",
tint: "0.94 0.99 0.98",
} as const;
export function escapePdfText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/[^\x20-\x7e]/g, " ");
}
/** Approximate rendered width of Helvetica text (slightly over-estimated so
* right-aligned text never crosses its column edge). */
export function textWidth(text: string, size: number): number {
return text.length * size * 0.52;
}
export function textOp(
text: string,
x: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
}
/** Right-align `text` so it ends at `rightX`. */
export function textOpRight(
text: string,
rightX: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
}
export function lineOp(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = PdfColor.line,
width = 0.8,
): string {
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
export function rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = "1 1 1",
strokeColor: string = PdfColor.line,
lineWidth = 0.7,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
function circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
"h",
].join("\n");
}
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
export function sealOp(
cx: number,
cy: number,
r: number,
lines: string[],
color: string = PdfColor.teal,
): string {
const rows = lines.slice(0, 3);
const ops = [
"q",
`${color} RG`,
`${color} rg`,
"2 w",
circlePath(cx, cy, r),
"S",
"0.7 w",
circlePath(cx, cy, r - 6),
"S",
];
const startY = cy + (rows.length - 1) * 6;
rows.forEach((text, i) => {
const size = i === 0 ? 10 : 7.5;
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
});
ops.push("Q");
return ops.join("\n");
}
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
export function clipText(value: string, max: number): string {
const t = String(value ?? "");
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
}
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
export function htmlToText(html: string): string {
return String(html ?? "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/gi, " ")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
)) {
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
}
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
);
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((m) => htmlToText(m[1]))
.filter(Boolean);
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
const landscape = headers.length > 7;
const page = landscape ? PageSize.landscape : PageSize.portrait;
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
// Summary tiles
let y = page.height - 100;
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
const tileH = 32;
tiles.forEach(([label, value], i) => {
const col = i % cols;
if (col === 0 && i > 0) y -= tileH;
const x = M + col * tileW;
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
});
y -= tileH + 12;
}
// Table
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
let shown = 0;
for (const row of rows) {
if (y < 96) break;
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
const cell = row[c] ?? "";
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
return assembleSinglePagePdf(ops, page);
}
/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
const out: string[] = [];
for (const raw of String(text ?? "").split("\n")) {
const words = raw.split(/\s+/).filter(Boolean);
let line = "";
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > maxChars && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
}
return out.length ? out : [""];
}
/** A4 page sizes in PDF points. */
export const PageSize = {
portrait: { width: 595, height: 842 },
landscape: { width: 842, height: 595 },
} as const;
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
export function assembleSinglePagePdf(
ops: string[],
page: { width: number; height: number } = PageSize.portrait,
): Buffer {
const stream = ops.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -0,0 +1,53 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List compliance records' })
findAll(
@Query('vehicleId') vehicleId?: string,
@Query('type') type?: ComplianceType,
) {
return this.complianceService.findAll({ vehicleId, type });
}
@Get('alerts')
@ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' })
getAlerts() {
return this.complianceService.getAlerts();
}
@Get(':id')
@ApiOperation({ summary: 'Get a compliance record by ID' })
findOne(@Param('id') id: string) {
return this.complianceService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ComplianceRecord } from './entities/compliance-record.entity';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
import { ComplianceService } from './compliance.service';
import { ComplianceRepository } from './compliance.repository';
import { ComplianceController } from './compliance.controller';
@Module({
imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])],
providers: [ComplianceService, ComplianceRepository],
controllers: [ComplianceController],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity';
@Injectable()
export class ComplianceRepository extends BaseRepository<ComplianceRecord> {
constructor(
@InjectRepository(ComplianceRecord)
private readonly complianceRepository: Repository<ComplianceRecord>,
) {
super(complianceRepository);
}
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
const where: FindOptionsWhere<ComplianceRecord> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.type) where.type = filter.type;
return this.complianceRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,184 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ComplianceRepository } from './compliance.repository';
import {
ComplianceRecord,
ComplianceStatus,
ComplianceType,
} from './entities/compliance-record.entity';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
const DUE_SOON_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
export interface ComplianceAlert {
vehicleId: string;
vehiclePlate?: string;
kind: string;
label: string;
expiryDate: string;
daysUntil: number;
severity: AlertSeverity;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly complianceRepository: ComplianceRepository,
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
return this.complianceRepository.create({
...dto,
status: dto.status ?? this.deriveStatus(dto.expiryDate),
});
}
async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
return this.complianceRepository.findWithFilters(filter);
}
async findById(id: string): Promise<ComplianceRecord> {
const record = await this.complianceRepository.findById(id);
if (!record) {
throw new NotFoundException(`Compliance record ${id} not found`);
}
return record;
}
async update(id: string, dto: UpdateComplianceRecordDto): Promise<ComplianceRecord> {
await this.findById(id);
const nextExpiry = dto.expiryDate;
const updated = await this.complianceRepository.update(id, {
...dto,
// Re-derive status when expiry changes and the caller didn't set it explicitly.
status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined),
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.complianceRepository.softDelete(id);
}
/**
* Flat list of compliance items that are overdue or due within 30 days.
* Combines the compliance_records table with the vehicle expiry columns
* (insurance / registration / next inspection) and assigned-driver license
* expiry. `new Date()` is fine here — this is the NestJS API runtime.
*/
async getAlerts(): Promise<ComplianceAlert[]> {
const now = new Date();
const alerts: ComplianceAlert[] = [];
const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } });
const vehicleById = new Map(vehicles.map((v) => [v.id, v]));
const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined;
// 1. Compliance records
const records = await this.complianceRepository.findWithFilters();
for (const record of records) {
const computed = this.computeSeverity(record.expiryDate, now);
if (!computed) continue;
const vehicle = vehicleById.get(record.vehicleId);
alerts.push({
vehicleId: record.vehicleId,
vehiclePlate: plateOf(vehicle),
kind: record.type,
label: record.documentNumber
? `${record.type} · ${record.documentNumber}`
: record.type,
expiryDate: record.expiryDate,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
// 2. Vehicle-level expiry columns
const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [
{ field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' },
{ field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' },
{ field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' },
];
for (const vehicle of vehicles) {
for (const { field, kind, label } of vehicleFields) {
const value = vehicle[field] as string | undefined;
if (!value) continue;
const computed = this.computeSeverity(value, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind,
label,
expiryDate: value,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
// 3. Assigned-driver license expiry
const driverIds = [
...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)),
];
if (driverIds.length > 0) {
const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } });
const driverById = new Map(drivers.map((d) => [d.id, d]));
for (const vehicle of vehicles) {
if (!vehicle.assignedDriverId) continue;
const driver = driverById.get(vehicle.assignedDriverId);
if (!driver?.licenseExpiryDate) continue;
const expiry =
driver.licenseExpiryDate instanceof Date
? driver.licenseExpiryDate.toISOString().slice(0, 10)
: String(driver.licenseExpiryDate);
const computed = this.computeSeverity(expiry, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind: 'DRIVER_LICENSE',
label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
expiryDate: expiry,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
return alerts.sort((a, b) => a.daysUntil - b.daysUntil);
}
private computeSeverity(
expiryDate: string,
now: Date,
): { daysUntil: number; severity: AlertSeverity } | null {
const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY);
if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' };
if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' };
return null;
}
private deriveStatus(expiryDate: string): ComplianceStatus {
const daysUntil = Math.ceil(
(new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY,
);
if (daysUntil < 0) return ComplianceStatus.EXPIRED;
if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING;
return ComplianceStatus.VALID;
}
}

View File

@@ -0,0 +1,55 @@
import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity';
export class CreateComplianceRecordDto {
@IsUUID()
vehicleId!: string;
@IsEnum(ComplianceType)
type!: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateComplianceRecordDto {
@IsOptional()
@IsEnum(ComplianceType)
type?: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsOptional()
@IsDateString()
expiryDate?: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum ComplianceType {
INSPECTION = 'INSPECTION',
INSURANCE = 'INSURANCE',
ROADWORTHINESS = 'ROADWORTHINESS',
PERMIT = 'PERMIT',
TAX = 'TAX',
}
export enum ComplianceStatus {
VALID = 'VALID',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
}
@Entity({ name: 'compliance_records', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class ComplianceRecord extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'type', type: 'varchar' })
type!: ComplianceType;
@Column({ name: 'document_number', type: 'varchar', nullable: true })
documentNumber?: string;
@Column({ name: 'issued_date', type: 'date', nullable: true })
issuedDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID })
status!: ComplianceStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -8,9 +8,13 @@ import {
Body,
Query,
ParseUUIDPipe,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
@@ -19,7 +23,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
@BookingStaff(FREIGHT_PERMS.drivers.view)
export class DriversController {
constructor(
private readonly driversService: DriversService,
@@ -27,7 +31,7 @@ export class DriversController {
) {}
@Post()
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.create)
@ApiOperation({ summary: 'Create a new driver' })
create(@Body() createDriverDto: CreateDriverDto) {
return this.driversService.create(createDriverDto);
@@ -65,8 +69,33 @@ export class DriversController {
return this.fleetHistory.getDriverHistory(id);
}
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.driversService.uploadDocuments(id, files ?? []);
}
@Get(':id/documents')
@ApiOperation({ summary: "List a driver's documents" })
listDocuments(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.listDocuments(id);
}
@Delete(':id/documents/:fileId')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Delete a driver document' })
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
return this.driversService.removeDocument(fileId);
}
@Patch(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Update a driver' })
update(
@Param('id', ParseUUIDPipe) id: string,
@@ -76,7 +105,7 @@ export class DriversController {
}
@Delete(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.delete)
@ApiOperation({ summary: 'Delete a driver' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.remove(id);

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { FilesModule } from '../files/files.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
imports: [TypeOrmModule.forFeature([Driver]), FilesModule],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],

View File

@@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
import { FilesService } from '../files/files.service';
/** Resource + code the driver-documents upload area is stored under. */
const DRIVER_DOCS_RESOURCE = 'driver';
const DRIVER_DOCS_CODE = 'driver_docs';
@Injectable()
export class DriversService {
@@ -13,8 +18,37 @@ export class DriversService {
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
private readonly filesService: FilesService,
) {}
/** Upload one or more driver documents (code "driver_docs"). */
async uploadDocuments(driverId: string, files: Express.Multer.File[]) {
const driver = await this.driverRepo.findOneBy({ id: driverId });
if (!driver) throw new NotFoundException(`Driver ${driverId} not found`);
if (!files?.length) throw new BadRequestException('No files provided');
return Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: driverId,
resource: DRIVER_DOCS_RESOURCE,
code: DRIVER_DOCS_CODE,
file,
}),
),
);
}
/** List a driver's uploaded documents (code "driver_docs"). */
async listDocuments(driverId: string) {
const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE);
return all.filter((f) => f.code === DRIVER_DOCS_CODE);
}
/** Delete a single driver document by file id. */
async removeDocument(fileId: string): Promise<void> {
await this.filesService.remove(fileId);
}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(

View File

@@ -119,6 +119,11 @@ export class FilesService {
return record;
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -66,13 +66,37 @@ export class FirstMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: fm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -27,7 +28,7 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.firstMile.view)
export class FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
@@ -63,27 +64,28 @@ export class FirstMileController {
}
@Get('acceptitem/:id')
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.acceptBooking(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.create)
@ApiOperation({ summary: 'Create a first-mile leg' })
create(@Body() dto: CreateFirstMileDto) {
return this.firstMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.update)
@ApiOperation({ summary: 'Update a first-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
// No invoice side-effects — invoices are generated only via the explicit
@@ -92,7 +94,7 @@ export class FirstMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.generateInvoice)
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.firstMileService.findById(id);
@@ -106,7 +108,7 @@ export class FirstMileController {
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -116,7 +118,7 @@ export class FirstMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -126,7 +128,7 @@ export class FirstMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a first-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -640,10 +640,24 @@ export class FirstMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -1,26 +1,40 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
// Stats feed the Financial Reports + Fleet Dashboard pages, so their viewers may
// read them without full fuel access.
const FUEL_STATS_PERMS = [
FREIGHT_PERMS.fuel.view,
FREIGHT_PERMS.fleetReports.view,
FREIGHT_PERMS.fleetDashboard.view,
];
@ApiTags('Fuel Management')
@ApiBearerAuth()
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.create)
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get all fuel purchases' })
async getAllFuelPurchases() {
return this.fuelService.getAllFuelPurchases();
}
@Get('purchases/:vehicleId')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@@ -35,6 +49,7 @@ export class FuelController {
}
@Get('consumption/:vehicleId/:month')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@@ -44,12 +59,14 @@ export class FuelController {
}
@Get('stats')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
async getFleetFuelStats(@Query('months') months: number = 12) {
return this.fuelService.getFleetFuelStats(months);
}
@Get('stats/:vehicleId')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
imei!: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
}
export class UpdateDeviceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
/**
* A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a
* vehicle. Carries the denormalized latest fix so the live map reads one row
* per device without scanning position history.
*/
@Entity({ name: 'gps_devices', schema: 'freight' })
@Index(['vehicleId'])
export class GpsDevice extends BaseEntity {
@Column({ name: 'imei', type: 'varchar', length: 20, unique: true })
imei!: string;
@Column({ name: 'name', type: 'varchar', nullable: true })
name?: string | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
/** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */
@Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' })
status!: string;
@Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true })
lastSeenAt?: Date | null;
// ── Denormalized latest fix ──
@Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLat?: number | null;
@Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLng?: number | null;
@Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true })
lastSpeed?: number | null;
@Column({ name: 'last_course', type: 'int', nullable: true })
lastCourse?: number | null;
@Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true })
lastFixAt?: Date | null;
@Column({ name: 'voltage_level', type: 'int', nullable: true })
voltageLevel?: number | null;
@Column({ name: 'gsm_level', type: 'int', nullable: true })
gsmLevel?: number | null;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** One GPS fix from a tracker (append-only history). */
@Entity({ name: 'gps_positions', schema: 'freight' })
@Index(['deviceId', 'gpsTime'])
@Index(['vehicleId', 'gpsTime'])
export class GpsPosition extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
@Column({ name: 'imei', type: 'varchar', length: 20 })
imei!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 })
lat!: number;
@Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 })
lng!: number;
@Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 })
speed!: number;
@Column({ name: 'course', type: 'int', default: 0 })
course!: number;
@Column({ name: 'satellites', type: 'int', default: 0 })
satellites!: number;
@Column({ name: 'positioned', type: 'boolean', default: false })
positioned!: boolean;
/** Fix time reported by the device (UTC). */
@Column({ name: 'gps_time', type: 'timestamptz' })
gpsTime!: Date;
/** Non-zero when the fix came in via an alarm packet. */
@Column({ name: 'alarm', type: 'int', default: 0 })
alarm!: number;
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@Get('positions/latest')
@ApiOperation({ summary: 'Latest fix per device (live map feed)' })
latest() {
return this.gps.latest();
}
@Get('positions/:vehicleId/history')
@ApiOperation({ summary: 'Position history for a vehicle' })
history(
@Param('vehicleId', ParseUUIDPipe) vehicleId: string,
@Query('limit') limit?: string,
) {
return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined);
}
@Get('devices')
@ApiOperation({ summary: 'List GPS trackers' })
listDevices() {
return this.gps.listDevices();
}
@Post('devices')
@FleetManage()
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
@Injectable()
export class GpsDeviceRepository extends BaseRepository<GpsDevice> {
constructor(
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
) {
super(repository);
}
findByImei(imei: string): Promise<GpsDevice | null> {
return this.repository.findOne({ where: { imei } });
}
}
@Injectable()
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
constructor(
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,124 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsDevice } from './entities/gps-device.entity';
import { Gt06Gps, Gt06Status } from './gt06/gt06.codec';
/** A device is considered ONLINE if seen within this window. */
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class GpsTrackingService {
private readonly logger = new Logger(GpsTrackingService.name);
constructor(
private readonly devices: GpsDeviceRepository,
private readonly positions: GpsPositionRepository,
) {}
private isOnline(d: GpsDevice): boolean {
return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS);
}
/** Find the device for an IMEI, auto-registering it on first contact. */
private async ensureDevice(imei: string): Promise<GpsDevice> {
const existing = await this.devices.findByImei(imei);
if (existing) return existing;
this.logger.log(`Auto-registering new GPS tracker ${imei}`);
return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() });
}
// ── Ingestion (called by the TCP server) ──
async handleLogin(imei: string): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
}
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, {
lastSeenAt: new Date(),
status: 'ONLINE',
voltageLevel: status.voltageLevel,
gsmLevel: status.gsmLevel,
});
}
async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
const now = new Date();
await this.devices.update(device.id, {
lastSeenAt: now,
status: 'ONLINE',
lastLat: gps.latitude,
lastLng: gps.longitude,
lastSpeed: gps.speed,
lastCourse: gps.course,
lastFixAt: new Date(gps.time),
...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}),
});
await this.positions.create({
deviceId: device.id,
imei,
vehicleId: device.vehicleId ?? null,
lat: gps.latitude,
lng: gps.longitude,
speed: gps.speed,
course: gps.course,
satellites: gps.satellites,
positioned: gps.positioned,
gpsTime: new Date(gps.time),
alarm,
});
}
// ── Queries / management (REST) ──
private decorate(d: GpsDevice) {
return { ...d, online: this.isOnline(d) };
}
async listDevices() {
const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } });
return rows.map((d) => this.decorate(d));
}
/** Live map feed — devices that have at least one fix. */
async latest() {
const rows = await this.devices.findAll({ relations: { vehicle: true } });
return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d));
}
async history(vehicleId: string, limit = 200) {
return this.positions.findAll({
where: { vehicleId },
order: { gpsTime: 'DESC' },
take: Math.min(limit, 1000),
});
}
async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) {
const existing = await this.devices.findByImei(dto.imei);
if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`);
return this.devices.create({
imei: dto.imei,
name: dto.name ?? null,
vehicleId: dto.vehicleId ?? null,
status: 'REGISTERED',
});
}
async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) {
const updated = await this.devices.update(id, {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) throw new NotFoundException(`GPS device ${id} not found`);
return updated;
}
async removeDevice(id: string): Promise<void> {
await this.devices.softDelete(id);
}
}

View File

@@ -0,0 +1,207 @@
/**
* GT06 GPS-tracker protocol codec.
*
* Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A
* `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over
* len..serial (inclusive) and equals the 2 crc bytes.
*/
const START = 0x7878;
const STOP = 0x0d0a;
export const GT06_PROTOCOL = {
LOGIN: 0x01,
LOCATION: 0x12,
HEARTBEAT: 0x13,
STRING: 0x15,
ALARM: 0x16,
ADDRESS_BY_PHONE: 0x1a,
SERVER_COMMAND: 0x80,
} as const;
/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */
export function crcItu(bytes: Buffer): number {
let fcs = 0xffff;
for (const b of bytes) {
fcs ^= b;
for (let i = 0; i < 8; i++) {
fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1;
}
}
return (~fcs) & 0xffff;
}
export interface Gt06Gps {
time: string; // ISO (UTC)
satellites: number;
latitude: number;
longitude: number;
speed: number; // km/h
course: number; // 0-360
positioned: boolean;
}
export interface Gt06Lbs {
mcc: number;
mnc: number;
lac: number;
cellId: number;
}
export interface Gt06Status {
terminalInfo: number;
voltageLevel: number;
gsmLevel: number;
alarm: number; // former byte of alarm/language
charging: boolean;
accOn: boolean;
gpsTracking: boolean;
oilCut: boolean;
}
export type Gt06Packet =
| { type: 'login'; protocol: number; serial: number; imei: string }
| { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs }
| { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status }
| { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status }
| { type: 'unknown'; protocol: number; serial: number };
/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */
function decodeImei(buf: Buffer): string {
return buf.toString('hex').replace(/^0/, '');
}
function decodeDateTime(buf: Buffer, off: number): string {
const year = 2000 + buf[off];
const month = buf[off + 1];
const day = buf[off + 2];
const hour = buf[off + 3];
const min = buf[off + 4];
const sec = buf[off + 5];
return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString();
}
/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */
function rawToDegrees(raw: number): number {
return raw / 30000 / 60;
}
function decodeGps(buf: Buffer, off: number): Gt06Gps {
const time = decodeDateTime(buf, off);
const lenSat = buf[off + 6];
const satellites = lenSat & 0x0f;
const latRaw = buf.readUInt32BE(off + 7);
const lonRaw = buf.readUInt32BE(off + 11);
const speed = buf[off + 15];
const cs = buf.readUInt16BE(off + 16);
const hi = (cs >> 8) & 0xff;
const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4
const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West)
const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North)
const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2
let latitude = rawToDegrees(latRaw);
let longitude = rawToDegrees(lonRaw);
if (!isNorth) latitude = -latitude;
if (isWest) longitude = -longitude;
return { time, satellites, latitude, longitude, speed, course, positioned };
}
function decodeStatus(buf: Buffer, off: number): Gt06Status {
const terminalInfo = buf[off];
const voltageLevel = buf[off + 1];
const gsmLevel = buf[off + 2];
const alarm = buf[off + 3]; // alarm/language former byte
return {
terminalInfo,
voltageLevel,
gsmLevel,
alarm,
oilCut: Boolean(terminalInfo & 0x80),
gpsTracking: Boolean(terminalInfo & 0x40),
charging: Boolean(terminalInfo & 0x04),
accOn: Boolean(terminalInfo & 0x02),
};
}
function decodeLbs(buf: Buffer, off: number): Gt06Lbs {
return {
mcc: buf.readUInt16BE(off),
mnc: buf[off + 2],
lac: buf.readUInt16BE(off + 3),
cellId: buf.readUIntBE(off + 5, 3),
};
}
function decodeFrame(frame: Buffer): Gt06Packet | null {
// frame = 78 78 len ...content... serial(2) crc(2) 0D 0A
const len = frame[2];
const protocol = frame[3];
const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2)
const serial = frame.readUInt16BE(serialOff);
const contentOff = 4; // start of content (after protocol)
switch (protocol) {
case GT06_PROTOCOL.LOGIN:
return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) };
case GT06_PROTOCOL.LOCATION:
return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) };
case GT06_PROTOCOL.HEARTBEAT:
return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) };
case GT06_PROTOCOL.ALARM: {
const gps = decodeGps(frame, contentOff);
// content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2)
const lbs = decodeLbs(frame, contentOff + 18 + 1);
const status = decodeStatus(frame, contentOff + 18 + 1 + 8);
return { type: 'alarm', protocol, serial, gps, lbs, status };
}
default:
return { type: 'unknown', protocol, serial };
}
}
/**
* Pull all complete frames out of a stream buffer. Returns the decoded packets
* (skipping CRC-failed ones) and the trailing bytes that form a partial frame.
*/
export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } {
const packets: Gt06Packet[] = [];
let i = 0;
while (i + 5 <= buffer.length) {
if (buffer.readUInt16BE(i) !== START) {
i += 1; // resync
continue;
}
const len = buffer[i + 2];
const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop
if (i + frameLen > buffer.length) break; // incomplete
const frame = buffer.subarray(i, i + frameLen);
if (frame.readUInt16BE(frameLen - 2) === STOP) {
// CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3.
const crcCalc = crcItu(frame.subarray(2, frameLen - 4));
const crcRecv = frame.readUInt16BE(frameLen - 4);
if (crcCalc === crcRecv) {
const pkt = decodeFrame(frame);
if (pkt) packets.push(pkt);
}
i += frameLen;
} else {
i += 1; // bad frame, resync
}
}
return { packets, rest: buffer.subarray(i) };
}
/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */
export function buildAck(protocol: number, serial: number): Buffer {
const body = Buffer.alloc(3); // protocol + serial(2)
body[0] = protocol;
body.writeUInt16BE(serial, 1);
const len = body.length + 2; // + crc(2)
const forCrc = Buffer.concat([Buffer.from([len]), body]);
const crc = crcItu(forCrc);
return Buffer.concat([
Buffer.from([0x78, 0x78, len]),
body,
Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]),
]);
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,48 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class CreateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsEnum(IncidentType)
type!: IncidentType;
@IsEnum(IncidentSeverity)
severity!: IncidentSeverity;
@IsDateString()
occurredAt!: string;
@IsOptional()
@IsString()
location?: string;
@IsString()
description!: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,52 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class UpdateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsOptional()
@IsEnum(IncidentType)
type?: IncidentType;
@IsOptional()
@IsEnum(IncidentSeverity)
severity?: IncidentSeverity;
@IsOptional()
@IsDateString()
occurredAt?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,76 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Driver } from '../../drivers/entities/driver.entity';
export enum IncidentType {
ACCIDENT = 'ACCIDENT',
BREAKDOWN = 'BREAKDOWN',
TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION',
THEFT = 'THEFT',
OTHER = 'OTHER',
}
export enum IncidentSeverity {
MINOR = 'MINOR',
MODERATE = 'MODERATE',
MAJOR = 'MAJOR',
CRITICAL = 'CRITICAL',
}
export enum IncidentStatus {
REPORTED = 'REPORTED',
UNDER_REVIEW = 'UNDER_REVIEW',
CLAIM_FILED = 'CLAIM_FILED',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
@Entity({ name: 'incidents', schema: 'freight' })
@Index(['driverId', 'occurredAt'])
@Index(['vehicleId', 'occurredAt'])
export class Incident extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@ManyToOne(() => Driver, { eager: false, nullable: true })
@JoinColumn({ name: 'driver_id' })
driver?: Driver;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string;
@Column({ name: 'type', type: 'varchar' })
type!: IncidentType;
@Column({ name: 'severity', type: 'varchar' })
severity!: IncidentSeverity;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true })
damageEstimate?: number;
@Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED })
status!: IncidentStatus;
@Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true })
insuranceClaimNumber?: string;
@Column({ name: 'reported_by', type: 'varchar', nullable: true })
reportedBy?: string;
}

View File

@@ -0,0 +1,69 @@
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@Controller('incidents')
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List incidents (optionally filtered)' })
async findAll(
@Query('vehicleId') vehicleId?: string,
@Query('driverId') driverId?: string,
@Query('status') status?: IncidentStatus,
@Query('type') type?: IncidentType,
) {
return this.incidentsService.findAll({ vehicleId, driverId, status, type });
}
@Get('driver/:driverId/stats')
@ApiOperation({ summary: 'Get incident statistics for a driver' })
async statsForDriver(@Param('driverId') driverId: string) {
return this.incidentsService.statsForDriver(driverId);
}
@Get('driver/:driverId')
@ApiOperation({ summary: 'List incidents for a driver (incident history)' })
async findByDriver(@Param('driverId') driverId: string) {
return this.incidentsService.findByDriver(driverId);
}
@Get(':id')
@ApiOperation({ summary: 'Get an incident by id' })
async findById(@Param('id') id: string) {
return this.incidentsService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Incident } from './entities/incident.entity';
import { IncidentsService } from './incidents.service';
import { IncidentsRepository } from './incidents.repository';
import { IncidentsController } from './incidents.controller';
@Module({
imports: [TypeOrmModule.forFeature([Incident])],
providers: [IncidentsService, IncidentsRepository],
controllers: [IncidentsController],
exports: [IncidentsService],
})
export class IncidentsModule {}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Incident } from './entities/incident.entity';
@Injectable()
export class IncidentsRepository extends BaseRepository<Incident> {
constructor(
@InjectRepository(Incident)
incidentRepository: Repository<Incident>,
) {
super(incidentRepository);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { IncidentsRepository } from './incidents.repository';
import {
Incident,
IncidentStatus,
IncidentType,
} from './entities/incident.entity';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
export interface IncidentFilter {
vehicleId?: string;
driverId?: string;
status?: IncidentStatus;
type?: IncidentType;
}
export interface DriverIncidentStats {
total: number;
byType: Record<string, number>;
lastIncidentAt: Date | null;
}
@Injectable()
export class IncidentsService {
constructor(private readonly incidentsRepository: IncidentsRepository) {}
async create(dto: CreateIncidentDto): Promise<Incident> {
return this.incidentsRepository.create({
...dto,
occurredAt: new Date(dto.occurredAt),
});
}
async findAll(filter: IncidentFilter = {}): Promise<Incident[]> {
const where: FindOptionsWhere<Incident> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.driverId) where.driverId = filter.driverId;
if (filter.status) where.status = filter.status;
if (filter.type) where.type = filter.type;
return this.incidentsRepository.findAll({
where,
order: { occurredAt: 'DESC' },
});
}
async findByDriver(driverId: string): Promise<Incident[]> {
return this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
}
async findById(id: string): Promise<Incident> {
const incident = await this.incidentsRepository.findById(id);
if (!incident) {
throw new NotFoundException(`Incident ${id} not found`);
}
return incident;
}
async update(id: string, dto: UpdateIncidentDto): Promise<Incident> {
await this.findById(id);
const updated = await this.incidentsRepository.update(id, {
...dto,
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined,
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.incidentsRepository.softDelete(id);
}
async statsForDriver(driverId: string): Promise<DriverIncidentStats> {
const incidents = await this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
const byType: Record<string, number> = {};
for (const incident of incidents) {
byType[incident.type] = (byType[incident.type] || 0) + 1;
}
return {
total: incidents.length,
byType,
lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null,
};
}
}

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

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -63,6 +63,30 @@ export class LastMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -70,7 +94,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: lm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -27,7 +28,7 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
@@ -63,21 +64,21 @@ export class LastMileController {
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.create)
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({ summary: 'Update a last-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
// No invoice side-effects here — invoices are generated only via the
@@ -86,7 +87,7 @@ export class LastMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -95,7 +96,7 @@ export class LastMileController {
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class LastMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -115,7 +116,7 @@ export class LastMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.lastMileService.findById(id);

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) {
@@ -510,10 +521,24 @@ export class LastMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -0,0 +1,171 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
Min,
} from 'class-validator';
import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity';
export class CreateWorkOrderDto {
@IsUUID()
vehicleId!: string;
@IsString()
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
openedAt?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class UpdateWorkOrderDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class CreatePartDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class UpdatePartDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class CreateWarrantyDto {
@IsUUID()
vehicleId!: string;
@IsString()
component!: string;
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsString()
coverageNotes?: string;
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
@Entity({ name: 'parts', schema: 'freight' })
@Index(['category'])
export class Part extends BaseEntity {
@Column({ name: 'name', type: 'varchar' })
name!: string;
@Column({ name: 'sku', type: 'varchar', nullable: true })
sku?: string;
@Column({ name: 'category', type: 'varchar', nullable: true })
category?: string; // includes 'TIRE' — doubles as tire inventory
@Column({ name: 'quantity_in_stock', type: 'int', default: 0 })
quantityInStock!: number;
@Column({ name: 'reorder_level', type: 'int', default: 0 })
reorderLevel!: number;
@Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
unitCost?: number;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
}

View File

@@ -0,0 +1,29 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'warranties', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class Warranty extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'component', type: 'varchar' })
component!: string;
@Column({ name: 'provider', type: 'varchar', nullable: true })
provider?: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'coverage_notes', type: 'text', nullable: true })
coverageNotes?: string;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum WorkOrderStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum WorkOrderPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
@Entity({ name: 'work_orders', schema: 'freight' })
@Index(['vehicleId', 'status'])
export class WorkOrder extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'title', type: 'varchar' })
title!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN })
status!: WorkOrderStatus;
@Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM })
priority!: WorkOrderPriority;
@Column({ name: 'assigned_to', type: 'varchar', nullable: true })
assignedTo?: string;
@Column({ name: 'opened_at', type: 'timestamptz' })
openedAt!: Date;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt?: Date;
@Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
laborCost?: number;
@Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
partsCost?: number;
}

View File

@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
@Injectable()
export class MaintenanceDepthService {
constructor(
private readonly workOrderRepository: WorkOrderRepository,
private readonly partRepository: PartRepository,
private readonly warrantyRepository: WarrantyRepository,
) {}
// ---- Work Orders ----
async createWorkOrder(dto: CreateWorkOrderDto): Promise<WorkOrder> {
return this.workOrderRepository.create({
...dto,
openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(),
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
}
async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
return this.workOrderRepository.findFiltered(filters);
}
async findWorkOrderById(id: string): Promise<WorkOrder> {
const workOrder = await this.workOrderRepository.findById(id);
if (!workOrder) throw new NotFoundException(`Work order ${id} not found`);
return workOrder;
}
async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise<WorkOrder> {
await this.findWorkOrderById(id);
const updated = await this.workOrderRepository.update(id, {
...dto,
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
return updated!;
}
async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> {
await this.findWorkOrderById(id);
await this.workOrderRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Parts / Tires ----
async createPart(dto: CreatePartDto): Promise<Part> {
return this.partRepository.create({ ...dto });
}
async findParts(filters: { category?: string; lowStock?: boolean }) {
return this.partRepository.findFiltered(filters);
}
async updatePart(id: string, dto: UpdatePartDto): Promise<Part> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
const updated = await this.partRepository.update(id, { ...dto });
return updated!;
}
async deletePart(id: string): Promise<{ id: string; deleted: boolean }> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
await this.partRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Warranties ----
async createWarranty(dto: CreateWarrantyDto): Promise<Warranty> {
return this.warrantyRepository.create({ ...dto });
}
async findWarranties(filters: { vehicleId?: string }) {
return this.warrantyRepository.findFiltered(filters);
}
async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> {
const warranty = await this.warrantyRepository.findById(id);
if (!warranty) throw new NotFoundException(`Warranty ${id} not found`);
await this.warrantyRepository.softDelete(id);
return { id, deleted: true };
}
}

View File

@@ -1,52 +1,173 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
import { WorkOrderStatus } from './entities/work-order.entity';
@ApiTags('Maintenance Management')
@ApiBearerAuth()
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
constructor(
private readonly maintenanceService: MaintenanceService,
private readonly maintenanceDepthService: MaintenanceDepthService,
) {}
@Post('schedules')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Schedule maintenance' })
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
return this.maintenanceService.scheduleMaintenanceAsync(dto);
}
@Post('costs')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Record maintenance cost' })
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
return this.maintenanceService.recordMaintenanceCost(dto);
}
@Patch('schedules/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update maintenance schedule' })
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })
async getUpcoming(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
}
@Get('history/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get maintenance history' })
async getHistory(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getMaintenanceHistory(vehicleId);
}
@Get('stats')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
async getFleetStats() {
return this.maintenanceService.getFleetMaintenanceStats();
}
@Get('stats/:vehicleId')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get maintenance statistics' })
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
// ---- Work Orders ----
@Post('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create work order' })
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
return this.maintenanceDepthService.createWorkOrder(dto);
}
@Get('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List work orders' })
async listWorkOrders(
@Query('vehicleId') vehicleId?: string,
@Query('status') status?: WorkOrderStatus,
) {
return this.maintenanceDepthService.findWorkOrders({ vehicleId, status });
}
@Get('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get work order' })
async getWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.findWorkOrderById(id);
}
@Patch('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update work order' })
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
return this.maintenanceDepthService.updateWorkOrder(id, dto);
}
@Delete('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete work order' })
async deleteWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWorkOrder(id);
}
// ---- Parts / Tires ----
@Post('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create part' })
async createPart(@Body() dto: CreatePartDto) {
return this.maintenanceDepthService.createPart(dto);
}
@Get('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List parts / tire inventory' })
async listParts(
@Query('category') category?: string,
@Query('lowStock') lowStock?: string,
) {
return this.maintenanceDepthService.findParts({
category,
lowStock: lowStock === 'true',
});
}
@Patch('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update part' })
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
return this.maintenanceDepthService.updatePart(id, dto);
}
@Delete('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete part' })
async deletePart(@Param('id') id: string) {
return this.maintenanceDepthService.deletePart(id);
}
// ---- Warranties ----
@Post('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create warranty' })
async createWarranty(@Body() dto: CreateWarrantyDto) {
return this.maintenanceDepthService.createWarranty(dto);
}
@Get('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List warranties' })
async listWarranties(@Query('vehicleId') vehicleId?: string) {
return this.maintenanceDepthService.findWarranties({ vehicleId });
}
@Delete('warranties/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete warranty' })
async deleteWarranty(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWarranty(id);
}
}

View File

@@ -2,14 +2,30 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { WorkOrder } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { MaintenanceRepository } from './maintenance.repository';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,
],
controllers: [MaintenanceController],
exports: [MaintenanceService],
exports: [MaintenanceService, MaintenanceDepthService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Part } from './entities/part.entity';
@Injectable()
export class PartRepository extends BaseRepository<Part> {
constructor(
@InjectRepository(Part)
private readonly partRepository: Repository<Part>,
) {
super(partRepository);
}
async findFiltered(filters: { category?: string; lowStock?: boolean }) {
const qb = this.partRepository.createQueryBuilder('part');
if (filters.category) {
qb.andWhere('part.category = :category', { category: filters.category });
}
if (filters.lowStock) {
qb.andWhere('part.quantityInStock <= part.reorderLevel');
}
qb.orderBy('part.name', 'ASC');
return qb.getMany();
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Warranty } from './entities/warranty.entity';
@Injectable()
export class WarrantyRepository extends BaseRepository<Warranty> {
constructor(
@InjectRepository(Warranty)
private readonly warrantyRepository: Repository<Warranty>,
) {
super(warrantyRepository);
}
async findFiltered(filters: { vehicleId?: string }) {
const where: FindOptionsWhere<Warranty> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
return this.warrantyRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
@Injectable()
export class WorkOrderRepository extends BaseRepository<WorkOrder> {
constructor(
@InjectRepository(WorkOrder)
private readonly workOrderRepository: Repository<WorkOrder>,
) {
super(workOrderRepository);
}
async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
const where: FindOptionsWhere<WorkOrder> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
if (filters.status) where.status = filters.status;
return this.workOrderRepository.find({
where,
order: { openedAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,193 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
import { DisposalMethod } from '../entities/asset-disposal.entity';
export class CreateVendorDto {
@IsString()
name!: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateVendorDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsEnum(AcquisitionType)
acquisitionType!: AcquisitionType;
@IsDateString()
acquisitionDate!: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsOptional()
@IsEnum(AcquisitionType)
acquisitionType?: AcquisitionType;
@IsOptional()
@IsDateString()
acquisitionDate?: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class CreateDisposalDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
disposalDate!: string;
@IsEnum(DisposalMethod)
method!: DisposalMethod;
@IsOptional()
@IsNumber()
salePrice?: number;
@IsOptional()
@IsString()
buyer?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Vendor } from './vendor.entity';
export enum AcquisitionType {
PURCHASE = 'PURCHASE',
LEASE = 'LEASE',
RENTAL = 'RENTAL',
}
export enum AcquisitionStatus {
ACTIVE = 'ACTIVE',
LEASE_EXPIRING = 'LEASE_EXPIRING',
DISPOSED = 'DISPOSED',
}
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'vendor_id', type: 'uuid', nullable: true })
vendorId?: string;
@ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vendor_id' })
vendor?: Vendor;
@Column({ name: 'acquisition_type', type: 'varchar' })
acquisitionType!: AcquisitionType;
@Column({ name: 'acquisition_date', type: 'date' })
acquisitionDate!: string;
@Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
cost?: number;
@Column({ name: 'useful_life_months', type: 'int', nullable: true })
usefulLifeMonths?: number;
@Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true })
salvageValue?: number;
@Column({ name: 'lease_start', type: 'date', nullable: true })
leaseStart?: string;
@Column({ name: 'lease_end', type: 'date', nullable: true })
leaseEnd?: string;
@Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true })
monthlyPayment?: number;
@Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE })
status!: AcquisitionStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
export enum DisposalMethod {
SALE = 'SALE',
SCRAP = 'SCRAP',
RETURN_LEASE = 'RETURN_LEASE',
TRADE_IN = 'TRADE_IN',
}
@Entity({ name: 'asset_disposals', schema: 'freight' })
@Index(['vehicleId', 'disposalDate'])
export class AssetDisposal extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@Column({ name: 'disposal_date', type: 'date' })
disposalDate!: string;
@Column({ name: 'method', type: 'varchar' })
method!: DisposalMethod;
@Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true })
salePrice?: number;
@Column({ name: 'buyer', nullable: true })
buyer?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column } from 'typeorm';
export enum VendorType {
DEALER = 'DEALER',
LEASING = 'LEASING',
PARTS = 'PARTS',
SERVICE = 'SERVICE',
OTHER = 'OTHER',
}
@Entity({ name: 'vendors', schema: 'freight' })
export class Vendor extends BaseEntity {
@Column({ name: 'name' })
name!: string;
@Column({ name: 'type', type: 'varchar', nullable: true })
type?: VendorType;
@Column({ name: 'contact_person', nullable: true })
contactPerson?: string;
@Column({ name: 'phone', nullable: true })
phone?: string;
@Column({ name: 'email', nullable: true })
email?: string;
@Column({ name: 'address', nullable: true })
address?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,98 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
}
@Get('vendors')
@ApiOperation({ summary: 'List vendors' })
async listVendors() {
return this.procurementService.listVendors();
}
@Patch('vendors/:id')
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
}
// ---- Acquisitions ----
@Post('acquisitions')
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
}
@Get('acquisitions')
@ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' })
async listAcquisitions(@Query('vehicleId') vehicleId?: string) {
return this.procurementService.listAcquisitions(vehicleId);
}
@Get('acquisitions/:id')
@ApiOperation({ summary: 'Get an asset acquisition by id' })
async getAcquisition(@Param('id') id: string) {
return this.procurementService.getAcquisition(id);
}
@Patch('acquisitions/:id')
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
}
// ---- Disposals ----
@Post('disposals')
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
}
@Get('disposals')
@ApiOperation({ summary: 'List asset disposals' })
async listDisposals() {
return this.procurementService.listDisposals();
}
@Delete('disposals/:id')
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);
}
// ---- Lifecycle ----
@Get('lifecycle/:vehicleId')
@ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' })
async lifecycle(@Param('vehicleId') vehicleId: string) {
return this.procurementService.lifecycle(vehicleId);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import { ProcurementService } from './procurement.service';
import { ProcurementRepository } from './procurement.repository';
import { ProcurementController } from './procurement.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])],
providers: [ProcurementService, ProcurementRepository],
controllers: [ProcurementController],
exports: [ProcurementService],
})
export class ProcurementModule {}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { DeepPartial, Repository } from 'typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
@Injectable()
export class ProcurementRepository extends BaseRepository<AssetAcquisition> {
constructor(
@InjectRepository(AssetAcquisition)
private readonly acquisitionRepository: Repository<AssetAcquisition>,
@InjectRepository(Vendor)
private readonly vendorRepository: Repository<Vendor>,
@InjectRepository(AssetDisposal)
private readonly disposalRepository: Repository<AssetDisposal>,
) {
super(acquisitionRepository);
}
// ---- Vendors ----
async createVendor(data: DeepPartial<Vendor>): Promise<Vendor> {
const vendor = this.vendorRepository.create(data);
return this.vendorRepository.save(vendor);
}
async findVendors(): Promise<Vendor[]> {
return this.vendorRepository.find({ order: { createdAt: 'DESC' } });
}
async updateVendor(id: string, data: DeepPartial<Vendor>): Promise<Vendor | null> {
await this.vendorRepository.update(id, data as never);
return this.vendorRepository.findOneBy({ id });
}
async softDeleteVendor(id: string): Promise<void> {
await this.vendorRepository.softDelete(id);
}
// ---- Acquisitions ----
async createAcquisition(data: DeepPartial<AssetAcquisition>): Promise<AssetAcquisition> {
const acquisition = this.acquisitionRepository.create(data);
return this.acquisitionRepository.save(acquisition);
}
async findAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.acquisitionRepository.find({
where: vehicleId ? { vehicleId } : {},
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
async findAcquisitionById(id: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { id },
relations: ['vehicle', 'vendor'],
});
}
async updateAcquisition(
id: string,
data: DeepPartial<AssetAcquisition>,
): Promise<AssetAcquisition | null> {
await this.acquisitionRepository.update(id, data as never);
return this.findAcquisitionById(id);
}
async softDeleteAcquisition(id: string): Promise<void> {
await this.acquisitionRepository.softDelete(id);
}
async findLatestAcquisitionByVehicle(vehicleId: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { vehicleId },
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
// ---- Disposals ----
async createDisposal(data: DeepPartial<AssetDisposal>): Promise<AssetDisposal> {
const disposal = this.disposalRepository.create(data);
return this.disposalRepository.save(disposal);
}
async findDisposals(): Promise<AssetDisposal[]> {
return this.disposalRepository.find({ order: { disposalDate: 'DESC' } });
}
async softDeleteDisposal(id: string): Promise<void> {
await this.disposalRepository.softDelete(id);
}
async findLatestDisposalByVehicle(vehicleId: string): Promise<AssetDisposal | null> {
return this.disposalRepository.findOne({
where: { vehicleId },
order: { disposalDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
export interface DepreciationResult {
method: 'STRAIGHT_LINE';
cost: number;
salvageValue: number;
usefulLifeMonths: number;
monthsElapsed: number;
monthlyDepreciation: number;
bookValue: number;
}
export interface LifecycleResult {
vehicleId: string;
acquisition: AssetAcquisition | null;
disposal: AssetDisposal | null;
depreciation: DepreciationResult | null;
}
@Injectable()
export class ProcurementService {
constructor(private readonly procurementRepository: ProcurementRepository) {}
// ---- Vendors ----
async createVendor(dto: CreateVendorDto): Promise<Vendor> {
return this.procurementRepository.createVendor(dto);
}
async listVendors(): Promise<Vendor[]> {
return this.procurementRepository.findVendors();
}
async updateVendor(id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
return this.procurementRepository.updateVendor(id, dto);
}
async deleteVendor(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteVendor(id);
return { success: true };
}
// ---- Acquisitions ----
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
return this.procurementRepository.createAcquisition(dto);
}
async listAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.procurementRepository.findAcquisitions(vehicleId);
}
async getAcquisition(id: string): Promise<AssetAcquisition | null> {
return this.procurementRepository.findAcquisitionById(id);
}
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
return this.procurementRepository.updateAcquisition(id, dto);
}
async deleteAcquisition(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteAcquisition(id);
return { success: true };
}
// ---- Disposals ----
async createDisposal(dto: CreateDisposalDto): Promise<AssetDisposal> {
return this.procurementRepository.createDisposal(dto);
}
async listDisposals(): Promise<AssetDisposal[]> {
return this.procurementRepository.findDisposals();
}
async deleteDisposal(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteDisposal(id);
return { success: true };
}
// ---- Lifecycle ----
async lifecycle(vehicleId: string): Promise<LifecycleResult> {
const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId);
const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId);
return {
vehicleId,
acquisition,
disposal,
depreciation: this.computeStraightLineDepreciation(acquisition),
};
}
/**
* Straight-line depreciation. Requires a cost and a positive useful life.
* monthlyDep = (cost - salvageValue) / usefulLifeMonths
* bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue.
*/
private computeStraightLineDepreciation(
acquisition: AssetAcquisition | null,
): DepreciationResult | null {
if (!acquisition) return null;
const cost = acquisition.cost != null ? Number(acquisition.cost) : null;
const usefulLifeMonths =
acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null;
if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) {
return null;
}
const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0;
const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths;
const acquiredAt = new Date(acquisition.acquisitionDate);
const now = new Date();
const monthsElapsed = Math.max(
0,
(now.getFullYear() - acquiredAt.getFullYear()) * 12 +
(now.getMonth() - acquiredAt.getMonth()),
);
const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue);
return {
method: 'STRAIGHT_LINE',
cost,
salvageValue,
usefulLifeMonths,
monthsElapsed,
monthlyDepreciation,
bookValue,
};
}
}

View File

@@ -1766,9 +1766,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
// Generic render — NOT the release-order fallback (would mislabel this as a
// gate-clearance / release order when Chromium is unavailable).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1786,8 +1786,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
// Generic render — NOT the release-order fallback (see importLoadListDocument).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,

View File

@@ -65,4 +65,12 @@ export class CreateVehicleDto {
@IsOptional()
@IsUUID()
locationId?: string;
@IsOptional()
@IsNumber()
pricePerKm?: number;
@IsOptional()
@IsString()
currency?: string;
}

View File

@@ -88,4 +88,29 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
// --- Haulage pricing ---
@Column({ name: 'price_per_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
pricePerKm?: number;
/** Currency for pricePerKm: ETB | USD */
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' })
currency?: string;
// --- Compliance / expiry tracking ---
@Column({ name: 'vin', type: 'varchar', nullable: true })
vin?: string;
/** Owned | Leased | Rented */
@Column({ name: 'ownership', type: 'varchar', nullable: true })
ownership?: string;
@Column({ name: 'insurance_expiry', type: 'date', nullable: true })
insuranceExpiry?: string;
@Column({ name: 'registration_expiry', type: 'date', nullable: true })
registrationExpiry?: string;
@Column({ name: 'next_inspection_date', type: 'date', nullable: true })
nextInspectionDate?: string;
}

View File

@@ -10,7 +10,8 @@ import {
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@@ -19,7 +20,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
@BookingStaff(FREIGHT_PERMS.vehicles.view)
export class VehiclesController {
constructor(
private readonly vehiclesService: VehiclesService,
@@ -27,7 +28,7 @@ export class VehiclesController {
) {}
@Post()
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.create)
@ApiOperation({ summary: 'Create a new vehicle' })
create(@Body() createVehicleDto: CreateVehicleDto) {
return this.vehiclesService.create(createVehicleDto);
@@ -68,7 +69,7 @@ export class VehiclesController {
}
@Patch(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.update)
@ApiOperation({ summary: 'Update a vehicle' })
update(
@Param('id', ParseUUIDPipe) id: string,
@@ -78,7 +79,7 @@ export class VehiclesController {
}
@Delete(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.delete)
@ApiOperation({ summary: 'Delete a vehicle' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.remove(id);

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@@ -82,11 +82,30 @@ export class CreateFeeRuleDto {
@Min(0)
freeDays!: number;
@ApiProperty()
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
@IsNumber()
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' })
@IsOptional()
@IsInt()
@Min(0)
freeHours?: number;
@ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' })
@IsOptional()
@IsString()
vehicleType?: string;
@ApiPropertyOptional({
enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
})
@IsOptional()
@IsEnum(FEE_RULE_BASES)
basis?: FeeRuleBasis;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()

View File

@@ -1,9 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
'DEMURRAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/**
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
* - PER_CONTAINER: booking container count
* - PER_TON: cargo total in tonnes (bulk cargo)
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
*/
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
@@ -41,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true })
containerType?: string | null;
// Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER
// | FLATBED | …). Null = any truck type.
@Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true })
vehicleType?: string | null;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@@ -57,9 +76,20 @@ 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;
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
// free days and tiers do not apply. Null for the day-based fee types.
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
basis?: FeeRuleBasis | null;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];

View File

@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes {
@@ -14,8 +14,12 @@ interface ItemAttributes {
tradeDirection: string | null;
cargoTypeCode: string | null;
containerTypeCode: string | null;
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
vehicleType: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -24,6 +28,8 @@ interface ItemAttributes {
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
freeDays: number;
@@ -48,6 +54,16 @@ export interface FeePreview {
ratePerDay: number;
amount: number;
}>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
groups?: Array<{
vehicleType: string | null;
truckCount: number;
chargeableDays: number;
ratePerDay: number;
amount: number;
ruleId: string | null;
ruleName: string | null;
}>;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -132,7 +148,8 @@ export class WarehouseFeeService {
b.trade_direction AS "tradeDirection",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -189,6 +206,7 @@ export class WarehouseFeeService {
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) return null;
if (!check(rule.vehicleType, item.vehicleType)) return null;
if (!check(rule.facilityId, item.facilityId)) return null;
if (!check(rule.warehouseId, item.warehouseId)) return null;
if (!check(rule.yardId, item.yardId)) return null;
@@ -283,6 +301,10 @@ export class WarehouseFeeService {
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
// Double handling is a flat charge (rate × basis quantity), not day-based.
if (ruleType === 'DOUBLE_HANDLING_FEE') {
return this.computeDoubleHandling(rule, item, now, billingCurrency);
}
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
@@ -321,6 +343,7 @@ export class WarehouseFeeService {
return {
ruleType,
basis: null,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays,
@@ -340,13 +363,68 @@ export class WarehouseFeeService {
};
}
/**
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
*/
private async computeDoubleHandling(
rule: WarehouseFeeRule | null,
item: ItemAttributes,
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
const rate = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
// Double handling applies to IMPORT only — no charge for export/domestic.
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
return {
ruleType: 'DOUBLE_HANDLING_FEE',
basis,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRate,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: null,
endDate: now.toISOString(),
endIsOpen: false,
elapsedDays: 0,
chargeableDays: 0,
containerCount,
billableUnits: quantity,
amount,
tiers: [],
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_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(
@@ -359,4 +437,197 @@ 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 [leg] = 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"
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 (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
// Truck detention applies to IMPORT only — no charge for export/domestic.
if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
const cur = this.normalizeCurrency(billingCurrency);
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: null,
ruleName: null,
freeDays: 0,
ratePerDay: 0,
currency: cur,
ruleCurrency: null,
billingCurrency: cur,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(),
endIsOpen: !leg.deliveredAt,
elapsedDays: 0,
chargeableDays: 0,
containerCount: 0,
billableUnits: 0,
amount: 0,
tiers: [],
groups: [],
};
}
// Group the leg's vehicles by type so each truck type is billed by its own
// matching rule (rates differ by truck type). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY v.vehicle_type`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
const now = new Date();
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
releaseDate: null,
freightType: leg.freightType ?? null,
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
inventoryQuantity: 1,
bookingContainerCount: 1,
cargoQuantity: 0,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
now,
billingCurrency,
);
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
}),
);
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
const single = computed.length === 1 ? computed[0].c : null;
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
freeDays: 0,
ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: totalTrucks,
billableUnits: totalBillable,
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
vehicleType: x.vehicleType,
truckCount: x.truckCount,
chargeableDays: x.c.chargeableDays,
ratePerDay: x.c.ratePerDay,
amount: x.c.amount,
ruleId: x.c.ruleId,
ruleName: x.c.ruleName,
})),
};
}
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

@@ -100,6 +100,27 @@ export class WarehouseInventoryController {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-dispatch-export')
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
@@ -333,6 +354,12 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -273,6 +273,45 @@ export interface BulkDispatchResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
export interface LoadableTrainRow {
scheduleId: string;
trainNumber: string | null;
origin: string | null;
destination: string | null;
status: string;
departureTime: string | Date | null;
/** Received/ready inventory not yet loaded onto this train. */
readyCount: number;
/** Inventory already loaded onto this train. */
loadedCount: number;
}
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
export interface TrainLoadableItemRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
inspectionStatus: string | null;
status: string;
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
loadable: boolean;
}
export interface TrainLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
@@ -1070,6 +1109,169 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('LOADED');
}
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
// the arrived containers/cargoes assigned to it, and load the ready ones onto
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
readyCount: Number(rest.readyCount) || 0,
loadedCount: Number(rest.loadedCount) || 0,
}));
}
/**
* Container/cargo inventory items assigned to a train, with the wagon each is
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN LATERAL (
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw
ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
}));
}
/**
* Load the selected inventory items onto their allocated wagons for the given
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
* an allocated wagon; others are skipped with a reason. When every inventory
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
*/
async loadItemsOntoTrain(
scheduleId: string,
inventoryIds: string[],
performedBy?: string,
): Promise<TrainLoadResult> {
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
const items = await this.trainLoadableItems(scheduleId);
const byId = new Map(items.map((i) => [i.id, i]));
const affectedBookingIds = new Set<string>();
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = byId.get(inventoryId);
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
result.loadedCount += 1;
result.results.push({ inventoryId, status: 'LOADED' });
if (item.bookingId) affectedBookingIds.add(item.bookingId);
} catch (error) {
skip(error instanceof Error ? error.message : 'Load failed');
}
}
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
for (const bookingId of affectedBookingIds) {
await this.dataSource.query(
`UPDATE freight.train_schedule_bookings tsb
SET loading_status = 'LOADED', updated_at = NOW()
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
)`,
[scheduleId, bookingId],
);
}
return result;
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array<
@@ -2308,6 +2510,95 @@ export class WarehouseInventoryService {
};
}
/**
* Per-container (or bulk) items of a booking with their lifecycle stage and
* reference sources — drives the container-level detail datatable (stage tabs,
* multiselect load-to-truck, per-item actions).
*/
async containerItems(bookingId: string): Promise<
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}>
> {
const rows: Array<{
containerNumber: string;
goods: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.customer_truck_containers ctc
ON ctc.container_number = bcu.container_number
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
LEFT JOIN freight.customer_truck_assignments a
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
LEFT JOIN freight.warehouse_inventory inv
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
}));
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the

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

@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
const items = previews
.filter((p) => p.amount > 0)
.map((p) => {
const feeType: WarehouseFeeType =
p.ruleType === "STORAGE_FEE"
? "STORAGE_FEE"
: isContainer
? "CONTAINER_DEMURRAGE"
: "BULK_DEMURRAGE";
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
let feeType: WarehouseFeeType;
let description: string;
switch (p.ruleType) {
case "STORAGE_FEE":
feeType = "STORAGE_FEE";
description = `Storage fee - ${days}${tierSuffix}`;
break;
case "DOUBLE_HANDLING_FEE": {
feeType = "DOUBLE_HANDLING";
const unit =
p.basis === "PER_TON"
? "ton(s)"
: p.basis === "PER_ITEM"
? "item(s)"
: "container(s)";
description = `Double handling - ${p.billableUnits} ${unit}`;
break;
}
case "TRUCK_DETENTION_FEE":
feeType = "TRUCK_DETENTION";
description = `Truck detention - ${days}${tierSuffix}`;
break;
default:
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
}
return {
feeRuleId: p.ruleId,
feeType,
description:
p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`,
description,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
@@ -256,6 +269,104 @@ 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.",
);
}
// One line per truck-type group (each billed by its own matching rule). Groups
// with no matching rule bill 0 and are dropped. Falls back to a single line.
const groups = preview.groups && preview.groups.length ? preview.groups : null;
const lines: InvoiceLineInput[] = groups
? groups
.filter((g) => g.amount > 0)
.map((g) => ({
chargeType: "TRUCK_DETENTION",
description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`,
quantity: g.truckCount * g.chargeableDays,
unitRate: g.ratePerDay,
amount: g.amount,
currency: preview.currency,
metadata: {
feeRuleId: g.ruleId ?? null,
chargeableDays: g.chargeableDays,
vehicleType: g.vehicleType ?? null,
},
}))
: [
{
chargeType: "TRUCK_DETENTION",
description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`,
quantity: preview.billableUnits,
unitRate: preview.ratePerDay,
amount: preview.amount,
currency: preview.currency,
metadata: {
feeRuleId: preview.ruleId ?? null,
chargeableDays: preview.chargeableDays ?? null,
},
},
];
if (lines.length === 0) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
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,
status: Freight.InvoiceStatus.Issued,
});
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);

View File

@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
'DOUBLE_HANDLING',
'TRUCK_DETENTION',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
const MIN_VALID_PDF_BYTES = 2_000;
@@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label });
}
/**
* Render a "summary tiles + one table + notice + signatures" document (the
* marshalling / load-list layout) with a STYLED table-aware fallback for when
* Chromium is unavailable — so the manifest draws as a real gridded document
* instead of a flat plain-text dump.
*/
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
});
}
/**
* Render document HTML with a STYLED hand-built fallback (the release layout,
* but with a custom title + section heading) for when Chromium is unavailable.

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

@@ -512,6 +512,32 @@ const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [
const CLEARANCE_DESCRIPTION =
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
// ── Driver documents ────────────────────────────────────────────────────────
// Configurable upload area (code "driver_docs") attached to a driver profile —
// license, national ID, contracts, training certificates, etc.
const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [
{
fileKey: "driver_docs",
fileLabel: "Driver documents",
helpText: "License, national ID, contracts, training certificates, etc.",
isRequired: false,
isMultiple: true,
maxFiles: 20,
allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"],
maxSizeMb: 10,
displayOrder: 1,
},
];
const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "driver_docs",
label: "Driver documents",
entity: "driver",
fields: DRIVER_DOCUMENT_FIELDS,
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -549,6 +575,11 @@ export class FileUploadSettingsSeeder {
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
for (const documentSetting of allSettings) {

View File

@@ -22,6 +22,7 @@
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -44,6 +45,7 @@
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@types/google.maps": "^3.65.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View File

@@ -94,6 +94,10 @@ import { MaintenancePage } from "./pages/fleet/MaintenancePage";
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
import { TrackingPage } from "./pages/fleet/TrackingPage";
import CompliancePage from "./pages/fleet/CompliancePage";
import IncidentsPage from "./pages/fleet/IncidentsPage";
import WorkOrdersPage from "./pages/fleet/WorkOrdersPage";
import ProcurementPage from "./pages/fleet/ProcurementPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -212,13 +216,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.trainScheduling.view,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.trainScheduling.view,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
@@ -229,7 +233,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
@@ -259,43 +263,67 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleet.view,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
@@ -868,7 +896,7 @@ const App = () => {
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<RequirePermission permission={FREIGHT_PERMS.firstMile.view}>
<FirstMilePage />
</RequirePermission>
}
@@ -876,7 +904,7 @@ const App = () => {
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<RequirePermission permission={FREIGHT_PERMS.lastMile.view}>
<LastMilePage />
</RequirePermission>
}
@@ -972,7 +1000,7 @@ const App = () => {
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.vehicles.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -980,7 +1008,7 @@ const App = () => {
<Route
path="vehicles/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.vehicles.view}>
<VehicleDetailPage />
</RequirePermission>
}
@@ -988,7 +1016,7 @@ const App = () => {
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.drivers.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -996,7 +1024,7 @@ const App = () => {
<Route
path="drivers/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.drivers.view}>
<DriverDetailPage />
</RequirePermission>
}
@@ -1058,7 +1086,7 @@ const App = () => {
<Route
path="fuel-purchases"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.fuel.view}>
<FuelPurchasePage />
</RequirePermission>
}
@@ -1066,7 +1094,7 @@ const App = () => {
<Route
path="fuel-stats"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.fuel.view}>
<FuelStatsPage />
</RequirePermission>
}
@@ -1074,7 +1102,7 @@ const App = () => {
<Route
path="maintenance"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.maintenance.view}>
<MaintenancePage />
</RequirePermission>
}
@@ -1082,7 +1110,7 @@ const App = () => {
<Route
path="financial-reports"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.fleetReports.view}>
<FinancialReportsPage />
</RequirePermission>
}
@@ -1090,7 +1118,7 @@ const App = () => {
<Route
path="fleet-dashboard"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.fleetDashboard.view}>
<FleetDashboard />
</RequirePermission>
}
@@ -1098,11 +1126,43 @@ const App = () => {
<Route
path="tracking"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={FREIGHT_PERMS.tracking.view}>
<TrackingPage />
</RequirePermission>
}
/>
<Route
path="compliance"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<CompliancePage />
</RequirePermission>
}
/>
<Route
path="incidents"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<IncidentsPage />
</RequirePermission>
}
/>
<Route
path="work-orders"
element={
<RequirePermission permission={FREIGHT_PERMS.maintenance.view}>
<WorkOrdersPage />
</RequirePermission>
}
/>
<Route
path="procurement"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<ProcurementPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -7,6 +7,7 @@ import {
Group,
Modal,
NumberInput,
Radio,
Select,
MultiSelect,
SimpleGrid,
@@ -293,6 +294,26 @@ const FleetFormDialog = ({
// only by verification and never hand-edited.
const isDisabled = Boolean(field.disabled || field.faydaLocked);
if (field.type === "radio") {
return (
<Radio.Group
key={field.name}
label={field.label}
value={value == null ? "" : String(value)}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next }))
}
error={error}
>
<Group gap="lg" mt={6}>
{(field.options ?? []).map((o) => (
<Radio key={o.value} value={o.value} label={o.label} disabled={isDisabled} />
))}
</Group>
</Radio.Group>
);
}
if (field.type === "select") {
return (
<Select

View File

@@ -0,0 +1,245 @@
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.groups && preview.groups.length > 1 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</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.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : 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

@@ -0,0 +1,211 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
];
const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [tab, setTab] = useState('ALL');
const [selected, setSelected] = useState<string[]>([]);
const [truckId, setTruckId] = useState<string | null>(null);
const itemsKey = ['container-items', bookingId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: trucks = [] } = useQuery({
queryKey: ['ci-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const visible = useMemo(
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemsKey });
setSelected([]);
toast({ title: 'Containers loaded onto truck' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="90%"
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
<Tabs.List>
{STAGE_TABS.map((t) => {
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
return (
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : items.length === 0 ? (
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
) : (
<Stack gap="sm">
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th />
<Table.Th>Container</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Contract</Table.Th>
<Table.Th>Last mile</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visible.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Checkbox
checked={selected.includes(i.containerNumber)}
onChange={() => toggle(i.containerNumber)}
disabled={!isLoadable(i)}
/>
</Table.Td>
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
>
Exit Paper
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{/* Multiselect → load onto a truck */}
<Group justify="space-between" align="flex-end">
<Text size="sm" c="dimmed">{selected.length} selected</Text>
<Group gap="sm" align="flex-end">
<Select
label="Load onto truck"
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
data={truckOptions}
value={truckId}
onChange={setTruckId}
disabled={truckOptions.length === 0}
w={260}
/>
<Button
color="edr-green"
disabled={selected.length === 0 || !truckId}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load selected
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' },
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
};
function fmtDate(iso: string | null) {

View File

@@ -0,0 +1,307 @@
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type LoadableTrain,
type TrainLoadableItem,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
STORED: 'gray',
RESERVED: 'grape',
READY_FOR_LOADING: 'teal',
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
interface BookingGroup {
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
items: TrainLoadableItem[];
}
function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] {
const map = new Map<string, BookingGroup>();
for (const i of items) {
const key = i.bookingId ?? i.bookingReference ?? 'unknown';
let g = map.get(key);
if (!g) {
g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] };
map.set(key, g);
}
g.items.push(i);
}
return [...map.values()];
}
/**
* Load to Train — a datatable of allocated EXPORT trains. Expand a train to see
* the bookings allocated to it; expand a booking to see its containers/cargoes
* and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an
* allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { data: trains = [], isLoading } = useQuery({
queryKey: ['loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
});
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setExpanded((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
if (isLoading) {
return (
<Group justify="center" py="lg">
<Loader />
</Group>
);
}
if (trains.length === 0) {
return (
<Alert color="gray" variant="light">
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Train</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th ta="center">Ready</Table.Th>
<Table.Th ta="center">Loaded</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((t) => (
<TrainRow
key={t.scheduleId}
train={t}
expanded={expanded.has(t.scheduleId)}
onToggle={() => toggle(t.scheduleId)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['train-loadable-items', train.scheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId),
enabled: expanded,
});
const bookings = useMemo(() => groupByBooking(items), [items]);
const route =
train.origin || train.destination ? `${train.origin ?? '?'}${train.destination ?? '?'}` : '—';
return (
<>
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<TrainFront size={16} />
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
</Group>
</Table.Td>
<Table.Td>{route}</Table.Td>
<Table.Td ta="center">
<Badge color="blue" variant="light">
{train.readyCount}
</Badge>
</Table.Td>
<Table.Td ta="center">
<Badge color="green" variant="light">
{train.loadedCount}
</Badge>
</Table.Td>
</Table.Tr>
{expanded && (
<Table.Tr>
<Table.Td colSpan={5} p={0}>
<Box p="sm" bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : bookings.length === 0 ? (
<Alert color="gray" variant="light">
No arrived containers/cargoes allocated to this train yet.
</Alert>
) : (
<Stack gap="xs">
{bookings.map((b) => (
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
))}
</Stack>
)}
</Box>
</Table.Td>
</Table.Tr>
)}
</>
);
}
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length;
const selectable = booking.items.filter((i) => i.loadable);
const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id));
const toggleItem = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] });
queryClient.invalidateQueries({ queryKey: ['loadable-trains'] });
setSelected([]);
toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` });
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
return (
<Paper withBorder radius="sm" p="xs">
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
<Text size="sm" c="dimmed">
{booking.customerName ?? '—'}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge variant="light" color="blue">
{booking.items.length} item(s)
</Badge>
{loadedCount > 0 && (
<Badge variant="light" color="green">
{loadedCount} loaded
</Badge>
)}
</Group>
</Group>
{open && (
<>
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectable.length === 0}
/>
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.items.map((i) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(i.id)} disabled={!i.loadable} />
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between" align="center" mt="xs">
<Text size="xs" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
</Text>
<Button
size="compact-sm"
color="edr-green"
leftSection={<TrainFront size={14} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
</>
)}
</Paper>
);
}

View File

@@ -62,6 +62,7 @@ import type {
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
@@ -645,9 +646,15 @@ function TruckEntranceFields({
function LocationSelects({
value,
onChange,
allowedYardTypes,
allowedZoneTypes,
}: {
value: Location;
onChange: (next: Location) => void;
/** When non-empty, only yards of these types are offered (matched to freight). */
allowedYardTypes?: string[];
/** When non-empty, only zones of these types are offered. */
allowedZoneTypes?: string[];
}) {
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
@@ -673,15 +680,17 @@ function LocationSelects({
() =>
(yardsQuery.data ?? [])
.filter((y) => y.status === 'ACTIVE')
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
[yardsQuery.data, allowedYardTypes],
);
const zoneOptions = useMemo(
() =>
(zonesQuery.data ?? [])
.filter((z) => z.status === 'ACTIVE')
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
[zonesQuery.data, allowedZoneTypes],
);
return (
@@ -1759,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
/**
* Yard/zone types valid for the freight being received — used to filter the receive
* location pickers so the yard list matches the cargo. Container freight → container
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
* Union across the given freight types; empty input → no restriction (show all).
*/
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
const yardTypes = new Set<string>();
const zoneTypes = new Set<string>();
for (const freightType of freightTypes) {
const normalized = (freightType ?? '').toUpperCase();
if (!normalized) continue;
if (normalized === 'CONTAINER') {
yardTypes.add('CONTAINER_YARD');
zoneTypes.add('CONTAINER_ZONE');
} else {
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
}
}
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
};
const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED';
@@ -2161,6 +2193,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2376,7 +2409,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
@@ -2500,6 +2533,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}
bookingId={containerItemsItem?.booking?.id ?? null}
bookingReference={containerItemsItem?.booking?.reference ?? null}
/>
</Stack>
);
}
@@ -2880,6 +2919,24 @@ export function WarehouseFlowWorkbench({
);
const activeDirection = direction === 'BOTH' ? tab : direction;
// Match the yard/zone list to the freight being received (container → container
// yards, etc). Same query key as the export tab, so React Query dedupes it.
const { data: eligibleForLocation = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: activeDirection },
enabled: enabled && activeDirection === 'EXPORT',
}),
);
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
() =>
yardZoneTypesForFreights(
eligibleForLocation
.filter((r) => r.direction === activeDirection)
.map((r) => r.freightType),
),
[eligibleForLocation, activeDirection],
);
useEffect(() => {
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [enabled, direction]);
@@ -2887,7 +2944,12 @@ export function WarehouseFlowWorkbench({
return (
<Stack gap="md">
{activeDirection === 'EXPORT' && (
<LocationSelects value={location} onChange={setLocation} />
<LocationSelects
value={location}
onChange={setLocation}
allowedYardTypes={allowedYardTypes}
allowedZoneTypes={allowedZoneTypes}
/>
)}
{direction === 'BOTH' ? (

View File

@@ -0,0 +1,343 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Grid,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
type ComplianceAlert,
type ComplianceRecord,
type ComplianceType,
} from "@/services/compliance.service";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
const COMPLIANCE_TYPES: ComplianceType[] = [
"INSPECTION",
"INSURANCE",
"ROADWORTHINESS",
"PERMIT",
"TAX",
];
const severityColor = (severity: ComplianceAlert["severity"]) =>
severity === "OVERDUE" ? "red" : "yellow";
const statusColor = (status: ComplianceRecord["status"]) => {
if (status === "EXPIRED") return "red";
if (status === "EXPIRING") return "yellow";
return "green";
};
const formatDate = (value?: string | null) =>
value ? new Date(value).toLocaleDateString() : "—";
const emptyForm = {
vehicleId: "",
type: "INSPECTION" as ComplianceType,
documentNumber: "",
issuedDate: "",
expiryDate: new Date().toISOString().split("T")[0],
notes: "",
};
export default function CompliancePage() {
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [formData, setFormData] = useState(emptyForm);
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "compliance-select"],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: alerts = [], isLoading: isLoadingAlerts } = useQuery({
queryKey: ["compliance", "alerts"],
queryFn: async () => {
const res = await complianceService.getAlerts();
return res.data || [];
},
});
const { data: records = [], isLoading: isLoadingRecords } = useQuery({
queryKey: ["compliance"],
queryFn: async () => {
const res = await complianceService.list();
return res.data || [];
},
});
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await complianceService.create({
vehicleId: data.vehicleId,
type: data.type,
expiryDate: data.expiryDate,
documentNumber: data.documentNumber || undefined,
issuedDate: data.issuedDate || undefined,
notes: data.notes || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: "Compliance record created" });
setModalOpen(false);
setFormData(emptyForm);
qc.invalidateQueries({ queryKey: ["compliance"] });
qc.invalidateQueries({ queryKey: ["compliance", "alerts"] });
},
onError: (error: any) => {
toast({
title: "Error creating record",
description:
error?.response?.data?.message || "Failed to create compliance record",
variant: "destructive",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber ?? v.code ?? v.id} - ${v.manufacturer ?? ""} ${v.model ?? ""}`.trim(),
})) || [];
const vehicleLabel = (record: ComplianceRecord) =>
record.vehicle?.plateNumber ||
vehiclesData?.find((v) => v.id === record.vehicleId)?.plateNumber ||
record.vehicleId;
const overdueCount = (alerts as ComplianceAlert[]).filter(
(a) => a.severity === "OVERDUE",
).length;
const dueSoonCount = (alerts as ComplianceAlert[]).filter(
(a) => a.severity === "DUE_SOON",
).length;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Compliance" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Compliance & Alerts</Title>
<Button
leftSection={<Plus size={16} />}
onClick={() => setModalOpen(true)}
color="edr-green"
>
New Record
</Button>
</Group>
{/* Alerts */}
<Group mb="sm" gap="xs">
<AlertTriangle size={18} />
<Title order={3}>Expiry Alerts</Title>
{overdueCount > 0 && (
<Badge color="red" variant="light">
{overdueCount} overdue
</Badge>
)}
{dueSoonCount > 0 && (
<Badge color="yellow" variant="light">
{dueSoonCount} due soon
</Badge>
)}
</Group>
{isLoadingAlerts ? (
<Group justify="center" py="md" mb="lg">
<Loader size="sm" />
</Group>
) : (alerts as ComplianceAlert[]).length === 0 ? (
<Card withBorder padding="lg" mb="lg">
<Text c="dimmed" ta="center">
No compliance items are overdue or due soon. All clear.
</Text>
</Card>
) : (
<Grid mb="lg">
{(alerts as ComplianceAlert[]).map((alert, index) => (
<Grid.Col
key={`${alert.vehicleId}-${alert.kind}-${index}`}
span={{ base: 12, sm: 6, md: 4 }}
>
<Card withBorder padding="md" h="100%">
<Group justify="space-between" mb="xs">
<Badge color={severityColor(alert.severity)}>
{alert.severity === "OVERDUE" ? "Overdue" : "Due Soon"}
</Badge>
<Text size="sm" c="dimmed">
{alert.daysUntil < 0
? `${Math.abs(alert.daysUntil)}d ago`
: `in ${alert.daysUntil}d`}
</Text>
</Group>
<Text fw={600}>{alert.label}</Text>
<Text size="sm" c="dimmed">
{alert.vehiclePlate || alert.vehicleId}
</Text>
<Text size="sm" mt="xs">
Expires {formatDate(alert.expiryDate)}
</Text>
</Card>
</Grid.Col>
))}
</Grid>
)}
{/* Records */}
<Title order={3} mb="sm">
Compliance Records
</Title>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Document #</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expiry</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoadingRecords ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : (records as ComplianceRecord[]).length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">
No compliance records yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{(records as ComplianceRecord[]).map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
<Badge variant="light" size="sm">
{record.type}
</Badge>
</Table.Td>
<Table.Td>{record.documentNumber || "—"}</Table.Td>
<Table.Td>{formatDate(record.issuedDate)}</Table.Td>
<Table.Td>{formatDate(record.expiryDate)}</Table.Td>
<Table.Td>
<Badge color={statusColor(record.status)} size="sm">
{record.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
{/* Modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title="New Compliance Record"
size="lg"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={formData.vehicleId}
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
searchable
required
/>
<Select
label="Type"
data={COMPLIANCE_TYPES.map((t) => ({ value: t, label: t }))}
value={formData.type}
onChange={(val) =>
setFormData({ ...formData, type: (val as ComplianceType) || "INSPECTION" })
}
required
/>
<TextInput
label="Document Number"
placeholder="Optional"
value={formData.documentNumber}
onChange={(e) =>
setFormData({ ...formData, documentNumber: e.currentTarget.value })
}
/>
<TextInput
label="Issued Date"
type="date"
value={formData.issuedDate}
onChange={(e) =>
setFormData({ ...formData, issuedDate: e.currentTarget.value })
}
/>
<TextInput
label="Expiry Date"
type="date"
value={formData.expiryDate}
onChange={(e) =>
setFormData({ ...formData, expiryDate: e.currentTarget.value })
}
required
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createMutation.mutate(formData)}
loading={createMutation.isPending}
disabled={!formData.vehicleId || !formData.expiryDate}
>
Create Record
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
@@ -17,11 +18,18 @@ import {
Timeline,
Title,
} from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import {
ArrowLeft,
Download,
Eye,
FileText,
History,
Route,
ShieldCheck,
Trash2,
Upload,
Truck,
User,
} from "lucide-react";
@@ -29,6 +37,8 @@ import {
import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
import { useToast } from "@/hooks/use-toast";
const fmtDate = (iso?: string | null) => {
if (!iso) return "—";
@@ -55,6 +65,178 @@ const Loading = () => (
<Center py="xl"><Loader size="sm" /></Center>
);
const fmtSize = (bytes: number) => {
if (!bytes) return "—";
const kb = bytes / 1024;
return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`;
};
/** Upload-area setting code configured on the File Settings page. */
const DRIVER_DOCS_CODE = "driver_docs";
// Field key the FALLBACK setting is keyed on (used only when the "driver_docs"
// upload area hasn't been configured in File Settings yet).
const DRIVER_DOCS_KEY = "driver_docs";
/** Fallback single-field setting so the dropzone still works before an admin
* configures the "driver_docs" area in File Settings. */
const DRIVER_DOCS_FALLBACK: IFileUploadSetting = {
id: "driver-docs-setting",
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "driver_docs",
label: "Driver documents",
description: null,
entity: "other",
fields: [
{
id: "driver-docs-field",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "driver-docs-setting",
fileKey: DRIVER_DOCS_KEY,
fileLabel: "Upload driver document(s)",
helpText: "License, national ID, contracts, training certificates, etc.",
isRequired: false,
isMultiple: true,
maxFiles: 20,
allowedExtensions: ["pdf", "png", "jpg", "jpeg", "doc", "docx"],
maxSizeMb: 10,
order: 1,
},
],
};
/** Driver documents upload + view area (files stored under code "driver_docs"). */
const DriverDocuments = ({ driverId }: { driverId: string }) => {
const { toast } = useToast();
const qc = useQueryClient();
// Files selected per configured field key (SmartFileInput is multi-field).
const [selectedMap, setSelectedMap] = useState<Record<string, File | File[] | null>>({});
const selectedFiles = Object.values(selectedMap).flatMap((v) =>
Array.isArray(v) ? v : v ? [v] : [],
);
// Upload-area configuration from the File Settings page (code "driver_docs").
// Falls back to a default field until an admin configures it there.
const { data: setting } = useQuery({
queryKey: ["file-upload-setting", DRIVER_DOCS_CODE],
queryFn: () => fileUploadSettingsService.getByCode(DRIVER_DOCS_CODE),
retry: false,
});
const activeSetting = setting ?? DRIVER_DOCS_FALLBACK;
const { data: docs = [], isLoading } = useQuery({
queryKey: ["driver", driverId, "documents"],
queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []),
enabled: Boolean(driverId),
});
const uploadMutation = useMutation({
mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files),
onSuccess: () => {
toast({ title: "Documents uploaded" });
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Upload failed";
toast({ title: "Upload failed", description, variant: "destructive" });
},
});
const removeMutation = useMutation({
mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId),
onSuccess: () => {
toast({ title: "Document deleted" });
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
// /files/:id is a public inline-serving route; open directly for preview/download.
const fileUrl = (fileId: string, download = false) =>
`${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
return (
<Card withBorder padding="lg" radius="md">
<Stack gap="md" mb="lg">
<Text fw={600} size="sm">{activeSetting.label ?? "Upload documents"}</Text>
<SmartFileInput
file={activeSetting}
value={selectedMap}
onChange={setSelectedMap}
/>
<Group justify="flex-end">
<Button
size="xs"
leftSection={<Upload size={14} />}
loading={uploadMutation.isPending}
disabled={selectedFiles.length === 0}
onClick={() =>
uploadMutation.mutate(selectedFiles, { onSuccess: () => setSelectedMap({}) })
}
>
Upload {selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""}
</Button>
</Group>
</Stack>
<Text fw={600} size="sm" mb="sm">Uploaded documents ({docs.length})</Text>
{isLoading ? (
<Loading />
) : docs.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No documents uploaded yet.</Text>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Uploaded</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{docs.map((doc) => (
<Table.Tr key={doc.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<FileText size={15} />
<Text size="sm" truncate>{doc.name}</Text>
</Group>
</Table.Td>
<Table.Td>{fmtSize(doc.size)}</Table.Td>
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" aria-label="View" onClick={() => window.open(fileUrl(doc.id), "_blank")}>
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" aria-label="Download" onClick={() => window.open(fileUrl(doc.id, true), "_blank")}>
<Download size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label="Delete"
loading={removeMutation.isPending && removeMutation.variables === doc.id}
onClick={() => removeMutation.mutate(doc.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
);
};
const DriverDetailPage = () => {
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -106,6 +288,7 @@ const DriverDetailPage = () => {
<Tabs.Tab value="vehicles" leftSection={<Truck size={14} />}>Vehicles</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="trips" leftSection={<Route size={14} />}>Trips</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={14} />}>Documents</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
@@ -149,6 +332,10 @@ const DriverDetailPage = () => {
<Tabs.Panel value="trips" pt="lg">
<TripsTab driverId={id} />
</Tabs.Panel>
<Tabs.Panel value="documents" pt="lg">
<DriverDocuments driverId={id} />
</Tabs.Panel>
</Tabs>
)}
</Container>

View File

@@ -394,7 +394,7 @@ const FleetResourcePage = () => {
<Group key={filter.key} gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...filter.data].map((option) => (
{filter.data.map((option) => (
<Button
key={option.value}
size="xs"

View File

@@ -0,0 +1,399 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
Textarea,
TextInput,
Title,
Badge,
Grid,
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import {
incidentsService,
type Incident,
type IncidentSeverity,
type IncidentStatus,
type IncidentType,
type SaveIncidentPayload,
} from "@/services/incidents.service";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
import { driversService, type Driver as DriverType } from "@/services/drivers.service";
const TYPE_OPTIONS: IncidentType[] = [
"ACCIDENT",
"BREAKDOWN",
"TRAFFIC_VIOLATION",
"THEFT",
"OTHER",
];
const SEVERITY_OPTIONS: IncidentSeverity[] = ["MINOR", "MODERATE", "MAJOR", "CRITICAL"];
const TYPE_COLORS: Record<IncidentType, string> = {
ACCIDENT: "red",
BREAKDOWN: "orange",
TRAFFIC_VIOLATION: "yellow",
THEFT: "grape",
OTHER: "gray",
};
const SEVERITY_COLORS: Record<IncidentSeverity, string> = {
MINOR: "gray",
MODERATE: "yellow",
MAJOR: "orange",
CRITICAL: "red",
};
const STATUS_COLORS: Record<IncidentStatus, string> = {
REPORTED: "blue",
UNDER_REVIEW: "yellow",
CLAIM_FILED: "grape",
RESOLVED: "teal",
CLOSED: "gray",
};
const OPEN_STATUSES: IncidentStatus[] = ["REPORTED", "UNDER_REVIEW", "CLAIM_FILED"];
const formatMoney = (value: unknown) =>
`ETB ${(Number(value) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const initialForm = {
type: "ACCIDENT" as IncidentType,
severity: "MINOR" as IncidentSeverity,
occurredAt: new Date().toISOString().split("T")[0],
vehicleId: "",
driverId: "",
location: "",
description: "",
damageEstimate: undefined as number | undefined,
reportedBy: "",
};
export default function IncidentsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [formData, setFormData] = useState(initialForm);
// Fetch vehicles
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "incidents-select"],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Fetch drivers
const { data: driversData } = useQuery({
queryKey: ["drivers", "incidents-select"],
queryFn: async () => {
const res = await driversService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Fetch incidents
const { data: incidentsData = [], isLoading } = useQuery({
queryKey: ["incidents"],
queryFn: async () => {
const res = await incidentsService.getAll();
return res.data || [];
},
});
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload: SaveIncidentPayload = {
type: data.type,
severity: data.severity,
occurredAt: new Date(data.occurredAt).toISOString(),
description: data.description,
};
if (data.vehicleId) payload.vehicleId = data.vehicleId;
if (data.driverId) payload.driverId = data.driverId;
if (data.location) payload.location = data.location;
if (data.damageEstimate != null) payload.damageEstimate = Number(data.damageEstimate);
if (data.reportedBy) payload.reportedBy = data.reportedBy;
const res = await incidentsService.create(payload);
return res.data;
},
onSuccess: () => {
toast({ title: "Incident reported" });
setModalOpen(false);
setFormData(initialForm);
qc.invalidateQueries({ queryKey: ["incidents"] });
},
onError: (error: any) => {
toast({
title: "Error reporting incident",
description: error?.response?.data?.message || "Failed to report incident",
variant: "destructive",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const driverOptions =
driversData?.map((d: DriverType) => ({
value: d.id,
label: `${d.firstName} ${d.lastName}${d.licenseNumber ? ` (${d.licenseNumber})` : ""}`,
})) || [];
const incidents = incidentsData as Incident[];
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
const resolvedCount = incidents.filter((i) => i.status === "RESOLVED").length;
const vehicleLabel = (incident: Incident) =>
incident.vehicle?.plateNumber ||
incident.vehicle?.registrationNumber ||
incident.vehicleId ||
"—";
const driverLabel = (incident: Incident) => {
if (incident.driver) {
const name = `${incident.driver.firstName ?? ""} ${incident.driver.lastName ?? ""}`.trim();
if (name) return name;
}
return incident.driverId || "—";
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Incidents" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Accidents &amp; Incidents</Title>
<Button leftSection={<Plus size={16} />} onClick={() => setModalOpen(true)} color="edr-green">
Report Incident
</Button>
</Group>
{/* Stats Cards */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Incidents
</Text>
<Text fw={700} size="lg">
{totalCount}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Open
</Text>
<Text fw={700} size="lg">
{openCount}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Under Review
</Text>
<Text fw={700} size="lg">
{underReviewCount}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Resolved
</Text>
<Text fw={700} size="lg">
{resolvedCount}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Incidents Table */}
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Severity</Table.Th>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th align="right">Damage</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : incidents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No incidents recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{incidents.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<Badge size="sm" color={TYPE_COLORS[incident.type]}>
{incident.type.replace(/_/g, " ")}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" color={SEVERITY_COLORS[incident.severity]}>
{incident.severity}
</Badge>
</Table.Td>
<Table.Td>{vehicleLabel(incident)}</Table.Td>
<Table.Td>{driverLabel(incident)}</Table.Td>
<Table.Td align="right">
{incident.damageEstimate != null ? formatMoney(incident.damageEstimate) : "—"}
</Table.Td>
<Table.Td>
<Badge size="sm" color={STATUS_COLORS[incident.status]} variant="light">
{incident.status.replace(/_/g, " ")}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
{/* Modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Report Incident" size="lg">
<Stack gap="md">
<Select
label="Type"
data={TYPE_OPTIONS.map((t) => ({ value: t, label: t.replace(/_/g, " ") }))}
value={formData.type}
onChange={(val) => setFormData({ ...formData, type: (val as IncidentType) || "ACCIDENT" })}
required
/>
<Select
label="Severity"
data={SEVERITY_OPTIONS.map((s) => ({ value: s, label: s }))}
value={formData.severity}
onChange={(val) =>
setFormData({ ...formData, severity: (val as IncidentSeverity) || "MINOR" })
}
required
/>
<TextInput
label="Occurred At"
type="date"
value={formData.occurredAt}
onChange={(e) => setFormData({ ...formData, occurredAt: e.currentTarget.value })}
required
/>
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={formData.vehicleId || null}
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
clearable
searchable
/>
<Select
label="Driver"
placeholder="Select driver"
data={driverOptions}
value={formData.driverId || null}
onChange={(val) => setFormData({ ...formData, driverId: val || "" })}
clearable
searchable
/>
<Textarea
label="Description"
placeholder="What happened?"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.currentTarget.value })}
minRows={3}
required
/>
<NumberInput
label="Damage Estimate (ETB)"
placeholder="0.00"
value={formData.damageEstimate}
onChange={(val) =>
setFormData({ ...formData, damageEstimate: val as number | undefined })
}
decimalScale={2}
min={0}
thousandSeparator=","
/>
<TextInput
label="Location"
placeholder="Where did it happen?"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.currentTarget.value })}
/>
<TextInput
label="Reported By"
placeholder="Optional"
value={formData.reportedBy}
onChange={(e) => setFormData({ ...formData, reportedBy: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createMutation.mutate(formData)}
loading={createMutation.isPending}
disabled={!formData.description.trim()}
>
Report Incident
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,665 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Switch,
Table,
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService, type Vehicle } from "@/services/vehicles.service";
import {
procurementService,
type AssetAcquisition,
type AssetDisposal,
type Vendor,
type AcquisitionType,
type AcquisitionStatus,
type VendorType,
type DisposalMethod,
} from "@/services/procurement.service";
const money = (x: number | null | undefined) =>
`ETB ${(Number(x) || 0).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const ACQUISITION_TYPES: AcquisitionType[] = ["PURCHASE", "LEASE", "RENTAL"];
const ACQUISITION_STATUSES: AcquisitionStatus[] = ["ACTIVE", "LEASE_EXPIRING", "DISPOSED"];
const VENDOR_TYPES: VendorType[] = ["DEALER", "LEASING", "PARTS", "SERVICE", "OTHER"];
const DISPOSAL_METHODS: DisposalMethod[] = ["SALE", "SCRAP", "RETURN_LEASE", "TRADE_IN"];
const typeBadgeColor = (t: AcquisitionType) =>
t === "PURCHASE" ? "green" : t === "LEASE" ? "blue" : "grape";
const statusBadgeColor = (s: AcquisitionStatus) =>
s === "ACTIVE" ? "green" : s === "LEASE_EXPIRING" ? "yellow" : "gray";
const vehicleLabel = (
v?: { plateNumber?: string | null; registrationNumber?: string | null } | null,
fallback?: string | null,
) => v?.plateNumber || v?.registrationNumber || fallback || "—";
// Strip empty strings / null / undefined before sending to the API (ValidationPipe rejects "" for UUID fields).
const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== "" && v !== undefined && v !== null),
) as Partial<T>;
const emptyAcquisition = {
vehicleId: "",
vendorId: "",
acquisitionType: "PURCHASE" as AcquisitionType,
acquisitionDate: new Date().toISOString().split("T")[0],
cost: undefined as number | undefined,
usefulLifeMonths: undefined as number | undefined,
salvageValue: undefined as number | undefined,
leaseStart: "",
leaseEnd: "",
monthlyPayment: undefined as number | undefined,
status: "ACTIVE" as AcquisitionStatus,
notes: "",
};
const emptyVendor = {
name: "",
type: "" as VendorType | "",
contactPerson: "",
phone: "",
email: "",
address: "",
isActive: true,
};
const emptyDisposal = {
vehicleId: "",
disposalDate: new Date().toISOString().split("T")[0],
method: "SALE" as DisposalMethod,
salePrice: undefined as number | undefined,
buyer: "",
notes: "",
};
export default function ProcurementPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [tab, setTab] = useState<string>("acquisitions");
const [acqModalOpen, setAcqModalOpen] = useState(false);
const [vendorModalOpen, setVendorModalOpen] = useState(false);
const [disposalModalOpen, setDisposalModalOpen] = useState(false);
const [acqForm, setAcqForm] = useState({ ...emptyAcquisition });
const [vendorForm, setVendorForm] = useState({ ...emptyVendor });
const [disposalForm, setDisposalForm] = useState({ ...emptyDisposal });
// ---- Queries ----
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "list"],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: acquisitions = [], isLoading: loadingAcquisitions } = useQuery({
queryKey: ["procurement", "acquisitions"],
queryFn: async () => {
const res = await procurementService.listAcquisitions();
return res.data || [];
},
});
const { data: vendors = [], isLoading: loadingVendors } = useQuery({
queryKey: ["procurement", "vendors"],
queryFn: async () => {
const res = await procurementService.listVendors();
return res.data || [];
},
});
const { data: disposals = [], isLoading: loadingDisposals } = useQuery({
queryKey: ["procurement", "disposals"],
queryFn: async () => {
const res = await procurementService.listDisposals();
return res.data || [];
},
});
const vehicleOptions =
vehiclesData?.map((v: Vehicle) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const vendorOptions = vendors.map((v: Vendor) => ({ value: v.id, label: v.name }));
// ---- Mutations ----
const createAcquisition = useMutation({
mutationFn: async () => {
const res = await procurementService.createAcquisition(clean(acqForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Acquisition recorded" });
setAcqModalOpen(false);
setAcqForm({ ...emptyAcquisition });
qc.invalidateQueries({ queryKey: ["procurement", "acquisitions"] });
},
onError: (error: any) => {
toast({
title: "Error recording acquisition",
description: error?.response?.data?.message || "Failed to record acquisition",
variant: "destructive",
});
},
});
const createVendor = useMutation({
mutationFn: async () => {
const res = await procurementService.createVendor(clean(vendorForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Vendor created" });
setVendorModalOpen(false);
setVendorForm({ ...emptyVendor });
qc.invalidateQueries({ queryKey: ["procurement", "vendors"] });
},
onError: (error: any) => {
toast({
title: "Error creating vendor",
description: error?.response?.data?.message || "Failed to create vendor",
variant: "destructive",
});
},
});
const createDisposal = useMutation({
mutationFn: async () => {
const res = await procurementService.createDisposal(clean(disposalForm) as never);
return res.data;
},
onSuccess: () => {
toast({ title: "Disposal recorded" });
setDisposalModalOpen(false);
setDisposalForm({ ...emptyDisposal });
qc.invalidateQueries({ queryKey: ["procurement", "disposals"] });
},
onError: (error: any) => {
toast({
title: "Error recording disposal",
description: error?.response?.data?.message || "Failed to record disposal",
variant: "destructive",
});
},
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Procurement" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Procurement & Assets</Title>
</Group>
<Tabs value={tab} onChange={(val) => setTab(val || "acquisitions")}>
<Tabs.List mb="lg">
<Tabs.Tab value="acquisitions">Acquisitions</Tabs.Tab>
<Tabs.Tab value="vendors">Vendors</Tabs.Tab>
<Tabs.Tab value="disposals">Disposals</Tabs.Tab>
</Tabs.List>
{/* ---- Acquisitions ---- */}
<Tabs.Panel value="acquisitions">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setAcqModalOpen(true)}
color="edr-green"
>
New Acquisition
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingAcquisitions ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : acquisitions.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="md">
No acquisitions recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{acquisitions.map((a: AssetAcquisition) => (
<Table.Tr key={a.id}>
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
<Table.Td>
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
{a.acquisitionType}
</Badge>
</Table.Td>
<Table.Td>{new Date(a.acquisitionDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{money(a.cost)}</Table.Td>
<Table.Td>
<Badge size="sm" color={statusBadgeColor(a.status)}>
{a.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
{/* ---- Vendors ---- */}
<Tabs.Panel value="vendors">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setVendorModalOpen(true)}
color="edr-green"
>
New Vendor
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Contact</Table.Th>
<Table.Th>Phone</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Active</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingVendors ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : vendors.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">
No vendors added yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{vendors.map((v: Vendor) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.type ? <Badge size="sm">{v.type}</Badge> : "—"}</Table.Td>
<Table.Td>{v.contactPerson || "—"}</Table.Td>
<Table.Td>{v.phone || "—"}</Table.Td>
<Table.Td>{v.email || "—"}</Table.Td>
<Table.Td>
<Badge size="sm" color={v.isActive ? "green" : "gray"}>
{v.isActive ? "Active" : "Inactive"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
{/* ---- Disposals ---- */}
<Tabs.Panel value="disposals">
<Group justify="flex-end" mb="md">
<Button
leftSection={<Plus size={16} />}
onClick={() => setDisposalModalOpen(true)}
color="edr-green"
>
New Disposal
</Button>
</Group>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Sale Price</Table.Th>
<Table.Th>Buyer</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadingDisposals ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : disposals.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="md">
No disposals recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{disposals.map((d: AssetDisposal) => (
<Table.Tr key={d.id}>
<Table.Td>{d.vehicleId}</Table.Td>
<Table.Td>
<Badge size="sm">{d.method}</Badge>
</Table.Td>
<Table.Td>{new Date(d.disposalDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{money(d.salePrice)}</Table.Td>
<Table.Td>{d.buyer || "—"}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
</Tabs>
{/* ---- Acquisition Modal ---- */}
<Modal
opened={acqModalOpen}
onClose={() => setAcqModalOpen(false)}
title="New Acquisition"
size="lg"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={acqForm.vehicleId}
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
searchable
clearable
/>
<Select
label="Vendor"
placeholder="Select vendor"
data={vendorOptions}
value={acqForm.vendorId}
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
searchable
clearable
/>
<Select
label="Acquisition Type"
data={ACQUISITION_TYPES}
value={acqForm.acquisitionType}
onChange={(val) =>
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
}
required
/>
<TextInput
label="Acquisition Date"
type="date"
value={acqForm.acquisitionDate}
onChange={(e) => setAcqForm({ ...acqForm, acquisitionDate: e.currentTarget.value })}
required
/>
<NumberInput
label="Cost"
placeholder="0.00"
value={acqForm.cost}
onChange={(val) => setAcqForm({ ...acqForm, cost: val as number | undefined })}
decimalScale={2}
min={0}
/>
<NumberInput
label="Useful Life (months)"
placeholder="Optional"
value={acqForm.usefulLifeMonths}
onChange={(val) =>
setAcqForm({ ...acqForm, usefulLifeMonths: val as number | undefined })
}
decimalScale={0}
min={0}
/>
<NumberInput
label="Salvage Value"
placeholder="0.00"
value={acqForm.salvageValue}
onChange={(val) => setAcqForm({ ...acqForm, salvageValue: val as number | undefined })}
decimalScale={2}
min={0}
/>
<TextInput
label="Lease Start"
type="date"
value={acqForm.leaseStart}
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
/>
<TextInput
label="Lease End"
type="date"
value={acqForm.leaseEnd}
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
/>
<NumberInput
label="Monthly Payment"
placeholder="0.00"
value={acqForm.monthlyPayment}
onChange={(val) =>
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
}
decimalScale={2}
min={0}
/>
<Select
label="Status"
data={ACQUISITION_STATUSES}
value={acqForm.status}
onChange={(val) =>
setAcqForm({ ...acqForm, status: (val as AcquisitionStatus) || "ACTIVE" })
}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={acqForm.notes}
onChange={(e) => setAcqForm({ ...acqForm, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setAcqModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createAcquisition.mutate()}
loading={createAcquisition.isPending}
disabled={!acqForm.acquisitionDate}
>
Save Acquisition
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Vendor Modal ---- */}
<Modal
opened={vendorModalOpen}
onClose={() => setVendorModalOpen(false)}
title="New Vendor"
size="lg"
>
<Stack gap="md">
<TextInput
label="Name"
placeholder="Vendor name"
value={vendorForm.name}
onChange={(e) => setVendorForm({ ...vendorForm, name: e.currentTarget.value })}
required
/>
<Select
label="Type"
placeholder="Select type"
data={VENDOR_TYPES}
value={vendorForm.type || null}
onChange={(val) => setVendorForm({ ...vendorForm, type: (val as VendorType) || "" })}
clearable
/>
<TextInput
label="Contact Person"
placeholder="Optional"
value={vendorForm.contactPerson}
onChange={(e) => setVendorForm({ ...vendorForm, contactPerson: e.currentTarget.value })}
/>
<TextInput
label="Phone"
placeholder="Optional"
value={vendorForm.phone}
onChange={(e) => setVendorForm({ ...vendorForm, phone: e.currentTarget.value })}
/>
<TextInput
label="Email"
placeholder="Optional"
value={vendorForm.email}
onChange={(e) => setVendorForm({ ...vendorForm, email: e.currentTarget.value })}
/>
<TextInput
label="Address"
placeholder="Optional"
value={vendorForm.address}
onChange={(e) => setVendorForm({ ...vendorForm, address: e.currentTarget.value })}
/>
<Switch
label="Active"
checked={vendorForm.isActive}
onChange={(e) => setVendorForm({ ...vendorForm, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setVendorModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createVendor.mutate()}
loading={createVendor.isPending}
disabled={!vendorForm.name}
>
Save Vendor
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Disposal Modal ---- */}
<Modal
opened={disposalModalOpen}
onClose={() => setDisposalModalOpen(false)}
title="New Disposal"
size="lg"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={disposalForm.vehicleId}
onChange={(val) => setDisposalForm({ ...disposalForm, vehicleId: val || "" })}
searchable
required
/>
<TextInput
label="Disposal Date"
type="date"
value={disposalForm.disposalDate}
onChange={(e) =>
setDisposalForm({ ...disposalForm, disposalDate: e.currentTarget.value })
}
required
/>
<Select
label="Method"
data={DISPOSAL_METHODS}
value={disposalForm.method}
onChange={(val) =>
setDisposalForm({ ...disposalForm, method: (val as DisposalMethod) || "SALE" })
}
required
/>
<NumberInput
label="Sale Price"
placeholder="0.00"
value={disposalForm.salePrice}
onChange={(val) =>
setDisposalForm({ ...disposalForm, salePrice: val as number | undefined })
}
decimalScale={2}
min={0}
/>
<TextInput
label="Buyer"
placeholder="Optional"
value={disposalForm.buyer}
onChange={(e) => setDisposalForm({ ...disposalForm, buyer: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={disposalForm.notes}
onChange={(e) => setDisposalForm({ ...disposalForm, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDisposalModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createDisposal.mutate()}
loading={createDisposal.isPending}
disabled={!disposalForm.vehicleId || !disposalForm.disposalDate}
>
Save Disposal
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -1,214 +1,347 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Grid,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import {
APIProvider,
InfoWindow,
Map as GoogleMap,
Marker,
useMap,
} from "@vis.gl/react-google-maps";
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
// Same default key + env override the portal's LocationPicker uses.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
v == null || v === "" ? null : Number(v);
const deviceLabel = (d: GpsDevice) =>
d.vehicle
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
: d.name || d.imei;
const fmtTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
</Box>
);
type LatLng = { lat: number; lng: number };
/** Flip a flag once the map (and thus the Maps JS classes) is loaded. */
function ReadyProbe({ onReady }: { onReady: () => void }) {
const map = useMap();
useEffect(() => {
if (map) onReady();
}, [map, onReady]);
return null;
}
interface GPSLocation {
/** Fit the map to the current markers (or center on a single one). */
function FitBounds({ points }: { points: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || points.length === 0 || typeof google === "undefined") return;
if (points.length === 1) {
map.setCenter(points[0]);
map.setZoom(14);
return;
}
const b = new google.maps.LatLngBounds();
points.forEach((p) => b.extend(p));
map.fitBounds(b, 60);
}, [map, points]);
return null;
}
/** Lazily reverse-geocode a coordinate to a human address. */
function useAddress(lat: number, lng: number): string | null {
const [addr, setAddr] = useState<string | null>(null);
useEffect(() => {
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
setAddr(null);
let cancelled = false;
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
if (cancelled) return;
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
});
return () => { cancelled = true; };
}, [lat, lng]);
return addr;
}
/** Hover popup: label, coordinates, speed/course, and the reverse-geocoded place. */
function HoverInfo({
device,
lat,
lng,
onClose,
}: {
device: GpsDevice;
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
onClose: () => void;
}) {
const address = useAddress(lat, lng);
return (
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
<div style={{ minWidth: 190, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
<div style={{ fontFamily: "monospace" }}>
{lat.toFixed(5)}, {lng.toFixed(5)}
</div>
<div style={{ color: "#555" }}>
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
</div>
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
</div>
</InfoWindow>
);
}
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
/** Draw the selected vehicle's recent path as a polyline. */
function RouteTrail({ path }: { path: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || path.length < 2 || typeof google === "undefined") return;
const line = new google.maps.Polyline({
path,
strokeColor: freightBrand.primary,
strokeOpacity: 0.85,
strokeWeight: 4,
});
line.setMap(map);
return () => line.setMap(null);
}, [map, path]);
return null;
}
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const { toast } = useToast();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editDevice, setEditDevice] = useState<GpsDevice | null>(null);
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
const openRegister = () => {
setEditDevice(null);
setForm({ imei: "", name: "", vehicleId: "" });
setModalOpen(true);
};
const openEdit = (d: GpsDevice) => {
setEditDevice(d);
setForm({ imei: d.imei, name: d.name ?? "", vehicleId: d.vehicleId ?? "" });
setModalOpen(true);
};
// Poll every 10s so the map tracks live movement.
const { data: devices = [] } = useQuery({
queryKey: ["gps", "devices"],
queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [],
refetchInterval: 10_000,
});
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
(vehiclesData ?? []).map((v) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})),
[vehiclesData],
);
const positioned = useMemo(
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
[devices],
);
const selected = devices.find((d) => d.id === selectedId) ?? null;
const onlineCount = devices.filter((d) => d.online).length;
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
// Teardrop pin colored by state with a white truck glyph inside.
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
// Maps API loads async — Size/Point classes may not exist yet at first render.
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 18V6a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h1"/>
<path d="M15 18H9"/>
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
<circle cx="7" cy="18" r="2"/>
<circle cx="17" cy="18" r="2"/>
</g>
</svg>`;
return {
url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
scaledSize: new google.maps.Size(40, 48),
anchor: new google.maps.Point(20, 48),
};
};
const saveMutation = useMutation({
mutationFn: () =>
editDevice
? gpsTrackingService.update(editDevice.id, {
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
})
: gpsTrackingService.register({
imei: form.imei.trim(),
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
}),
onSuccess: () => {
toast({ title: editDevice ? "Tracker updated" : "Tracker registered" });
setModalOpen(false);
setEditDevice(null);
setForm({ imei: "", name: "", vehicleId: "" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(),
}));
}, [vehicles]);
const assignMutation = useMutation({
mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) =>
gpsTrackingService.update(id, { vehicleId }),
onSuccess: () => {
toast({ title: "Tracker updated" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Update failed", variant: "destructive" }),
});
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
const trackableVehicles = useMemo(
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
[vehiclesWithGPS]
);
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
[trackableVehicles]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
const deleteMutation = useMutation({
mutationFn: (id: string) => gpsTrackingService.remove(id),
onSuccess: () => {
toast({ title: "Tracker removed" });
setSelectedId(null);
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Stack gap="xl">
<Group justify="space-between">
<Group justify="space-between" mb="xl">
<div>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
</Group>
<Grid>
{/* Map Section */}
{/* Map */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Text fw={500}>Live Map</Text>
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{trackableVehicles.length} Tracked
{onlineCount} online · {positioned.length} located
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{trackableVehicles.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
) : null;
})()}
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
No located trackers yet waiting for GPS fixes.
</Text>
)}
</Card.Section>
</Card>
</Grid.Col>
@@ -216,144 +349,97 @@ export function TrackingPage() {
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
{selected && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
Showing last {trail.length} fixes as a route trail.
</Text>
)}
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
<Select
label="Assigned vehicle"
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
searchable
clearable
/>
</Stack>
</Card>
)}
{/* Tracked Vehicles List */}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Text fw={500}>Trackers ({devices.length})</Text>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
{devices.map((d) => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
{v.status || 'N/A'}
</Badge>
<Pencil size={15} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
@@ -362,7 +448,53 @@ export function TrackingPage() {
</Stack>
</Grid.Col>
</Grid>
{/* Register / edit modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editDevice ? "Edit GPS tracker" : "Register GPS tracker"}
radius="lg"
centered
>
<Stack gap="md">
<TextInput
label="IMEI"
placeholder="15-digit device IMEI"
required
disabled={Boolean(editDevice)}
value={form.imei}
onChange={(e) => setForm({ ...form, imei: e.currentTarget.value })}
/>
<TextInput
label="Name"
placeholder="Optional label"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
/>
<Select
label="Vehicle"
placeholder="Assign to a vehicle (optional)"
data={vehicleOptions}
value={form.vehicleId || null}
onChange={(v) => setForm({ ...form, vehicleId: v ?? "" })}
searchable
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
loading={saveMutation.isPending}
disabled={!form.imei.trim()}
onClick={() => saveMutation.mutate()}
>
{editDevice ? "Save" : "Register"}
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default TrackingPage;

View File

@@ -0,0 +1,830 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Button,
Modal,
Stack,
Group,
Select,
TextInput,
Textarea,
NumberInput,
Table,
Badge,
Text,
Title,
Container,
Tabs,
Loader,
Switch,
ActionIcon,
} from '@mantine/core';
import { Plus, Trash2, Pencil, Wrench, Package, ShieldCheck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
maintenanceDepthService,
type WorkOrder,
type WorkOrderStatus,
type WorkOrderPriority,
type Part,
type Warranty,
} from '@/services/maintenance-depth.service';
import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service';
const WORK_ORDER_STATUSES: WorkOrderStatus[] = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
const WORK_ORDER_PRIORITIES: WorkOrderPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'];
const PART_CATEGORIES = ['TIRE', 'ENGINE', 'BRAKE', 'ELECTRICAL', 'FILTER', 'FLUID', 'OTHER'];
const etb = (x: number | string | null | undefined) =>
`ETB ${(Number(x) || 0).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const statusColor = (status: string) => {
const colors: Record<string, string> = {
OPEN: 'edr-blue',
IN_PROGRESS: 'edr-amber-soft',
COMPLETED: 'edr-green',
CANCELLED: 'edr-slate',
};
return colors[status] || 'edr-slate';
};
const priorityColor = (priority: string) => {
const colors: Record<string, string> = {
LOW: 'edr-slate',
MEDIUM: 'edr-blue',
HIGH: 'edr-amber-soft',
URGENT: 'edr-red',
};
return colors[priority] || 'edr-slate';
};
const emptyWorkOrder = {
vehicleId: '',
title: '',
description: '',
status: 'OPEN' as WorkOrderStatus,
priority: 'MEDIUM' as WorkOrderPriority,
assignedTo: '',
laborCost: 0,
partsCost: 0,
};
const emptyPart = {
name: '',
sku: '',
category: 'TIRE',
quantityInStock: 0,
reorderLevel: 0,
unitCost: 0,
location: '',
};
const emptyWarranty = {
vehicleId: '',
component: '',
provider: '',
startDate: '',
expiryDate: new Date().toISOString().split('T')[0],
coverageNotes: '',
};
export default function WorkOrdersPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<string | null>('work-orders');
// Work orders
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [openWorkOrderModal, setOpenWorkOrderModal] = useState(false);
const [workOrderForm, setWorkOrderForm] = useState(emptyWorkOrder);
// Parts
const [lowStockOnly, setLowStockOnly] = useState(false);
const [openPartModal, setOpenPartModal] = useState(false);
const [editingPartId, setEditingPartId] = useState<string | null>(null);
const [partForm, setPartForm] = useState(emptyPart);
// Warranties
const [openWarrantyModal, setOpenWarrantyModal] = useState(false);
const [warrantyForm, setWarrantyForm] = useState(emptyWarranty);
const { data: vehiclesData } = useQuery({
queryKey: ['vehicles', 'all-for-maintenance-depth'],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: v.plateNumber
? `${v.plateNumber} - ${v.manufacturer} ${v.model}`
: v.registrationNumber || v.id,
})) || [];
const vehicleLabel = (vehicleId: string) =>
vehicleOptions.find((o) => o.value === vehicleId)?.label || vehicleId;
// ---- Work orders queries/mutations ----
const { data: workOrders, isLoading: workOrdersLoading } = useQuery({
queryKey: ['maintenance-work-orders', statusFilter],
queryFn: async () => {
const res = await maintenanceDepthService.getWorkOrders({
status: (statusFilter as WorkOrderStatus) || undefined,
});
return res.data || [];
},
});
const workOrderList: WorkOrder[] = Array.isArray(workOrders) ? workOrders : [];
const createWorkOrderMutation = useMutation({
mutationFn: async () => {
const res = await maintenanceDepthService.createWorkOrder({
vehicleId: workOrderForm.vehicleId,
title: workOrderForm.title,
description: workOrderForm.description || undefined,
status: workOrderForm.status,
priority: workOrderForm.priority,
assignedTo: workOrderForm.assignedTo || undefined,
laborCost: Number(workOrderForm.laborCost) || undefined,
partsCost: Number(workOrderForm.partsCost) || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Work order created' });
queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] });
setOpenWorkOrderModal(false);
setWorkOrderForm(emptyWorkOrder);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const deleteWorkOrderMutation = useMutation({
mutationFn: async (id: string) => {
await maintenanceDepthService.deleteWorkOrder(id);
},
onSuccess: () => {
toast({ title: 'Work order deleted' });
queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] });
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
// ---- Parts queries/mutations ----
const { data: parts, isLoading: partsLoading } = useQuery({
queryKey: ['maintenance-parts', lowStockOnly],
queryFn: async () => {
const res = await maintenanceDepthService.getParts({ lowStock: lowStockOnly || undefined });
return res.data || [];
},
});
const partList: Part[] = Array.isArray(parts) ? parts : [];
const savePartMutation = useMutation({
mutationFn: async () => {
const payload = {
name: partForm.name,
sku: partForm.sku || undefined,
category: partForm.category || undefined,
quantityInStock: Number(partForm.quantityInStock) || 0,
reorderLevel: Number(partForm.reorderLevel) || 0,
unitCost: Number(partForm.unitCost) || undefined,
location: partForm.location || undefined,
};
if (editingPartId) {
const res = await maintenanceDepthService.updatePart(editingPartId, payload);
return res.data;
}
const res = await maintenanceDepthService.createPart(payload);
return res.data;
},
onSuccess: () => {
toast({ title: editingPartId ? 'Part updated' : 'Part created' });
queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] });
setOpenPartModal(false);
setPartForm(emptyPart);
setEditingPartId(null);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const deletePartMutation = useMutation({
mutationFn: async (id: string) => {
await maintenanceDepthService.deletePart(id);
},
onSuccess: () => {
toast({ title: 'Part deleted' });
queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] });
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
// ---- Warranties queries/mutations ----
const { data: warranties, isLoading: warrantiesLoading } = useQuery({
queryKey: ['maintenance-warranties'],
queryFn: async () => {
const res = await maintenanceDepthService.getWarranties();
return res.data || [];
},
});
const warrantyList: Warranty[] = Array.isArray(warranties) ? warranties : [];
const createWarrantyMutation = useMutation({
mutationFn: async () => {
const res = await maintenanceDepthService.createWarranty({
vehicleId: warrantyForm.vehicleId,
component: warrantyForm.component,
provider: warrantyForm.provider || undefined,
startDate: warrantyForm.startDate || undefined,
expiryDate: warrantyForm.expiryDate,
coverageNotes: warrantyForm.coverageNotes || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Warranty created' });
queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] });
setOpenWarrantyModal(false);
setWarrantyForm(emptyWarranty);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const deleteWarrantyMutation = useMutation({
mutationFn: async (id: string) => {
await maintenanceDepthService.deleteWarranty(id);
},
onSuccess: () => {
toast({ title: 'Warranty deleted' });
queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] });
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const openPartForEdit = (part: Part) => {
setEditingPartId(part.id);
setPartForm({
name: part.name,
sku: part.sku || '',
category: part.category || 'OTHER',
quantityInStock: part.quantityInStock,
reorderLevel: part.reorderLevel,
unitCost: Number(part.unitCost) || 0,
location: part.location || '',
});
setOpenPartModal(true);
};
const openPartForCreate = () => {
setEditingPartId(null);
setPartForm(emptyPart);
setOpenPartModal(true);
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Maintenance' }, { label: 'Work Orders' }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Work Orders &amp; Parts</Title>
</Group>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="work-orders" leftSection={<Wrench size={14} />}>
Work Orders
</Tabs.Tab>
<Tabs.Tab value="parts" leftSection={<Package size={14} />}>
Parts / Tires
</Tabs.Tab>
<Tabs.Tab value="warranties" leftSection={<ShieldCheck size={14} />}>
Warranties
</Tabs.Tab>
</Tabs.List>
{/* ---- Work Orders tab ---- */}
<Tabs.Panel value="work-orders" pt="lg">
<Group justify="space-between" mb="md">
<Select
placeholder="All statuses"
data={WORK_ORDER_STATUSES}
value={statusFilter}
onChange={setStatusFilter}
clearable
w={220}
/>
<Button
onClick={() => setOpenWorkOrderModal(true)}
color="edr-green"
leftSection={<Plus size={16} />}
>
New Work Order
</Button>
</Group>
<Card withBorder>
{workOrdersLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : workOrderList.length > 0 ? (
<Table.ScrollContainer minWidth={800}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Title</Table.Th>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Priority</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Assigned</Table.Th>
<Table.Th>Labor</Table.Th>
<Table.Th>Parts</Table.Th>
<Table.Th>Opened</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{workOrderList.map((wo) => (
<Table.Tr key={wo.id}>
<Table.Td>{wo.title}</Table.Td>
<Table.Td>{vehicleLabel(wo.vehicleId)}</Table.Td>
<Table.Td>
<Badge color={priorityColor(wo.priority)}>{wo.priority}</Badge>
</Table.Td>
<Table.Td>
<Badge color={statusColor(wo.status)}>{wo.status}</Badge>
</Table.Td>
<Table.Td>{wo.assignedTo || '—'}</Table.Td>
<Table.Td>{etb(wo.laborCost)}</Table.Td>
<Table.Td>{etb(wo.partsCost)}</Table.Td>
<Table.Td>{new Date(wo.openedAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="edr-red"
onClick={() => deleteWorkOrderMutation.mutate(wo.id)}
aria-label="Delete work order"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text c="dimmed" ta="center" p="xl">
No work orders yet
</Text>
)}
</Card>
</Tabs.Panel>
{/* ---- Parts / Tires tab ---- */}
<Tabs.Panel value="parts" pt="lg">
<Group justify="space-between" mb="md">
<Switch
label="Low stock only"
checked={lowStockOnly}
onChange={(e) => setLowStockOnly(e.currentTarget.checked)}
/>
<Button onClick={openPartForCreate} color="edr-green" leftSection={<Plus size={16} />}>
New Part
</Button>
</Group>
<Card withBorder>
{partsLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : partList.length > 0 ? (
<Table.ScrollContainer minWidth={800}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>SKU</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>In Stock</Table.Th>
<Table.Th>Reorder Level</Table.Th>
<Table.Th>Unit Cost</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{partList.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>{p.name}</Table.Td>
<Table.Td>{p.sku || '—'}</Table.Td>
<Table.Td>
{p.category ? <Badge variant="light">{p.category}</Badge> : '—'}
</Table.Td>
<Table.Td>
<Group gap="xs">
{p.quantityInStock}
{p.quantityInStock <= p.reorderLevel && (
<Badge color="edr-red" size="sm">
Low stock
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>{p.reorderLevel}</Table.Td>
<Table.Td>{etb(p.unitCost)}</Table.Td>
<Table.Td>{p.location || '—'}</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
onClick={() => openPartForEdit(p)}
aria-label="Adjust part"
>
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="edr-red"
onClick={() => deletePartMutation.mutate(p.id)}
aria-label="Delete part"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text c="dimmed" ta="center" p="xl">
No parts in inventory
</Text>
)}
</Card>
</Tabs.Panel>
{/* ---- Warranties tab ---- */}
<Tabs.Panel value="warranties" pt="lg">
<Group justify="flex-end" mb="md">
<Button
onClick={() => setOpenWarrantyModal(true)}
color="edr-green"
leftSection={<Plus size={16} />}
>
New Warranty
</Button>
</Group>
<Card withBorder>
{warrantiesLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : warrantyList.length > 0 ? (
<Table.ScrollContainer minWidth={700}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Component</Table.Th>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Provider</Table.Th>
<Table.Th>Start</Table.Th>
<Table.Th>Expiry</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{warrantyList.map((w) => {
const expired = new Date(w.expiryDate) < new Date();
return (
<Table.Tr key={w.id}>
<Table.Td>{w.component}</Table.Td>
<Table.Td>{vehicleLabel(w.vehicleId)}</Table.Td>
<Table.Td>{w.provider || '—'}</Table.Td>
<Table.Td>
{w.startDate ? new Date(w.startDate).toLocaleDateString() : '—'}
</Table.Td>
<Table.Td>
<Group gap="xs">
{new Date(w.expiryDate).toLocaleDateString()}
<Badge color={expired ? 'edr-red' : 'edr-green'} size="sm">
{expired ? 'Expired' : 'Active'}
</Badge>
</Group>
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="edr-red"
onClick={() => deleteWarrantyMutation.mutate(w.id)}
aria-label="Delete warranty"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Text c="dimmed" ta="center" p="xl">
No warranties recorded
</Text>
)}
</Card>
</Tabs.Panel>
</Tabs>
{/* ---- Work Order modal ---- */}
<Modal
opened={openWorkOrderModal}
onClose={() => setOpenWorkOrderModal(false)}
title="New Work Order"
size="md"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={workOrderForm.vehicleId || null}
onChange={(v) => setWorkOrderForm({ ...workOrderForm, vehicleId: v || '' })}
searchable
required
/>
<TextInput
label="Title"
placeholder="e.g., Replace front brake pads"
value={workOrderForm.title}
onChange={(e) => setWorkOrderForm({ ...workOrderForm, title: e.currentTarget.value })}
required
/>
<Textarea
label="Description"
placeholder="Details of the work needed"
value={workOrderForm.description}
onChange={(e) =>
setWorkOrderForm({ ...workOrderForm, description: e.currentTarget.value })
}
/>
<Group grow>
<Select
label="Priority"
data={WORK_ORDER_PRIORITIES}
value={workOrderForm.priority}
onChange={(v) =>
setWorkOrderForm({ ...workOrderForm, priority: (v as WorkOrderPriority) || 'MEDIUM' })
}
/>
<Select
label="Status"
data={WORK_ORDER_STATUSES}
value={workOrderForm.status}
onChange={(v) =>
setWorkOrderForm({ ...workOrderForm, status: (v as WorkOrderStatus) || 'OPEN' })
}
/>
</Group>
<TextInput
label="Assigned To"
placeholder="e.g., Mechanic name"
value={workOrderForm.assignedTo}
onChange={(e) =>
setWorkOrderForm({ ...workOrderForm, assignedTo: e.currentTarget.value })
}
/>
<Group grow>
<NumberInput
label="Labor Cost (ETB)"
min={0}
value={workOrderForm.laborCost}
onChange={(v) => setWorkOrderForm({ ...workOrderForm, laborCost: Number(v) || 0 })}
/>
<NumberInput
label="Parts Cost (ETB)"
min={0}
value={workOrderForm.partsCost}
onChange={(v) => setWorkOrderForm({ ...workOrderForm, partsCost: Number(v) || 0 })}
/>
</Group>
<Group justify="flex-end">
<Button variant="light" onClick={() => setOpenWorkOrderModal(false)}>
Cancel
</Button>
<Button
onClick={() => createWorkOrderMutation.mutate()}
loading={createWorkOrderMutation.isPending}
disabled={!workOrderForm.vehicleId || !workOrderForm.title}
>
Create
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Part modal ---- */}
<Modal
opened={openPartModal}
onClose={() => {
setOpenPartModal(false);
setEditingPartId(null);
}}
title={editingPartId ? 'Adjust Part' : 'New Part'}
size="md"
>
<Stack gap="md">
<TextInput
label="Name"
placeholder="e.g., 315/80R22.5 Tire"
value={partForm.name}
onChange={(e) => setPartForm({ ...partForm, name: e.currentTarget.value })}
required
/>
<Group grow>
<TextInput
label="SKU"
placeholder="Optional"
value={partForm.sku}
onChange={(e) => setPartForm({ ...partForm, sku: e.currentTarget.value })}
/>
<Select
label="Category"
data={PART_CATEGORIES}
value={partForm.category}
onChange={(v) => setPartForm({ ...partForm, category: v || 'OTHER' })}
/>
</Group>
<Group grow>
<NumberInput
label="Quantity in Stock"
min={0}
value={partForm.quantityInStock}
onChange={(v) => setPartForm({ ...partForm, quantityInStock: Number(v) || 0 })}
/>
<NumberInput
label="Reorder Level"
min={0}
value={partForm.reorderLevel}
onChange={(v) => setPartForm({ ...partForm, reorderLevel: Number(v) || 0 })}
/>
</Group>
<Group grow>
<NumberInput
label="Unit Cost (ETB)"
min={0}
value={partForm.unitCost}
onChange={(v) => setPartForm({ ...partForm, unitCost: Number(v) || 0 })}
/>
<TextInput
label="Location"
placeholder="e.g., Shelf A3"
value={partForm.location}
onChange={(e) => setPartForm({ ...partForm, location: e.currentTarget.value })}
/>
</Group>
<Group justify="flex-end">
<Button
variant="light"
onClick={() => {
setOpenPartModal(false);
setEditingPartId(null);
}}
>
Cancel
</Button>
<Button
onClick={() => savePartMutation.mutate()}
loading={savePartMutation.isPending}
disabled={!partForm.name}
>
{editingPartId ? 'Save' : 'Create'}
</Button>
</Group>
</Stack>
</Modal>
{/* ---- Warranty modal ---- */}
<Modal
opened={openWarrantyModal}
onClose={() => setOpenWarrantyModal(false)}
title="New Warranty"
size="md"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={warrantyForm.vehicleId || null}
onChange={(v) => setWarrantyForm({ ...warrantyForm, vehicleId: v || '' })}
searchable
required
/>
<TextInput
label="Component"
placeholder="e.g., Engine, Transmission"
value={warrantyForm.component}
onChange={(e) => setWarrantyForm({ ...warrantyForm, component: e.currentTarget.value })}
required
/>
<TextInput
label="Provider"
placeholder="e.g., Manufacturer name"
value={warrantyForm.provider}
onChange={(e) => setWarrantyForm({ ...warrantyForm, provider: e.currentTarget.value })}
/>
<Group grow>
<TextInput
label="Start Date"
type="date"
value={warrantyForm.startDate}
onChange={(e) =>
setWarrantyForm({ ...warrantyForm, startDate: e.currentTarget.value })
}
/>
<TextInput
label="Expiry Date"
type="date"
value={warrantyForm.expiryDate}
onChange={(e) =>
setWarrantyForm({ ...warrantyForm, expiryDate: e.currentTarget.value })
}
required
/>
</Group>
<Textarea
label="Coverage Notes"
placeholder="What the warranty covers"
value={warrantyForm.coverageNotes}
onChange={(e) =>
setWarrantyForm({ ...warrantyForm, coverageNotes: e.currentTarget.value })
}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setOpenWarrantyModal(false)}>
Cancel
</Button>
<Button
onClick={() => createWarrantyMutation.mutate()}
loading={createWarrantyMutation.isPending}
disabled={!warrantyForm.vehicleId || !warrantyForm.component || !warrantyForm.expiryDate}
>
Create
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -29,6 +29,11 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
{ label: "Busy", value: "BUSY" },
];
const CURRENCY_OPTIONS = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
@@ -84,12 +89,14 @@ export const vehiclesConfig: FleetResourceConfig = {
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "pricePerKm", label: "Price per KM", type: "number" },
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
],
emptyValues: {
code: "",
code: "03-ET",
plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
@@ -102,6 +109,8 @@ export const vehiclesConfig: FleetResourceConfig = {
locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
pricePerKm: "",
currency: "ETB",
status: "ACTIVE",
availability: "FREE",
description: "",

View File

@@ -20,7 +20,7 @@ import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Autocomplete,
Alert,
Badge,
Box,
Button,
@@ -54,7 +54,6 @@ import {
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
@@ -191,7 +190,24 @@ const isPostPaymentPending = (r: FirstMileRecord) =>
// Map API record → display fields used in modals and trip slip
const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB";
const currencyOf = (r: FirstMileRecord) =>
r.vehicle?.currency ??
r.vehicleAssignments?.[0]?.vehicle?.currency ??
r.booking?.paymentCurrency ??
"ETB";
type FmAssignment = NonNullable<FirstMileRecord["vehicleAssignments"]>[number];
const truckShort = (a: FmAssignment) =>
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
const billingIssues = (r: FirstMileRecord) => {
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
const currencies = [
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
@@ -480,6 +496,8 @@ const FirstMilePage = () => {
const [distanceOpen, setDistanceOpen] = useState(false);
// Per-vehicle actual distance, keyed by vehicleId.
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
// Record pending invoice-generation confirmation (shows a summary first).
const [invoiceConfirm, setInvoiceConfirm] = useState<FirstMileRecord | null>(null);
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
@@ -500,14 +518,6 @@ const FirstMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "FIRST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("FIRST_MILE");
return res.data;
},
});
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
@@ -572,10 +582,17 @@ const FirstMilePage = () => {
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => firstMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
onSuccess: (res) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
const updated = res?.data as FirstMileRecord | undefined;
closeDistance();
// Every truck has a distance and it isn't billed yet → offer to invoice now.
const trucks = updated?.vehicleAssignments ?? [];
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
if (updated && allFilled && !updated.invoice) {
setInvoiceConfirm(updated);
}
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -783,18 +800,9 @@ const FirstMilePage = () => {
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
remainingPayment = total * parseFloat(firstMileRate.rateValue);
}
}
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
// Amount is computed server-side per truck (distance × the vehicle's
// price/km, in the vehicle's currency) — no flat FIRST_MILE rate.
setDistancesMutation.mutate({ id: activeId, distances });
};
const matchesFilter = (r: FirstMileRecord) => {
@@ -849,6 +857,33 @@ const FirstMilePage = () => {
return filteredRecords.slice(start, start + pagination.pageSize);
}, [filteredRecords, pagination]);
// Billing problems on the leg pending invoice confirmation.
const confirmIssues = invoiceConfirm
? billingIssues(invoiceConfirm)
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
// Guard invoice generation: block mixed currency, warn (but proceed) on trucks
// priced at 0/km.
const handleGenerateInvoice = (r: FirstMileRecord) => {
const { zeroPrice, mixedCurrency, currencies } = billingIssues(r);
if (mixedCurrency) {
toast({
title: "Mixed truck currencies",
description: `Trucks use ${currencies.join(", ")}. Assign trucks that share one currency.`,
variant: "destructive",
});
return;
}
if (zeroPrice.length) {
toast({
title: "Truck has no price/km",
description: `${zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.`,
variant: "destructive",
});
}
generateInvoiceMutation.mutate(r.id);
};
const openAssign = (id: string | null) => {
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
const rec = records.find((r) => r.id === resolved);
@@ -1173,7 +1208,7 @@ const FirstMilePage = () => {
!(row.original.exactKm != null && row.original.exactKm > 0) ||
Boolean(row.original.invoice)
}
onClick={() => generateInvoiceMutation.mutate(row.original.id)}
onClick={() => handleGenerateInvoice(row.original)}
>
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
@@ -1337,21 +1372,29 @@ const FirstMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Autocomplete
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
data={containerOptions.filter(
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
)}
value={row.containerNumber}
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{vehicleRows.length > 1 && (
<ActionIcon
@@ -1740,6 +1783,77 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Generate Invoice — confirmation summary */}
<Modal
opened={Boolean(invoiceConfirm)}
onClose={() => setInvoiceConfirm(null)}
title={<Text fw={600}>Generate Invoice</Text>}
size="md"
radius="lg"
centered
>
{invoiceConfirm && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} size="sm">{bookingRef(invoiceConfirm)}</Text>
<Text size="sm" c="dimmed">{customerName(invoiceConfirm)}</Text>
</Group>
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap={6}>
{(invoiceConfirm.vehicleAssignments ?? []).map((a) => {
const v = a.vehicle;
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return (
<Group key={a.id} justify="space-between" wrap="nowrap">
<Text size="sm">
{label}
{a.containerNumber ? ` · ${a.containerNumber}` : ""}
</Text>
<Text size="sm">{a.distanceKm != null ? `${a.distanceKm} km` : "—"}</Text>
</Group>
);
})}
</Stack>
</Card>
<Group justify="space-between">
<Text size="sm" c="dimmed">Total distance</Text>
<Text size="sm" fw={500}>{invoiceConfirm.exactKm ?? 0} km</Text>
</Group>
<Group justify="space-between">
<Text fw={600}>Invoice amount</Text>
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
</Group>
{confirmIssues.mixedCurrency && (
<Alert color="red" variant="light" title="Mixed truck currencies">
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
</Alert>
)}
{confirmIssues.zeroPrice.length > 0 && (
<Alert color="yellow" variant="light" title="Truck has no price/km">
{confirmIssues.zeroPrice.join(", ")} will bill 0 set Price per KM on the vehicle.
</Alert>
)}
<Text size="xs" c="dimmed">
Generate the delivery-fee invoice now, or close and generate later from the row actions.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Later</Button>
<Button
loading={generateInvoiceMutation.isPending}
disabled={confirmIssues.mixedCurrency}
onClick={() =>
generateInvoiceMutation.mutate(invoiceConfirm.id, {
onSuccess: () => setInvoiceConfirm(null),
})
}
>
Generate Invoice
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
};

View File

@@ -19,7 +19,6 @@ import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Alert,
Autocomplete,
Badge,
Box,
Button,
@@ -56,8 +55,8 @@ import {
} from "@/services/last-mile.service";
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") =>
@@ -233,7 +232,24 @@ const computeLastMileSteps = (
};
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB";
const currencyOf = (r: LastMileRecord) =>
r.vehicle?.currency ??
r.vehicleAssignments?.[0]?.vehicle?.currency ??
r.booking?.paymentCurrency ??
"ETB";
type LmAssignment = NonNullable<LastMileRecord["vehicleAssignments"]>[number];
const truckShort = (a: LmAssignment) =>
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
const billingIssues = (r: LastMileRecord) => {
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
const currencies = [
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
@@ -544,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 }>
@@ -581,14 +598,6 @@ const LastMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const existingLastMileBookingIds = useMemo(
() => new Set(records.map((record) => record.bookingId)),
@@ -667,10 +676,17 @@ const LastMilePage = () => {
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => lastMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
onSuccess: (res) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
const updated = res?.data as LastMileRecord | undefined;
closeDistance();
// Every truck has a distance and it isn't billed yet → offer to invoice now.
const trucks = updated?.vehicleAssignments ?? [];
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
if (updated && allFilled && !updated.invoice) {
setInvoiceConfirm(updated);
}
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -802,18 +818,9 @@ const LastMilePage = () => {
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
remainingPayment = total * parseFloat(lastMileRate.rateValue);
}
}
distanceMutation.mutate({ id: activeId, distances, remainingPayment });
// Amount is computed server-side per truck (distance × the vehicle's
// price/km, in the vehicle's currency) — no flat LAST_MILE rate.
distanceMutation.mutate({ id: activeId, distances });
};
const activeRecord = useMemo(
@@ -929,6 +936,11 @@ const LastMilePage = () => {
[records],
);
// Billing problems on the leg pending invoice confirmation.
const confirmIssues = invoiceConfirm
? billingIssues(invoiceConfirm)
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
const filteredRecords = useMemo(() => {
const term = search.trim().toLowerCase();
return records.filter((r) => {
@@ -1270,6 +1282,17 @@ const LastMilePage = () => {
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
{pastTransit && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Receipt size={13} />}
onClick={() => setDetentionRecord(row.original)}
>
Detention
</Button>
)}
<Menu
position="bottom-end"
width={200}
@@ -1360,6 +1383,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} />}
@@ -1697,21 +1730,29 @@ const LastMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Autocomplete
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
data={containerOptions.filter(
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
)}
value={row.containerNumber}
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{vehicleRows.length > 1 && (
<ActionIcon
@@ -1993,6 +2034,16 @@ const LastMilePage = () => {
<Text fw={600}>Invoice amount</Text>
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
</Group>
{confirmIssues.mixedCurrency && (
<Alert color="red" variant="light" title="Mixed truck currencies">
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
</Alert>
)}
{confirmIssues.zeroPrice.length > 0 && (
<Alert color="yellow" variant="light" title="Truck has no price/km">
{confirmIssues.zeroPrice.join(", ")} will bill 0 set Price per KM on the vehicle.
</Alert>
)}
<Text size="xs" c="dimmed">
This creates the delivery-fee invoice. Confirm the distances and amount are correct.
</Text>
@@ -2000,6 +2051,7 @@ const LastMilePage = () => {
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Cancel</Button>
<Button
loading={generateInvoiceMutation.isPending}
disabled={confirmIssues.mixedCurrency}
onClick={() =>
generateInvoiceMutation.mutate(invoiceConfirm.id, {
onSuccess: () => setInvoiceConfirm(null),
@@ -2019,6 +2071,12 @@ const LastMilePage = () => {
item={releaseItem}
truckPrefill={releaseTruckPrefill}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}
record={detentionRecord}
/>
</Stack>
);
};

Some files were not shown because too many files have changed in this diff Show More