diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts index d3e70acff..129262ef0 100644 --- a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto { @IsEnum(MaintenanceType) maintenanceType!: MaintenanceType; + /** What is serviced — matched against the interval for auto-scheduling. */ + @IsOptional() + @IsString() + serviceItem?: string; + @IsString() description!: string; @@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto { @IsNumber() actualCost?: number; + /** Odometer at completion — drives KM-based auto-scheduling of the next service. */ + @IsOptional() + @IsNumber() + odometerReading?: number; + @IsOptional() @IsString() notes?: string; } + +export class UpsertMaintenanceIntervalDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */ + @IsOptional() + @IsString() + serviceItem?: string; + + @IsOptional() + @IsNumber() + intervalKm?: number; + + @IsOptional() + @IsNumber() + intervalDays?: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts index 095123c09..640c3e26e 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -1,15 +1,18 @@ import { BaseEntity } from '@edr/api-common'; -import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { MaintenanceType } from './maintenance-schedule.entity'; /** - * Maintenance interval configuration. Defines how often a vehicle/type needs maintenance. - * Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km). + * Maintenance interval configuration. Defines how often a vehicle needs a + * given service. Identity is (vehicle, maintenanceType, serviceItem) — a + * vehicle carries several intervals of the same coarse type with different + * items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness + * is enforced by a COALESCE expression index in the migration (nullable + * service_item), not a TypeORM @Unique. */ @Entity({ name: 'maintenance_intervals', schema: 'freight' }) @Index(['vehicleId', 'maintenanceType']) -@Unique(['vehicleId', 'maintenanceType']) export class MaintenanceInterval extends BaseEntity { @Column({ name: 'vehicle_id', type: 'uuid' }) vehicleId!: string; @@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — "oil change", "tires", … Null = generic for the type. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) intervalKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts index 7d3ab7a95..5a0cc074a 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — matches the interval's service_item for auto-scheduling. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + @Column({ name: 'description' }) description!: string; diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts new file mode 100644 index 000000000..1b020455c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts @@ -0,0 +1,99 @@ +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceStatus } from './entities/maintenance-schedule.entity'; + +/** + * KM-based auto-scheduling: completing a maintenance with an odometer reading + * creates the next SCHEDULED item at completedKm + intervalKm, matched on the + * schedule's (type, serviceItem) interval. Re-completing must not duplicate. + */ +function makeService(opts: { + before: Record | null; + after: Record | null; + interval: Record | null; +}) { + const saved: Array> = []; + const service = Object.create(MaintenanceService.prototype) as Record; + service.scheduleRepository = { + findOneBy: jest + .fn() + .mockResolvedValueOnce(opts.before) + .mockResolvedValueOnce(opts.after), + update: jest.fn(), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + saved.push(v); + return v; + }), + }; + service.intervalRepository = { + getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval), + }; + service.dataSource = { + getRepository: jest.fn().mockReturnValue({ update: jest.fn() }), + }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, saved }; +} + +const base = { + id: 's-1', + vehicleId: 'v-1', + maintenanceType: 'PREVENTIVE', + serviceItem: 'oil change', + description: 'Oil and filter', +}; + +describe('MaintenanceService auto-next scheduling', () => { + it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.SCHEDULED }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ + vehicleId: 'v-1', + serviceItem: 'oil change', + nextDueKm: 60000, + status: MaintenanceStatus.SCHEDULED, + }); + }); + + it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(0); + }); + + it('a km + days interval produces ONE next schedule carrying both thresholds', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.IN_PROGRESS }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 20000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0].nextDueKm).toBe(30000); + expect(saved[0].nextDueDate).toBeInstanceOf(Date); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts index eded86274..d884cdf55 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { MaintenanceType } from './entities/maintenance-schedule.entity'; @@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository { + /** + * Resolve the interval for a completed service. Prefers the exact + * (type, serviceItem) match; a completion without an item falls back to the + * type's item-less interval only, so "oil" completions never consume the + * "tires" interval. + */ + async getByVehicleAndType( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + ): Promise { return this.intervalRepository.findOne({ - where: { vehicleId, maintenanceType, isActive: true }, + where: { + vehicleId, + maintenanceType, + isActive: true, + serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(), + }, }); } async getActiveIntervals(vehicleId: string): Promise { return this.intervalRepository.find({ where: { vehicleId, isActive: true }, - order: { maintenanceType: 'ASC' }, + order: { maintenanceType: 'ASC', serviceItem: 'ASC' }, }); } async upsertInterval( vehicleId: string, maintenanceType: MaintenanceType, + serviceItem?: string | null, intervalKm?: number | null, intervalDays?: number | null, description?: string | null, ): Promise { - const existing = await this.getByVehicleAndType(vehicleId, maintenanceType); + const item = serviceItem?.trim() || null; + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item); if (existing) { await this.intervalRepository.update(existing.id, { @@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository { + await this.intervalRepository.update(id, { isActive: false }); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index 9324ad694..32e5dfba6 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { CreateWorkOrderDto, UpdateWorkOrderDto, @@ -51,6 +56,27 @@ export class MaintenanceController { return this.maintenanceService.getDueBoard(); } + @Post('intervals') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' }) + async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) { + return this.maintenanceService.upsertInterval(dto); + } + + @Get('intervals/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: "A vehicle's active service intervals" }) + async getIntervals(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getIntervals(vehicleId); + } + + @Delete('intervals/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' }) + async deactivateInterval(@Param('id') id: string) { + return this.maintenanceService.deactivateInterval(id); + } + @Get('upcoming/:vehicleId') @BookingStaff(FREIGHT_PERMS.maintenance.view) @ApiOperation({ summary: 'Get upcoming maintenance' }) diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 8e58702ac..b417e72cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: Date; nextDueDate: Date | null; @@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository { overdue: boolean; }> > { + // Every SCHEDULED item, not one per vehicle — a truck legitimately holds + // several (oil vs tires intervals differ). return this.scheduleRepository.manager.query(` - SELECT DISTINCT ON (s.vehicle_id) + SELECT s.id AS "scheduleId", s.vehicle_id AS "vehicleId", v.plate_number AS "plateNumber", s.maintenance_type AS "maintenanceType", + s.service_item AS "serviceItem", s.description, s.scheduled_date AS "scheduledDate", s.next_due_date AS "nextDueDate", diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 283ca5eea..dd9da55a8 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -8,7 +8,12 @@ import { MaintenanceIntervalRepository } from './maintenance-interval.repository import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() @@ -107,6 +112,10 @@ export class MaintenanceService { id: string, dto: UpdateMaintenanceScheduleDto, ): Promise { + // Status BEFORE the write: completing an already-COMPLETED schedule again + // must not auto-create a second "next" schedule. + const before = await this.scheduleRepository.findOneBy({ id }); + await this.scheduleRepository.update(id, { ...dto, completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, @@ -122,8 +131,12 @@ export class MaintenanceService { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); - // If completed, schedule the next maintenance based on interval - if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) { + // First transition into COMPLETED with an odometer → auto-schedule next. + if ( + dto.status === MaintenanceStatus.COMPLETED && + before?.status !== MaintenanceStatus.COMPLETED && + updated.odometerReading != null + ) { await this.scheduleNextMaintenance(updated); } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { @@ -135,52 +148,75 @@ export class MaintenanceService { return updated!; } + /** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */ + async upsertInterval(dto: UpsertMaintenanceIntervalDto) { + return this.intervalRepository.upsertInterval( + dto.vehicleId, + dto.maintenanceType, + dto.serviceItem ?? null, + dto.intervalKm ?? null, + dto.intervalDays ?? null, + dto.description ?? null, + ); + } + + async getIntervals(vehicleId: string) { + return this.intervalRepository.getActiveIntervals(vehicleId); + } + + async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> { + await this.intervalRepository.deactivate(id); + return { id, deactivated: true }; + } + + /** + * Auto-schedule the next service after a completion: matched on the + * completed schedule's (type, serviceItem) interval; one SCHEDULED row + * carrying BOTH thresholds when the interval defines km and days — + * whichever is crossed first makes it due. + */ private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { try { - // Get maintenance interval for this type const interval = await this.intervalRepository.getByVehicleAndType( completed.vehicleId, completed.maintenanceType as MaintenanceType, + completed.serviceItem, ); if (!interval) return; // No interval defined, skip auto-scheduling const now = new Date(); const completedKm = Number(completed.odometerReading ?? 0); + const intervalKm = Number(interval.intervalKm ?? 0); + const intervalDays = Number(interval.intervalDays ?? 0); + if (intervalKm <= 0 && intervalDays <= 0) return; - // Calculate next due based on KM interval - if (interval.intervalKm && interval.intervalKm > 0) { - const nextDueKm = completedKm + Number(interval.intervalKm); + const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; + const nextDueDate = + intervalDays > 0 + ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) + : undefined; - // Create next scheduled maintenance - const nextSchedule = this.scheduleRepository.create({ + const label = interval.serviceItem ? `${interval.serviceItem}: ` : ''; + const due = [ + nextDueKm != null ? `${nextDueKm} km` : null, + nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null, + ] + .filter(Boolean) + .join(' / '); + + await this.scheduleRepository.save( + this.scheduleRepository.create({ vehicleId: completed.vehicleId, maintenanceType: completed.maintenanceType, - description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`, + serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, + description: `${label}${interval.description || completed.description} (next due: ${due})`, scheduledDate: now, nextDueKm, + nextDueDate, status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - - // Calculate next due based on date interval - if (interval.intervalDays && interval.intervalDays > 0) { - const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000); - - // If no KM-based next maintenance was created, use date-based - if (!interval.intervalKm) { - const nextSchedule = this.scheduleRepository.create({ - vehicleId: completed.vehicleId, - maintenanceType: completed.maintenanceType, - description: completed.description, - scheduledDate: now, - nextDueDate, - status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - } + }), + ); } catch (err) { this.logger.error( `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index e42cbdcea..582face93 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2504,27 +2504,37 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - let document = await this.interchangeDocuments.generateFromSchedule({ - scheduleId, - direction: 'EXPORT', - handoverLocation: schedule.destinationName ?? 'Djibouti Port', - handoverFrom: 'EDR', - handoverTo: 'Djibouti Port Operator', - portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy ?? 'EDR Operations', - remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', - }); - if (document.status !== 'ACKNOWLEDGED') { - document = await this.interchangeDocuments.acknowledge(document.id, { - acknowledgedBy: 'Djibouti Port Operator', - remarks: 'Auto acknowledged after Djibouti export unloading.', + // Best-effort: the unload is already committed — a paperwork failure must + // not fail the response (it did once: items unloaded, request 500'd, and + // the document only appeared after a manual retry days later). The doc + // backfills on any retry since already-unloaded items count as unloaded. + try { + let document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } catch (err) { + this.logger.warn( + `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`, + ); } - result.interchangeDocument = { - id: document.id, - documentNo: document.documentNo, - status: document.status, - }; } return result; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 80645d256..c57a607d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -208,6 +208,8 @@ export const QUERY_KEYS = { ["maintenance", "history", vehicleId ?? "all"] as const, stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + intervals: (vehicleId?: string) => + ["maintenance", "intervals", vehicleId ?? "all"] as const, }, FINANCIAL_REPORTS: { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 0fcb35a27..12f5181d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -14,8 +14,10 @@ import { Text, Title, Container, + ActionIcon, + Tooltip, } from '@mantine/core'; -import { Plus } from 'lucide-react'; +import { CheckCircle2, Plus, Trash2 } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -26,6 +28,7 @@ interface MaintenanceSchedule { id: string; vehicleId: string; maintenanceType: string; + serviceItem?: string | null; description: string; scheduledDate: string; completedDate?: string; @@ -40,6 +43,7 @@ interface DueBoardRow { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: string; nextDueDate: string | null; @@ -50,8 +54,20 @@ interface DueBoardRow { overdue: boolean; } +interface MaintenanceInterval { + id: string; + vehicleId: string; + maintenanceType: string; + serviceItem: string | null; + intervalKm: number | null; + intervalDays: number | null; + description: string | null; + isActive: boolean; +} + const emptyForm = { maintenanceType: 'PREVENTIVE', + serviceItem: '', description: '', scheduledDate: new Date().toISOString().split('T')[0], estimatedCost: 0, @@ -59,12 +75,24 @@ const emptyForm = { notes: '', }; +const emptyIntervalForm = { + maintenanceType: 'PREVENTIVE', + serviceItem: '', + intervalKm: '' as number | '', + intervalDays: '' as number | '', + description: '', +}; + export function MaintenancePage() { const { toast } = useToast(); const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); const [formData, setFormData] = useState(emptyForm); + const [intervalForm, setIntervalForm] = useState(emptyIntervalForm); + const [completeTarget, setCompleteTarget] = useState(null); + const [completeOdometer, setCompleteOdometer] = useState(''); + const [completeCost, setCompleteCost] = useState(''); // Maintenance is driven by time AND km, not a picked-then-scheduled action — // this is the fleet-wide board of what's actually due, by date or mileage. @@ -94,7 +122,32 @@ export function MaintenancePage() { enabled: !!selectedVehicle, }); + const { data: intervals } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/intervals/${selectedVehicle}`); + return (res.data || []) as MaintenanceInterval[]; + }, + enabled: !!selectedVehicle, + }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : []; + + const invalidateVehicle = () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT }); + }; + + const onError = (err: unknown) => { + toast({ + title: 'Error', + description: + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + 'Failed', + variant: 'destructive', + }); + }; const scheduleMutation = useMutation({ mutationFn: async () => { @@ -102,24 +155,79 @@ export function MaintenancePage() { const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, + serviceItem: formData.serviceItem.trim() || undefined, }); return res.data; }, onSuccess: () => { toast({ title: 'Maintenance scheduled' }); - queryClient.invalidateQueries({ - queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - }); + invalidateVehicle(); setOpenScheduleModal(false); setFormData(emptyForm); }, - onError: (err: any) => { - toast({ - title: 'Error', - description: err?.response?.data?.message ?? 'Failed', - variant: 'destructive', + onError, + }); + + // Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling + // of the next service when a maintenance completes with an odometer reading. + const intervalMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + const res = await api.post('/maintenance/intervals', { + vehicleId: selectedVehicle, + maintenanceType: intervalForm.maintenanceType, + serviceItem: intervalForm.serviceItem.trim() || undefined, + intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm), + intervalDays: + intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays), + description: intervalForm.description.trim() || undefined, }); + return res.data; }, + onSuccess: () => { + toast({ title: 'Interval saved' }); + invalidateVehicle(); + setIntervalForm(emptyIntervalForm); + }, + onError, + }); + + const deactivateIntervalMutation = useMutation({ + mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`), + onSuccess: () => { + toast({ title: 'Interval deactivated' }); + invalidateVehicle(); + }, + onError, + }); + + // Completion with odometer: the reading is what advances KM-based + // scheduling — the API auto-creates the next SCHEDULED item from it. + const completeMutation = useMutation({ + mutationFn: async () => { + if (!completeTarget) return; + const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, { + status: 'COMPLETED', + completedDate: new Date().toISOString(), + odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer), + actualCost: completeCost === '' ? undefined : Number(completeCost), + }); + return res.data; + }, + onSuccess: () => { + toast({ + title: 'Maintenance completed', + description: + completeOdometer === '' + ? 'No odometer recorded — next service was NOT auto-scheduled.' + : 'Next service auto-scheduled from the recorded odometer.', + }); + invalidateVehicle(); + setCompleteTarget(null); + setCompleteOdometer(''); + setCompleteCost(''); + }, + onError, }); const vehicleOptions = @@ -170,6 +278,7 @@ export function MaintenancePage() { Vehicle Type + Service Item Next Due Date Next Due Km Current Km @@ -186,6 +295,7 @@ export function MaintenancePage() { > {row.plateNumber} {row.maintenanceType} + {row.serviceItem ?? '—'} {row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'} @@ -231,50 +341,179 @@ export function MaintenancePage() { ) : ( - - - Upcoming Maintenance - - - {isLoading ? ( - Loading... - ) : upcomingList.length > 0 ? ( - - - - Type - Description - Scheduled - Est. Cost - Status - - - - {upcomingList.map((m) => ( - - {m.maintenanceType} - {m.description} - {new Date(m.scheduledDate).toLocaleDateString()} - - {m.estimatedCost != null - ? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}` - : '—'} - - - {m.status} - + <> + + + Service Intervals — drives auto-scheduling + + e.g. oil change every 10,000 km. On completion with an odometer reading, the + next service is scheduled automatically at reading + interval. + + + + + {intervalList.length > 0 && ( +
+ + + Type + Service Item + Every (km) + Every (days) + Description + + + + + {intervalList.map((i) => ( + + {i.maintenanceType} + {i.serviceItem ?? '—'} + {i.intervalKm ?? '—'} + {i.intervalDays ?? '—'} + {i.description ?? '—'} + + + deactivateIntervalMutation.mutate(i.id)} + > + + + + + + ))} + +
+ )} + +