mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fleet
This commit is contained in:
@@ -78,6 +78,9 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
|||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
import { MaintenanceModule } from "./modules/maintenance/maintenance.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 { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
@@ -145,6 +148,9 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
DriversModule,
|
DriversModule,
|
||||||
FuelModule,
|
FuelModule,
|
||||||
MaintenanceModule,
|
MaintenanceModule,
|
||||||
|
ComplianceModule,
|
||||||
|
IncidentsModule,
|
||||||
|
ProcurementModule,
|
||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
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,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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +1,24 @@
|
|||||||
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
|
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
|
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
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')
|
@ApiTags('Maintenance Management')
|
||||||
@Controller('maintenance')
|
@Controller('maintenance')
|
||||||
export class MaintenanceController {
|
export class MaintenanceController {
|
||||||
constructor(private readonly maintenanceService: MaintenanceService) {}
|
constructor(
|
||||||
|
private readonly maintenanceService: MaintenanceService,
|
||||||
|
private readonly maintenanceDepthService: MaintenanceDepthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('schedules')
|
@Post('schedules')
|
||||||
@ApiOperation({ summary: 'Schedule maintenance' })
|
@ApiOperation({ summary: 'Schedule maintenance' })
|
||||||
@@ -49,4 +61,91 @@ export class MaintenanceController {
|
|||||||
async getStats(@Param('vehicleId') vehicleId: string) {
|
async getStats(@Param('vehicleId') vehicleId: string) {
|
||||||
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
|
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Work Orders ----
|
||||||
|
|
||||||
|
@Post('work-orders')
|
||||||
|
@ApiOperation({ summary: 'Create work order' })
|
||||||
|
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
|
||||||
|
return this.maintenanceDepthService.createWorkOrder(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('work-orders')
|
||||||
|
@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')
|
||||||
|
@ApiOperation({ summary: 'Get work order' })
|
||||||
|
async getWorkOrder(@Param('id') id: string) {
|
||||||
|
return this.maintenanceDepthService.findWorkOrderById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('work-orders/:id')
|
||||||
|
@ApiOperation({ summary: 'Update work order' })
|
||||||
|
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
|
||||||
|
return this.maintenanceDepthService.updateWorkOrder(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('work-orders/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete work order' })
|
||||||
|
async deleteWorkOrder(@Param('id') id: string) {
|
||||||
|
return this.maintenanceDepthService.deleteWorkOrder(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Parts / Tires ----
|
||||||
|
|
||||||
|
@Post('parts')
|
||||||
|
@ApiOperation({ summary: 'Create part' })
|
||||||
|
async createPart(@Body() dto: CreatePartDto) {
|
||||||
|
return this.maintenanceDepthService.createPart(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('parts')
|
||||||
|
@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')
|
||||||
|
@ApiOperation({ summary: 'Update part' })
|
||||||
|
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
|
||||||
|
return this.maintenanceDepthService.updatePart(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('parts/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete part' })
|
||||||
|
async deletePart(@Param('id') id: string) {
|
||||||
|
return this.maintenanceDepthService.deletePart(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Warranties ----
|
||||||
|
|
||||||
|
@Post('warranties')
|
||||||
|
@ApiOperation({ summary: 'Create warranty' })
|
||||||
|
async createWarranty(@Body() dto: CreateWarrantyDto) {
|
||||||
|
return this.maintenanceDepthService.createWarranty(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warranties')
|
||||||
|
@ApiOperation({ summary: 'List warranties' })
|
||||||
|
async listWarranties(@Query('vehicleId') vehicleId?: string) {
|
||||||
|
return this.maintenanceDepthService.findWarranties({ vehicleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('warranties/:id')
|
||||||
|
@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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.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 { MaintenanceService } from './maintenance.service';
|
||||||
|
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
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';
|
import { MaintenanceController } from './maintenance.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
|
imports: [
|
||||||
providers: [MaintenanceService, MaintenanceRepository],
|
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
MaintenanceService,
|
||||||
|
MaintenanceDepthService,
|
||||||
|
MaintenanceRepository,
|
||||||
|
WorkOrderRepository,
|
||||||
|
PartRepository,
|
||||||
|
WarrantyRepository,
|
||||||
|
],
|
||||||
controllers: [MaintenanceController],
|
controllers: [MaintenanceController],
|
||||||
exports: [MaintenanceService],
|
exports: [MaintenanceService, MaintenanceDepthService],
|
||||||
})
|
})
|
||||||
export class MaintenanceModule {}
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,4 +88,21 @@ export class Vehicle extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||||
locationId?: string;
|
locationId?: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ import { MaintenancePage } from "./pages/fleet/MaintenancePage";
|
|||||||
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
|
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
|
||||||
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
|
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
|
||||||
import { TrackingPage } from "./pages/fleet/TrackingPage";
|
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
@@ -290,6 +294,30 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Work Orders",
|
||||||
|
href: "/dashboard/work-orders",
|
||||||
|
icon: <SlidersHorizontal />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.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",
|
label: "Financial Reports",
|
||||||
href: "/dashboard/financial-reports",
|
href: "/dashboard/financial-reports",
|
||||||
@@ -1058,6 +1086,38 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</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.fleet.view}>
|
||||||
|
<WorkOrdersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="procurement"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<ProcurementPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
|
export type ComplianceType =
|
||||||
|
| 'INSPECTION'
|
||||||
|
| 'INSURANCE'
|
||||||
|
| 'ROADWORTHINESS'
|
||||||
|
| 'PERMIT'
|
||||||
|
| 'TAX';
|
||||||
|
|
||||||
|
export type ComplianceStatus = 'VALID' | 'EXPIRING' | 'EXPIRED';
|
||||||
|
|
||||||
|
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
|
||||||
|
|
||||||
|
export interface ComplianceRecord {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
vehicle?: {
|
||||||
|
id: string;
|
||||||
|
plateNumber?: string | null;
|
||||||
|
manufacturer?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
};
|
||||||
|
type: ComplianceType;
|
||||||
|
documentNumber?: string | null;
|
||||||
|
issuedDate?: string | null;
|
||||||
|
expiryDate: string;
|
||||||
|
status: ComplianceStatus;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceAlert {
|
||||||
|
vehicleId: string;
|
||||||
|
vehiclePlate?: string;
|
||||||
|
kind: string;
|
||||||
|
label: string;
|
||||||
|
expiryDate: string;
|
||||||
|
daysUntil: number;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceListFilters {
|
||||||
|
vehicleId?: string;
|
||||||
|
type?: ComplianceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveCompliancePayload {
|
||||||
|
vehicleId: string;
|
||||||
|
type: ComplianceType;
|
||||||
|
documentNumber?: string;
|
||||||
|
issuedDate?: string;
|
||||||
|
expiryDate: string;
|
||||||
|
status?: ComplianceStatus;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const complianceService = {
|
||||||
|
list: (filters: ComplianceListFilters = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||||
|
if (filters.type) params.set('type', filters.type);
|
||||||
|
const qs = params.toString();
|
||||||
|
return apiClient.get<ComplianceRecord[]>(`/compliance${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
getAlerts: () => apiClient.get<ComplianceAlert[]>('/compliance/alerts'),
|
||||||
|
getById: (id: string) => apiClient.get<ComplianceRecord>(`/compliance/${id}`),
|
||||||
|
create: (data: SaveCompliancePayload) =>
|
||||||
|
apiClient.post<ComplianceRecord>('/compliance', data),
|
||||||
|
update: (id: string, data: Partial<SaveCompliancePayload>) =>
|
||||||
|
apiClient.patch<ComplianceRecord>(`/compliance/${id}`, data),
|
||||||
|
remove: (id: string) => apiClient.delete(`/compliance/${id}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { api } from '@/auth/http';
|
||||||
|
|
||||||
|
export type IncidentType = 'ACCIDENT' | 'BREAKDOWN' | 'TRAFFIC_VIOLATION' | 'THEFT' | 'OTHER';
|
||||||
|
export type IncidentSeverity = 'MINOR' | 'MODERATE' | 'MAJOR' | 'CRITICAL';
|
||||||
|
export type IncidentStatus =
|
||||||
|
| 'REPORTED'
|
||||||
|
| 'UNDER_REVIEW'
|
||||||
|
| 'CLAIM_FILED'
|
||||||
|
| 'RESOLVED'
|
||||||
|
| 'CLOSED';
|
||||||
|
|
||||||
|
export interface Incident {
|
||||||
|
id: string;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
driverId?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
|
type: IncidentType;
|
||||||
|
severity: IncidentSeverity;
|
||||||
|
occurredAt: string;
|
||||||
|
location?: string | null;
|
||||||
|
description: string;
|
||||||
|
/** API sends numeric as string; coerce with Number. */
|
||||||
|
damageEstimate?: number | string | null;
|
||||||
|
status: IncidentStatus;
|
||||||
|
insuranceClaimNumber?: string | null;
|
||||||
|
reportedBy?: string | null;
|
||||||
|
vehicle?: {
|
||||||
|
id: string;
|
||||||
|
plateNumber?: string | null;
|
||||||
|
registrationNumber?: string | null;
|
||||||
|
} | null;
|
||||||
|
driver?: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
} | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentFilters {
|
||||||
|
vehicleId?: string;
|
||||||
|
driverId?: string;
|
||||||
|
status?: IncidentStatus;
|
||||||
|
type?: IncidentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverIncidentStats {
|
||||||
|
total: number;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
lastIncidentAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveIncidentPayload {
|
||||||
|
vehicleId?: string;
|
||||||
|
driverId?: string;
|
||||||
|
bookingId?: string;
|
||||||
|
type: IncidentType;
|
||||||
|
severity: IncidentSeverity;
|
||||||
|
occurredAt: string;
|
||||||
|
location?: string;
|
||||||
|
description: string;
|
||||||
|
damageEstimate?: number;
|
||||||
|
status?: IncidentStatus;
|
||||||
|
insuranceClaimNumber?: string;
|
||||||
|
reportedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incidentsService = {
|
||||||
|
getAll: (filters: IncidentFilters = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||||
|
if (filters.driverId) params.set('driverId', filters.driverId);
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.type) params.set('type', filters.type);
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<Incident[]>(`/incidents${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
getByDriver: (driverId: string) => api.get<Incident[]>(`/incidents/driver/${driverId}`),
|
||||||
|
getDriverStats: (driverId: string) =>
|
||||||
|
api.get<DriverIncidentStats>(`/incidents/driver/${driverId}/stats`),
|
||||||
|
getById: (id: string) => api.get<Incident>(`/incidents/${id}`),
|
||||||
|
create: (data: SaveIncidentPayload) => api.post<Incident>('/incidents', data),
|
||||||
|
update: (id: string, data: Partial<SaveIncidentPayload>) =>
|
||||||
|
api.patch<Incident>(`/incidents/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/incidents/${id}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { api } from '@/auth/http';
|
||||||
|
|
||||||
|
export type WorkOrderStatus = 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
|
||||||
|
export type WorkOrderPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||||
|
|
||||||
|
export interface WorkOrder {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
priority: WorkOrderPriority;
|
||||||
|
assignedTo?: string | null;
|
||||||
|
openedAt: string;
|
||||||
|
closedAt?: string | null;
|
||||||
|
/** API sends numeric as string; coerce with Number. */
|
||||||
|
laborCost?: number | string | null;
|
||||||
|
partsCost?: number | string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveWorkOrderPayload {
|
||||||
|
vehicleId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
assignedTo?: string;
|
||||||
|
openedAt?: string;
|
||||||
|
closedAt?: string;
|
||||||
|
laborCost?: number;
|
||||||
|
partsCost?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderFilters {
|
||||||
|
vehicleId?: string;
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Part {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sku?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
quantityInStock: number;
|
||||||
|
reorderLevel: number;
|
||||||
|
/** API sends numeric as string; coerce with Number. */
|
||||||
|
unitCost?: number | string | null;
|
||||||
|
location?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavePartPayload {
|
||||||
|
name: string;
|
||||||
|
sku?: string;
|
||||||
|
category?: string;
|
||||||
|
quantityInStock?: number;
|
||||||
|
reorderLevel?: number;
|
||||||
|
unitCost?: number;
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartFilters {
|
||||||
|
category?: string;
|
||||||
|
lowStock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warranty {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
component: string;
|
||||||
|
provider?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
expiryDate: string;
|
||||||
|
coverageNotes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveWarrantyPayload {
|
||||||
|
vehicleId: string;
|
||||||
|
component: string;
|
||||||
|
provider?: string;
|
||||||
|
startDate?: string;
|
||||||
|
expiryDate: string;
|
||||||
|
coverageNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maintenanceDepthService = {
|
||||||
|
// Work Orders
|
||||||
|
getWorkOrders: (filters: WorkOrderFilters = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<WorkOrder[]>(`/maintenance/work-orders${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
getWorkOrder: (id: string) => api.get<WorkOrder>(`/maintenance/work-orders/${id}`),
|
||||||
|
createWorkOrder: (data: SaveWorkOrderPayload) =>
|
||||||
|
api.post<WorkOrder>('/maintenance/work-orders', data),
|
||||||
|
updateWorkOrder: (id: string, data: Partial<SaveWorkOrderPayload>) =>
|
||||||
|
api.patch<WorkOrder>(`/maintenance/work-orders/${id}`, data),
|
||||||
|
deleteWorkOrder: (id: string) => api.delete(`/maintenance/work-orders/${id}`),
|
||||||
|
|
||||||
|
// Parts / Tires
|
||||||
|
getParts: (filters: PartFilters = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.category) params.set('category', filters.category);
|
||||||
|
if (filters.lowStock) params.set('lowStock', 'true');
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<Part[]>(`/maintenance/parts${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
createPart: (data: SavePartPayload) => api.post<Part>('/maintenance/parts', data),
|
||||||
|
updatePart: (id: string, data: Partial<SavePartPayload>) =>
|
||||||
|
api.patch<Part>(`/maintenance/parts/${id}`, data),
|
||||||
|
deletePart: (id: string) => api.delete(`/maintenance/parts/${id}`),
|
||||||
|
|
||||||
|
// Warranties
|
||||||
|
getWarranties: (vehicleId?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (vehicleId) params.set('vehicleId', vehicleId);
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<Warranty[]>(`/maintenance/warranties${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
createWarranty: (data: SaveWarrantyPayload) =>
|
||||||
|
api.post<Warranty>('/maintenance/warranties', data),
|
||||||
|
deleteWarranty: (id: string) => api.delete(`/maintenance/warranties/${id}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { api } from "@/auth/http";
|
||||||
|
|
||||||
|
export type VendorType = "DEALER" | "LEASING" | "PARTS" | "SERVICE" | "OTHER";
|
||||||
|
export type AcquisitionType = "PURCHASE" | "LEASE" | "RENTAL";
|
||||||
|
export type AcquisitionStatus = "ACTIVE" | "LEASE_EXPIRING" | "DISPOSED";
|
||||||
|
export type DisposalMethod = "SALE" | "SCRAP" | "RETURN_LEASE" | "TRADE_IN";
|
||||||
|
|
||||||
|
export interface Vendor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type?: VendorType | null;
|
||||||
|
contactPerson?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetAcquisition {
|
||||||
|
id: string;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
vendorId?: string | null;
|
||||||
|
acquisitionType: AcquisitionType;
|
||||||
|
acquisitionDate: string;
|
||||||
|
cost?: number | null;
|
||||||
|
usefulLifeMonths?: number | null;
|
||||||
|
salvageValue?: number | null;
|
||||||
|
leaseStart?: string | null;
|
||||||
|
leaseEnd?: string | null;
|
||||||
|
monthlyPayment?: number | null;
|
||||||
|
status: AcquisitionStatus;
|
||||||
|
notes?: string | null;
|
||||||
|
vehicle?: {
|
||||||
|
id: string;
|
||||||
|
plateNumber?: string | null;
|
||||||
|
registrationNumber?: string | null;
|
||||||
|
manufacturer?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
} | null;
|
||||||
|
vendor?: Vendor | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetDisposal {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
disposalDate: string;
|
||||||
|
method: DisposalMethod;
|
||||||
|
salePrice?: number | null;
|
||||||
|
buyer?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVendorPayload {
|
||||||
|
name: string;
|
||||||
|
type?: VendorType;
|
||||||
|
contactPerson?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAcquisitionPayload {
|
||||||
|
vehicleId?: string;
|
||||||
|
vendorId?: string;
|
||||||
|
acquisitionType: AcquisitionType;
|
||||||
|
acquisitionDate: string;
|
||||||
|
cost?: number;
|
||||||
|
usefulLifeMonths?: number;
|
||||||
|
salvageValue?: number;
|
||||||
|
leaseStart?: string;
|
||||||
|
leaseEnd?: string;
|
||||||
|
monthlyPayment?: number;
|
||||||
|
status?: AcquisitionStatus;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDisposalPayload {
|
||||||
|
vehicleId: string;
|
||||||
|
disposalDate: string;
|
||||||
|
method: DisposalMethod;
|
||||||
|
salePrice?: number;
|
||||||
|
buyer?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const procurementService = {
|
||||||
|
// Vendors
|
||||||
|
listVendors: () => api.get<Vendor[]>("/procurement/vendors"),
|
||||||
|
createVendor: (data: CreateVendorPayload) => api.post("/procurement/vendors", data),
|
||||||
|
updateVendor: (id: string, data: Partial<CreateVendorPayload>) =>
|
||||||
|
api.patch(`/procurement/vendors/${id}`, data),
|
||||||
|
deleteVendor: (id: string) => api.delete(`/procurement/vendors/${id}`),
|
||||||
|
|
||||||
|
// Acquisitions
|
||||||
|
listAcquisitions: (vehicleId?: string) =>
|
||||||
|
api.get<AssetAcquisition[]>(
|
||||||
|
`/procurement/acquisitions${vehicleId ? `?vehicleId=${vehicleId}` : ""}`,
|
||||||
|
),
|
||||||
|
getAcquisition: (id: string) => api.get<AssetAcquisition>(`/procurement/acquisitions/${id}`),
|
||||||
|
createAcquisition: (data: CreateAcquisitionPayload) =>
|
||||||
|
api.post("/procurement/acquisitions", data),
|
||||||
|
updateAcquisition: (id: string, data: Partial<CreateAcquisitionPayload>) =>
|
||||||
|
api.patch(`/procurement/acquisitions/${id}`, data),
|
||||||
|
deleteAcquisition: (id: string) => api.delete(`/procurement/acquisitions/${id}`),
|
||||||
|
|
||||||
|
// Disposals
|
||||||
|
listDisposals: () => api.get<AssetDisposal[]>("/procurement/disposals"),
|
||||||
|
createDisposal: (data: CreateDisposalPayload) => api.post("/procurement/disposals", data),
|
||||||
|
deleteDisposal: (id: string) => api.delete(`/procurement/disposals/${id}`),
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
lifecycle: (vehicleId: string) =>
|
||||||
|
api.get<LifecycleResult>(`/procurement/lifecycle/${vehicleId}`),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user