mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #936 from Tria-plc/Truckmaintenance
Export interchange document — a generation error after Djibouti unloa… and Intervals were definable only via SQL and completion couldn't record an odometer, so KM-based auto-scheduling never fired. Add /maintenance/intervals endpoints + per-vehicle intervals UI, odometer on the completion dialog, service_item to distinguish oil vs tire intervals, full due board (all items per vehicle), no-dup completion guard and dual km+date thresholds on one schedule.
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 { 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<MaintenanceInt
|
||||
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({
|
||||
where: { vehicleId, maintenanceType, isActive: true },
|
||||
where: {
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
isActive: true,
|
||||
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||
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<MaintenanceInterval> {
|
||||
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<MaintenanceInt
|
||||
this.intervalRepository.create({
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
serviceItem: item,
|
||||
intervalKm,
|
||||
intervalDays,
|
||||
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 { 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' })
|
||||
|
||||
@@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
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<MaintenanceSchedule> {
|
||||
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",
|
||||
|
||||
@@ -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<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, {
|
||||
...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<void> {
|
||||
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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||
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 —
|
||||
// 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() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Next Due Date</Table.Th>
|
||||
<Table.Th>Next Due 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.maintenanceType}</Table.Td>
|
||||
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||
</Table.Td>
|
||||
@@ -231,50 +341,179 @@ export function MaintenancePage() {
|
||||
</Text>
|
||||
</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>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<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>
|
||||
<>
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||
next service is scheduled automatically at reading + interval.
|
||||
</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Stack gap="sm">
|
||||
{intervalList.length > 0 && (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Every (km)</Table.Th>
|
||||
<Table.Th>Every (days)</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{intervalList.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||
<ActionIcon
|
||||
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.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<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>
|
||||
|
||||
@@ -291,6 +530,12 @@ export function MaintenancePage() {
|
||||
value={formData.maintenanceType}
|
||||
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
|
||||
label="Description"
|
||||
placeholder="What needs to be done?"
|
||||
@@ -335,6 +580,49 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user