mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
Export interchange document — a generation error after Djibouti unloading failed the whole request and left the document missing until a manual rerun. Generation is now best-effort (unload never fails on paperwork) and reruns backfill the document.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
|||||||
@IsEnum(MaintenanceType)
|
@IsEnum(MaintenanceType)
|
||||||
maintenanceType!: MaintenanceType;
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
serviceItem?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
actualCost?: number;
|
actualCost?: number;
|
||||||
|
|
||||||
|
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
odometerReading?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { MaintenanceType } from './maintenance-schedule.entity';
|
import { MaintenanceType } from './maintenance-schedule.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maintenance interval configuration. Defines how often a vehicle/type needs maintenance.
|
* Maintenance interval configuration. Defines how often a vehicle needs a
|
||||||
* Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km).
|
* 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' })
|
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
||||||
@Index(['vehicleId', 'maintenanceType'])
|
@Index(['vehicleId', 'maintenanceType'])
|
||||||
@Unique(['vehicleId', 'maintenanceType'])
|
|
||||||
export class MaintenanceInterval extends BaseEntity {
|
export class MaintenanceInterval extends BaseEntity {
|
||||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
vehicleId!: string;
|
vehicleId!: string;
|
||||||
@@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity {
|
|||||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||||
maintenanceType!: MaintenanceType;
|
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. */
|
/** 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 })
|
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
intervalKm?: number | null;
|
intervalKm?: number | null;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||||
maintenanceType!: MaintenanceType;
|
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' })
|
@Column({ name: 'description' })
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
interval: Record<string, unknown> | null;
|
||||||
|
}) {
|
||||||
|
const saved: Array<Record<string, unknown>> = [];
|
||||||
|
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
|
||||||
|
service.scheduleRepository = {
|
||||||
|
findOneBy: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(opts.before)
|
||||||
|
.mockResolvedValueOnce(opts.after),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn((v: Record<string, unknown>) => v),
|
||||||
|
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||||
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||||
|
|
||||||
@@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
super(intervalRepository);
|
super(intervalRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
|
/**
|
||||||
|
* 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<MaintenanceInterval | null> {
|
||||||
return this.intervalRepository.findOne({
|
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<MaintenanceInterval[]> {
|
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||||
return this.intervalRepository.find({
|
return this.intervalRepository.find({
|
||||||
where: { vehicleId, isActive: true },
|
where: { vehicleId, isActive: true },
|
||||||
order: { maintenanceType: 'ASC' },
|
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertInterval(
|
async upsertInterval(
|
||||||
vehicleId: string,
|
vehicleId: string,
|
||||||
maintenanceType: MaintenanceType,
|
maintenanceType: MaintenanceType,
|
||||||
|
serviceItem?: string | null,
|
||||||
intervalKm?: number | null,
|
intervalKm?: number | null,
|
||||||
intervalDays?: number | null,
|
intervalDays?: number | null,
|
||||||
description?: string | null,
|
description?: string | null,
|
||||||
): Promise<MaintenanceInterval> {
|
): Promise<MaintenanceInterval> {
|
||||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType);
|
const item = serviceItem?.trim() || null;
|
||||||
|
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.intervalRepository.update(existing.id, {
|
await this.intervalRepository.update(existing.id, {
|
||||||
@@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
this.intervalRepository.create({
|
this.intervalRepository.create({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
maintenanceType,
|
maintenanceType,
|
||||||
|
serviceItem: item,
|
||||||
intervalKm,
|
intervalKm,
|
||||||
intervalDays,
|
intervalDays,
|
||||||
description,
|
description,
|
||||||
@@ -57,4 +75,9 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
|
||||||
|
async deactivate(id: string): Promise<void> {
|
||||||
|
await this.intervalRepository.update(id, { isActive: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
|
|||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { MaintenanceDepthService } from './maintenance-depth.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 {
|
import {
|
||||||
CreateWorkOrderDto,
|
CreateWorkOrderDto,
|
||||||
UpdateWorkOrderDto,
|
UpdateWorkOrderDto,
|
||||||
@@ -51,6 +56,27 @@ export class MaintenanceController {
|
|||||||
return this.maintenanceService.getDueBoard();
|
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')
|
@Get('upcoming/:vehicleId')
|
||||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: Date;
|
scheduledDate: Date;
|
||||||
nextDueDate: Date | null;
|
nextDueDate: Date | null;
|
||||||
@@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
overdue: boolean;
|
overdue: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
|
||||||
|
// several (oil vs tires intervals differ).
|
||||||
return this.scheduleRepository.manager.query(`
|
return this.scheduleRepository.manager.query(`
|
||||||
SELECT DISTINCT ON (s.vehicle_id)
|
SELECT
|
||||||
s.id AS "scheduleId",
|
s.id AS "scheduleId",
|
||||||
s.vehicle_id AS "vehicleId",
|
s.vehicle_id AS "vehicleId",
|
||||||
v.plate_number AS "plateNumber",
|
v.plate_number AS "plateNumber",
|
||||||
s.maintenance_type AS "maintenanceType",
|
s.maintenance_type AS "maintenanceType",
|
||||||
|
s.service_item AS "serviceItem",
|
||||||
s.description,
|
s.description,
|
||||||
s.scheduled_date AS "scheduledDate",
|
s.scheduled_date AS "scheduledDate",
|
||||||
s.next_due_date AS "nextDueDate",
|
s.next_due_date AS "nextDueDate",
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import { MaintenanceIntervalRepository } from './maintenance-interval.repository
|
|||||||
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.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';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -107,6 +112,10 @@ export class MaintenanceService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateMaintenanceScheduleDto,
|
dto: UpdateMaintenanceScheduleDto,
|
||||||
): Promise<MaintenanceSchedule> {
|
): Promise<MaintenanceSchedule> {
|
||||||
|
// 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, {
|
await this.scheduleRepository.update(id, {
|
||||||
...dto,
|
...dto,
|
||||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||||
@@ -122,8 +131,12 @@ export class MaintenanceService {
|
|||||||
// Maintenance finished/aborted → vehicle back in service.
|
// Maintenance finished/aborted → vehicle back in service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||||
|
|
||||||
// If completed, schedule the next maintenance based on interval
|
// First transition into COMPLETED with an odometer → auto-schedule next.
|
||||||
if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) {
|
if (
|
||||||
|
dto.status === MaintenanceStatus.COMPLETED &&
|
||||||
|
before?.status !== MaintenanceStatus.COMPLETED &&
|
||||||
|
updated.odometerReading != null
|
||||||
|
) {
|
||||||
await this.scheduleNextMaintenance(updated);
|
await this.scheduleNextMaintenance(updated);
|
||||||
}
|
}
|
||||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||||
@@ -135,52 +148,75 @@ export class MaintenanceService {
|
|||||||
return updated!;
|
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<void> {
|
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get maintenance interval for this type
|
|
||||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||||
completed.vehicleId,
|
completed.vehicleId,
|
||||||
completed.maintenanceType as MaintenanceType,
|
completed.maintenanceType as MaintenanceType,
|
||||||
|
completed.serviceItem,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const completedKm = Number(completed.odometerReading ?? 0);
|
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
|
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
|
||||||
if (interval.intervalKm && interval.intervalKm > 0) {
|
const nextDueDate =
|
||||||
const nextDueKm = completedKm + Number(interval.intervalKm);
|
intervalDays > 0
|
||||||
|
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Create next scheduled maintenance
|
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
|
||||||
const nextSchedule = this.scheduleRepository.create({
|
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,
|
vehicleId: completed.vehicleId,
|
||||||
maintenanceType: completed.maintenanceType,
|
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,
|
scheduledDate: now,
|
||||||
nextDueKm,
|
nextDueKm,
|
||||||
|
nextDueDate,
|
||||||
status: MaintenanceStatus.SCHEDULED,
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -2504,27 +2504,37 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.unloadedCount > 0) {
|
if (result.unloadedCount > 0) {
|
||||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
// Best-effort: the unload is already committed — a paperwork failure must
|
||||||
scheduleId,
|
// not fail the response (it did once: items unloaded, request 500'd, and
|
||||||
direction: 'EXPORT',
|
// the document only appeared after a manual retry days later). The doc
|
||||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
// backfills on any retry since already-unloaded items count as unloaded.
|
||||||
handoverFrom: 'EDR',
|
try {
|
||||||
handoverTo: 'Djibouti Port Operator',
|
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||||
portOperatorName: 'Doraleh Multipurpose Port',
|
scheduleId,
|
||||||
generatedBy: performedBy ?? 'EDR Operations',
|
direction: 'EXPORT',
|
||||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||||
});
|
handoverFrom: 'EDR',
|
||||||
if (document.status !== 'ACKNOWLEDGED') {
|
handoverTo: 'Djibouti Port Operator',
|
||||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
portOperatorName: 'Doraleh Multipurpose Port',
|
||||||
acknowledgedBy: 'Djibouti Port Operator',
|
generatedBy: performedBy ?? 'EDR Operations',
|
||||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
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;
|
return result;
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const QUERY_KEYS = {
|
|||||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
stats: (vehicleId?: string) =>
|
stats: (vehicleId?: string) =>
|
||||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
|
intervals: (vehicleId?: string) =>
|
||||||
|
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FINANCIAL_REPORTS: {
|
FINANCIAL_REPORTS: {
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Container,
|
Container,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Plus } from 'lucide-react';
|
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
|
|||||||
id: string;
|
id: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
completedDate?: string;
|
completedDate?: string;
|
||||||
@@ -40,6 +43,7 @@ interface DueBoardRow {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
nextDueDate: string | null;
|
nextDueDate: string | null;
|
||||||
@@ -50,8 +54,20 @@ interface DueBoardRow {
|
|||||||
overdue: boolean;
|
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 = {
|
const emptyForm = {
|
||||||
maintenanceType: 'PREVENTIVE',
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
serviceItem: '',
|
||||||
description: '',
|
description: '',
|
||||||
scheduledDate: new Date().toISOString().split('T')[0],
|
scheduledDate: new Date().toISOString().split('T')[0],
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
@@ -59,12 +75,24 @@ const emptyForm = {
|
|||||||
notes: '',
|
notes: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyIntervalForm = {
|
||||||
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
serviceItem: '',
|
||||||
|
intervalKm: '' as number | '',
|
||||||
|
intervalDays: '' as number | '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
export function MaintenancePage() {
|
export function MaintenancePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||||
const [formData, setFormData] = useState(emptyForm);
|
const [formData, setFormData] = useState(emptyForm);
|
||||||
|
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
|
||||||
|
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
|
||||||
|
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
|
||||||
|
const [completeCost, setCompleteCost] = useState<number | ''>('');
|
||||||
|
|
||||||
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
// 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.
|
// this is the fleet-wide board of what's actually due, by date or mileage.
|
||||||
@@ -94,7 +122,32 @@ export function MaintenancePage() {
|
|||||||
enabled: !!selectedVehicle,
|
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 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({
|
const scheduleMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -102,24 +155,79 @@ export function MaintenancePage() {
|
|||||||
const res = await api.post('/maintenance/schedules', {
|
const res = await api.post('/maintenance/schedules', {
|
||||||
vehicleId: selectedVehicle,
|
vehicleId: selectedVehicle,
|
||||||
...formData,
|
...formData,
|
||||||
|
serviceItem: formData.serviceItem.trim() || undefined,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Maintenance scheduled' });
|
toast({ title: 'Maintenance scheduled' });
|
||||||
queryClient.invalidateQueries({
|
invalidateVehicle();
|
||||||
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
|
||||||
});
|
|
||||||
setOpenScheduleModal(false);
|
setOpenScheduleModal(false);
|
||||||
setFormData(emptyForm);
|
setFormData(emptyForm);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError,
|
||||||
toast({
|
});
|
||||||
title: 'Error',
|
|
||||||
description: err?.response?.data?.message ?? 'Failed',
|
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
|
||||||
variant: 'destructive',
|
// 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 =
|
const vehicleOptions =
|
||||||
@@ -170,6 +278,7 @@ export function MaintenancePage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Vehicle</Table.Th>
|
<Table.Th>Vehicle</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Service Item</Table.Th>
|
||||||
<Table.Th>Next Due Date</Table.Th>
|
<Table.Th>Next Due Date</Table.Th>
|
||||||
<Table.Th>Next Due Km</Table.Th>
|
<Table.Th>Next Due Km</Table.Th>
|
||||||
<Table.Th>Current Km</Table.Th>
|
<Table.Th>Current Km</Table.Th>
|
||||||
@@ -186,6 +295,7 @@ export function MaintenancePage() {
|
|||||||
>
|
>
|
||||||
<Table.Td>{row.plateNumber}</Table.Td>
|
<Table.Td>{row.plateNumber}</Table.Td>
|
||||||
<Table.Td>{row.maintenanceType}</Table.Td>
|
<Table.Td>{row.maintenanceType}</Table.Td>
|
||||||
|
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -231,50 +341,179 @@ export function MaintenancePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder>
|
<>
|
||||||
<Card.Section p="md" withBorder>
|
<Card withBorder>
|
||||||
<Text fw={500}>Upcoming Maintenance</Text>
|
<Card.Section p="md" withBorder>
|
||||||
</Card.Section>
|
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||||
<Card.Section p="md">
|
<Text size="xs" c="dimmed">
|
||||||
{isLoading ? (
|
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||||
<Text>Loading...</Text>
|
next service is scheduled automatically at reading + interval.
|
||||||
) : upcomingList.length > 0 ? (
|
</Text>
|
||||||
<Table striped highlightOnHover>
|
</Card.Section>
|
||||||
<Table.Thead>
|
<Card.Section p="md">
|
||||||
<Table.Tr>
|
<Stack gap="sm">
|
||||||
<Table.Th>Type</Table.Th>
|
{intervalList.length > 0 && (
|
||||||
<Table.Th>Description</Table.Th>
|
<Table striped>
|
||||||
<Table.Th>Scheduled</Table.Th>
|
<Table.Thead>
|
||||||
<Table.Th>Est. Cost</Table.Th>
|
<Table.Tr>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
</Table.Tr>
|
<Table.Th>Service Item</Table.Th>
|
||||||
</Table.Thead>
|
<Table.Th>Every (km)</Table.Th>
|
||||||
<Table.Tbody>
|
<Table.Th>Every (days)</Table.Th>
|
||||||
{upcomingList.map((m) => (
|
<Table.Th>Description</Table.Th>
|
||||||
<Table.Tr key={m.id}>
|
<Table.Th />
|
||||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>{m.description}</Table.Td>
|
</Table.Thead>
|
||||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
<Table.Tbody>
|
||||||
<Table.Td>
|
{intervalList.map((i) => (
|
||||||
{m.estimatedCost != null
|
<Table.Tr key={i.id}>
|
||||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||||
minimumFractionDigits: 2,
|
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||||
maximumFractionDigits: 2,
|
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||||
})}`
|
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||||
: '—'}
|
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
<ActionIcon
|
||||||
</Table.Td>
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => deactivateIntervalMutation.mutate(i.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
w={150}
|
||||||
|
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||||
|
value={intervalForm.maintenanceType}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service item"
|
||||||
|
placeholder="e.g. oil change"
|
||||||
|
w={170}
|
||||||
|
value={intervalForm.serviceItem}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Every (km)"
|
||||||
|
min={0}
|
||||||
|
w={130}
|
||||||
|
value={intervalForm.intervalKm}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Every (days)"
|
||||||
|
min={0}
|
||||||
|
w={130}
|
||||||
|
value={intervalForm.intervalDays}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({
|
||||||
|
...intervalForm,
|
||||||
|
intervalDays: v === '' ? '' : Number(v),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Description"
|
||||||
|
placeholder="Oil and filter change"
|
||||||
|
style={{ flex: 1, minWidth: 160 }}
|
||||||
|
value={intervalForm.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
loading={intervalMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
|
||||||
|
}
|
||||||
|
onClick={() => intervalMutation.mutate()}
|
||||||
|
>
|
||||||
|
Save interval
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Upcoming Maintenance</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text>Loading...</Text>
|
||||||
|
) : upcomingList.length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Service Item</Table.Th>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Scheduled</Table.Th>
|
||||||
|
<Table.Th>Est. Cost</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
</Table.Thead>
|
||||||
</Table.Tbody>
|
<Table.Tbody>
|
||||||
</Table>
|
{upcomingList.map((m) => (
|
||||||
) : (
|
<Table.Tr key={m.id}>
|
||||||
<Text c="dimmed">No upcoming maintenance</Text>
|
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||||
)}
|
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||||
</Card.Section>
|
<Table.Td>{m.description}</Table.Td>
|
||||||
</Card>
|
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{m.estimatedCost != null
|
||||||
|
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`
|
||||||
|
: '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={13} />}
|
||||||
|
onClick={() => setCompleteTarget(m)}
|
||||||
|
>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed">No upcoming maintenance</Text>
|
||||||
|
)}
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -291,6 +530,12 @@ export function MaintenancePage() {
|
|||||||
value={formData.maintenanceType}
|
value={formData.maintenanceType}
|
||||||
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service item"
|
||||||
|
placeholder="e.g. oil change — links this schedule to its interval"
|
||||||
|
value={formData.serviceItem}
|
||||||
|
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="What needs to be done?"
|
placeholder="What needs to be done?"
|
||||||
@@ -335,6 +580,49 @@ export function MaintenancePage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={completeTarget != null}
|
||||||
|
onClose={() => setCompleteTarget(null)}
|
||||||
|
title={`Complete maintenance${completeTarget?.serviceItem ? ` — ${completeTarget.serviceItem}` : ''}`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Record the odometer at completion — the next service is auto-scheduled at reading +
|
||||||
|
interval (e.g. completed at 50,000 km with a 10,000 km interval ⇒ next due at 60,000
|
||||||
|
km).
|
||||||
|
</Text>
|
||||||
|
<NumberInput
|
||||||
|
label="Odometer reading (km)"
|
||||||
|
placeholder="e.g. 50000"
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
value={completeOdometer}
|
||||||
|
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Actual cost (ETB)"
|
||||||
|
min={0}
|
||||||
|
value={completeCost}
|
||||||
|
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setCompleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={15} />}
|
||||||
|
loading={completeMutation.isPending}
|
||||||
|
disabled={completeOdometer === ''}
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Complete & schedule next
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user