mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
resolve conflict
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "-")
|
||||
|
||||
@@ -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(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal file
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal 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]),
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
import { CreateLastMileDto } from './create-last-mile.dto';
|
||||
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
|
||||
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
arrivedAt?: string;
|
||||
|
||||
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
deliveredAt?: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
|
||||
status!: LastMileStatus;
|
||||
|
||||
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
|
||||
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
|
||||
// between them beyond the rule's grace hours (default 3h), per truck per day.
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
advancedPayment!: number;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -65,4 +65,12 @@ export class CreateVehicleDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
locationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pricePerKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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 : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user