mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
Merge branch 'freight/hot_fix' of github.com:Tria-plc/edr-platform into freight/hot_fix
This commit is contained in:
@@ -70,6 +70,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
import { DriversModule } from './modules/drivers/drivers.module';
|
import { DriversModule } from './modules/drivers/drivers.module';
|
||||||
import { FuelModule } from './modules/fuel/fuel.module';
|
import { FuelModule } from './modules/fuel/fuel.module';
|
||||||
|
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
|
||||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
@@ -135,6 +136,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
FuelModule,
|
FuelModule,
|
||||||
|
MaintenanceModule,
|
||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Create maintenance_schedules table
|
||||||
|
const scheduleTableExists = await queryRunner.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!scheduleTableExists[0].exists) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "freight"."maintenance_schedules" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"vehicle_id" uuid NOT NULL,
|
||||||
|
"maintenance_type" varchar NOT NULL,
|
||||||
|
"description" varchar NOT NULL,
|
||||||
|
"scheduled_date" timestamptz NOT NULL,
|
||||||
|
"completed_date" timestamptz,
|
||||||
|
"estimated_cost" numeric(14,2),
|
||||||
|
"actual_cost" numeric(14,2),
|
||||||
|
"status" varchar NOT NULL DEFAULT 'SCHEDULED',
|
||||||
|
"odometer_reading" numeric,
|
||||||
|
"service_provider" varchar,
|
||||||
|
"notes" text,
|
||||||
|
"next_due_km" numeric,
|
||||||
|
"next_due_date" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create maintenance_costs table
|
||||||
|
const costsTableExists = await queryRunner.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'freight' AND table_name = 'maintenance_costs'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!costsTableExists[0].exists) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "freight"."maintenance_costs" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"vehicle_id" uuid NOT NULL,
|
||||||
|
"maintenance_schedule_id" uuid,
|
||||||
|
"incurred_date" timestamptz NOT NULL,
|
||||||
|
"cost_amount" numeric(14,2) NOT NULL,
|
||||||
|
"cost_type" varchar NOT NULL,
|
||||||
|
"description" varchar NOT NULL,
|
||||||
|
"service_provider" varchar,
|
||||||
|
"invoice_number" varchar,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id")
|
||||||
|
REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { MaintenanceService } from './maintenance.service';
|
||||||
|
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||||
|
|
||||||
|
@ApiTags('Maintenance Management')
|
||||||
|
@Controller('maintenance')
|
||||||
|
export class MaintenanceController {
|
||||||
|
constructor(private readonly maintenanceService: MaintenanceService) {}
|
||||||
|
|
||||||
|
@Post('schedules')
|
||||||
|
@ApiOperation({ summary: 'Schedule maintenance' })
|
||||||
|
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
|
||||||
|
return this.maintenanceService.scheduleMaintenanceAsync(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('costs')
|
||||||
|
@ApiOperation({ summary: 'Record maintenance cost' })
|
||||||
|
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
|
||||||
|
return this.maintenanceService.recordMaintenanceCost(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('schedules/:id')
|
||||||
|
@ApiOperation({ summary: 'Update maintenance schedule' })
|
||||||
|
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
|
||||||
|
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('upcoming/:vehicleId')
|
||||||
|
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||||
|
async getUpcoming(@Param('vehicleId') vehicleId: string) {
|
||||||
|
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history/:vehicleId')
|
||||||
|
@ApiOperation({ summary: 'Get maintenance history' })
|
||||||
|
async getHistory(@Param('vehicleId') vehicleId: string) {
|
||||||
|
return this.maintenanceService.getMaintenanceHistory(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats/:vehicleId')
|
||||||
|
@ApiOperation({ summary: 'Get maintenance statistics' })
|
||||||
|
async getStats(@Param('vehicleId') vehicleId: string) {
|
||||||
|
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||||
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
|
import { MaintenanceService } from './maintenance.service';
|
||||||
|
import { MaintenanceRepository } from './maintenance.repository';
|
||||||
|
import { MaintenanceController } from './maintenance.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
|
||||||
|
providers: [MaintenanceService, MaintenanceRepository],
|
||||||
|
controllers: [MaintenanceController],
|
||||||
|
exports: [MaintenanceService],
|
||||||
|
})
|
||||||
|
export class MaintenanceModule {}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { MaintenanceRepository } from './maintenance.repository';
|
||||||
|
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||||
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
|
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MaintenanceService {
|
||||||
|
constructor(
|
||||||
|
private readonly maintenanceRepository: MaintenanceRepository,
|
||||||
|
@InjectRepository(MaintenanceSchedule)
|
||||||
|
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||||
|
@InjectRepository(MaintenanceCost)
|
||||||
|
private readonly costRepository: Repository<MaintenanceCost>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
|
||||||
|
const schedule = this.scheduleRepository.create({
|
||||||
|
...dto,
|
||||||
|
scheduledDate: new Date(dto.scheduledDate),
|
||||||
|
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
|
||||||
|
});
|
||||||
|
return this.scheduleRepository.save(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
|
||||||
|
const cost = this.costRepository.create({
|
||||||
|
...dto,
|
||||||
|
incurredDate: new Date(dto.incurredDate),
|
||||||
|
});
|
||||||
|
return this.costRepository.save(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMaintenanceSchedule(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateMaintenanceScheduleDto,
|
||||||
|
): Promise<MaintenanceSchedule> {
|
||||||
|
await this.scheduleRepository.update(id, {
|
||||||
|
...dto,
|
||||||
|
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||||
|
});
|
||||||
|
const updated = await this.scheduleRepository.findOneBy({ id });
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingMaintenance(vehicleId: string) {
|
||||||
|
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
||||||
|
return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
||||||
|
|
||||||
|
const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
|
||||||
|
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicleId,
|
||||||
|
totalCost,
|
||||||
|
numberOfMaintenanceItems: costs.length,
|
||||||
|
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
|
||||||
|
costByType: this.groupCostsByType(costs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupCostsByType(costs: MaintenanceCost[]) {
|
||||||
|
const grouped: Record<string, number> = {};
|
||||||
|
costs.forEach((c) => {
|
||||||
|
if (!grouped[c.costType]) grouped[c.costType] = 0;
|
||||||
|
grouped[c.costType] += Number(c.costAmount);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
|||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
|
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
|
||||||
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
|
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
|
||||||
|
import { MaintenancePage } from "./pages/fleet/MaintenancePage";
|
||||||
|
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
@@ -214,6 +216,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Maintenance",
|
||||||
|
href: "/dashboard/maintenance",
|
||||||
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Financial Reports",
|
||||||
|
href: "/dashboard/financial-reports",
|
||||||
|
icon: <Wallet />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// label: "Containers",
|
// label: "Containers",
|
||||||
// href: "/dashboard/containers",
|
// href: "/dashboard/containers",
|
||||||
@@ -755,6 +769,22 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="maintenance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<MaintenancePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="financial-reports"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FinancialReportsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -141,4 +141,24 @@ export const QUERY_KEYS = {
|
|||||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||||
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
FUEL: {
|
||||||
|
ROOT: ["fuel"] as const,
|
||||||
|
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
MAINTENANCE: {
|
||||||
|
ROOT: ["maintenance"] as const,
|
||||||
|
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||||
|
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
||||||
|
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
FINANCIAL_REPORTS: {
|
||||||
|
ROOT: ["financial-reports"] as const,
|
||||||
|
fleet: (vehicleId?: string, months?: number) =>
|
||||||
|
["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core';
|
||||||
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import { vehiclesService } from '@/services/vehicles.service';
|
||||||
|
|
||||||
|
interface FuelStats {
|
||||||
|
vehicleId: string;
|
||||||
|
totalPurchases: number;
|
||||||
|
totalFuel: number;
|
||||||
|
totalCost: number;
|
||||||
|
averageCostPerLiter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaintenanceStats {
|
||||||
|
vehicleId: string;
|
||||||
|
totalCost: number;
|
||||||
|
numberOfMaintenanceItems: number;
|
||||||
|
averageCostPerMaintenance: number;
|
||||||
|
costByType: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombinedReport {
|
||||||
|
vehicleId: string;
|
||||||
|
fuelCost: number;
|
||||||
|
maintenanceCost: number;
|
||||||
|
totalOperatingCost: number;
|
||||||
|
fuelPercentage: number;
|
||||||
|
maintenancePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinancialReportsPage() {
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
|
const [months, setMonths] = useState('12');
|
||||||
|
|
||||||
|
const { data: vehicles } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.VEHICLES.list(),
|
||||||
|
queryFn: () => vehiclesService.getAll({ limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fuelStats } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''),
|
||||||
|
queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null),
|
||||||
|
enabled: !!selectedVehicle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: maintenanceStats } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''),
|
||||||
|
queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null),
|
||||||
|
enabled: !!selectedVehicle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicleOptions = useMemo(
|
||||||
|
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
|
||||||
|
[vehicles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = useMemo(() => {
|
||||||
|
if (!fuelStats || !maintenanceStats) return null;
|
||||||
|
|
||||||
|
const fuelCost = Number(fuelStats.totalCost) || 0;
|
||||||
|
const maintenanceCost = Number(maintenanceStats.totalCost) || 0;
|
||||||
|
const total = fuelCost + maintenanceCost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicleId: selectedVehicle!,
|
||||||
|
fuelCost,
|
||||||
|
maintenanceCost,
|
||||||
|
totalOperatingCost: total,
|
||||||
|
fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0,
|
||||||
|
maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
}, [fuelStats, maintenanceStats, selectedVehicle]);
|
||||||
|
|
||||||
|
const StatCard = ({ label, value }: { label: string; value: string }) => (
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Fleet Financial Analysis</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
label="Vehicle"
|
||||||
|
placeholder="Select a vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicle}
|
||||||
|
onChange={setSelectedVehicle}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
data={[
|
||||||
|
{ label: 'Last 3 months', value: '3' },
|
||||||
|
{ label: 'Last 6 months', value: '6' },
|
||||||
|
{ label: 'Last 12 months', value: '12' },
|
||||||
|
]}
|
||||||
|
value={months}
|
||||||
|
onChange={v => setMonths(v || '12')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Monthly Avg
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Cost Breakdown</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{report.fuelPercentage}%</Text>
|
||||||
|
</Stack>
|
||||||
|
<RingProgress
|
||||||
|
sections={[{ value: report.fuelPercentage, color: 'blue' }]}
|
||||||
|
label={
|
||||||
|
<Text size="xs" align="center">
|
||||||
|
{report.fuelPercentage}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size={100}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Maintenance
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{report.maintenancePercentage}%</Text>
|
||||||
|
</Stack>
|
||||||
|
<RingProgress
|
||||||
|
sections={[{ value: report.maintenancePercentage, color: 'orange' }]}
|
||||||
|
label={
|
||||||
|
<Text size="xs" align="center">
|
||||||
|
{report.maintenancePercentage}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size={100}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Operational Insights</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel Purchases
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{fuelStats?.totalPurchases || 0} transactions</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel Efficiency
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>
|
||||||
|
{fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Maintenance Items
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{maintenanceStats?.numberOfMaintenanceItems || 0} records</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Avg Maintenance Cost
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedVehicle && (
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text c="dimmed">Select a vehicle to view financial reports</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
import { vehiclesService } from '@/services/vehicles.service';
|
||||||
|
|
||||||
|
interface MaintenanceSchedule {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
maintenanceType: string;
|
||||||
|
description: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
completedDate?: string;
|
||||||
|
status: string;
|
||||||
|
estimatedCost?: number;
|
||||||
|
actualCost?: number;
|
||||||
|
serviceProvider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaintenancePage() {
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
|
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
description: '',
|
||||||
|
scheduledDate: new Date(),
|
||||||
|
estimatedCost: 0,
|
||||||
|
serviceProvider: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: vehicles } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.VEHICLES.list(),
|
||||||
|
queryFn: () => vehiclesService.getAll({ limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: upcoming, isLoading } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
||||||
|
queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]),
|
||||||
|
enabled: !!selectedVehicle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedVehicle) return;
|
||||||
|
return api.post('/maintenance/schedules', {
|
||||||
|
vehicleId: selectedVehicle,
|
||||||
|
...formData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') });
|
||||||
|
setOpenScheduleModal(false);
|
||||||
|
setFormData({
|
||||||
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
description: '',
|
||||||
|
scheduledDate: new Date(),
|
||||||
|
estimatedCost: 0,
|
||||||
|
serviceProvider: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicleOptions = useMemo(
|
||||||
|
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
|
||||||
|
[vehicles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
SCHEDULED: 'blue',
|
||||||
|
IN_PROGRESS: 'yellow',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
OVERDUE: 'red',
|
||||||
|
};
|
||||||
|
return colors[status] || 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>Schedule Maintenance</Text>
|
||||||
|
<Button onClick={() => setOpenScheduleModal(true)}>New Schedule</Button>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Select
|
||||||
|
label="Select Vehicle"
|
||||||
|
placeholder="Pick a vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicle}
|
||||||
|
onChange={setSelectedVehicle}
|
||||||
|
/>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedVehicle && (
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Upcoming Maintenance</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text>Loading...</Text>
|
||||||
|
) : (upcoming || []).length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<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>
|
||||||
|
{(upcoming as MaintenanceSchedule[]).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?.toFixed(2) || '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed">No upcoming maintenance</Text>
|
||||||
|
)}
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={openScheduleModal}
|
||||||
|
onClose={() => setOpenScheduleModal(false)}
|
||||||
|
title="Schedule Maintenance"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||||
|
value={formData.maintenanceType}
|
||||||
|
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Description"
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Scheduled Date"
|
||||||
|
value={formData.scheduledDate}
|
||||||
|
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Estimated Cost"
|
||||||
|
value={formData.estimatedCost}
|
||||||
|
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service Provider"
|
||||||
|
placeholder="e.g., John's Auto Repair"
|
||||||
|
value={formData.serviceProvider}
|
||||||
|
onChange={e => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Additional notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setOpenScheduleModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => scheduleMutation.mutate()} loading={scheduleMutation.isPending}>
|
||||||
|
Schedule
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user