This commit is contained in:
natib21
2026-07-06 12:05:52 +00:00
parent e13720a32d
commit 998a6801ab
46 changed files with 5026 additions and 5 deletions

View File

@@ -78,6 +78,9 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
@@ -145,6 +148,9 @@ import { LoggerMiddleware } from "./logger.middleware";
DriversModule,
FuelModule,
MaintenanceModule,
ComplianceModule,
IncidentsModule,
ProcurementModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
import { WorkOrderStatus } from './entities/work-order.entity';
@ApiTags('Maintenance Management')
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
constructor(
private readonly maintenanceService: MaintenanceService,
private readonly maintenanceDepthService: MaintenanceDepthService,
) {}
@Post('schedules')
@ApiOperation({ summary: 'Schedule maintenance' })
@@ -49,4 +61,91 @@ export class MaintenanceController {
async getStats(@Param('vehicleId') vehicleId: string) {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,4 +88,21 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'location_id', type: 'uuid', nullable: true })
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;
}

View File

@@ -93,6 +93,10 @@ import { MaintenancePage } from "./pages/fleet/MaintenancePage";
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
import { TrackingPage } from "./pages/fleet/TrackingPage";
import CompliancePage from "./pages/fleet/CompliancePage";
import IncidentsPage from "./pages/fleet/IncidentsPage";
import WorkOrdersPage from "./pages/fleet/WorkOrdersPage";
import ProcurementPage from "./pages/fleet/ProcurementPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -290,6 +294,30 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Truck />,
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",
href: "/dashboard/financial-reports",
@@ -1058,6 +1086,38 @@ const App = () => {
</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
path="locomotives"
element={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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