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:
Hagernesh Tadesse
2026-07-23 14:33:15 +03:00
committed by GitHub
12 changed files with 694 additions and 113 deletions

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View File

@@ -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' })

View File

@@ -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",

View File

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

View File

@@ -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;

View File

@@ -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: {

View File

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