feat: Scheduled Maintenance feature complete
This commit is contained in:
43
backend/package-lock.json
generated
43
backend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"get-jwks": "^11.0.3",
|
||||
"ioredis": "^5.4.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"opossum": "^8.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"resend": "^3.0.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/opossum": "^8.0.0",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/supertest": "^6.0.3",
|
||||
@@ -77,7 +79,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1946,11 +1947,17 @@
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/opossum": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/opossum/-/opossum-8.1.9.tgz",
|
||||
@@ -2072,7 +2079,6 @@
|
||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
@@ -2317,7 +2323,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2763,7 +2768,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3609,7 +3613,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4931,7 +4934,6 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -5852,6 +5854,7 @@
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
@@ -6058,6 +6061,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -6430,7 +6445,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -7152,6 +7166,7 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -7692,7 +7707,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7850,7 +7864,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -7938,7 +7951,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8046,6 +8058,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@@ -36,12 +36,14 @@
|
||||
"@fastify/autoload": "^6.0.1",
|
||||
"get-jwks": "^11.0.3",
|
||||
"file-type": "^16.5.4",
|
||||
"resend": "^3.0.0"
|
||||
"resend": "^3.0.0",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"typescript": "^5.7.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"nodemon": "^3.1.9",
|
||||
|
||||
@@ -100,9 +100,9 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
app.get('/auth/verify', {
|
||||
preHandler: [app.authenticate]
|
||||
}, async (request, reply) => {
|
||||
const user = request.user ?? {};
|
||||
const userId = typeof user.sub === 'string' ? user.sub : 'unknown';
|
||||
const rolesClaim = user['https://motovaultpro.com/roles'];
|
||||
const user = request.user;
|
||||
const userId = user?.sub || 'unknown';
|
||||
const rolesClaim = user?.['https://motovaultpro.com/roles'];
|
||||
const roles = Array.isArray(rolesClaim) ? rolesClaim : [];
|
||||
|
||||
reply
|
||||
|
||||
@@ -5,18 +5,39 @@
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import buildGetJwks from 'get-jwks';
|
||||
import fastifyJwt from '@fastify/jwt';
|
||||
import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
|
||||
// Define the Auth0 JWT payload type
|
||||
interface Auth0JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
'https://motovaultpro.com/roles'?: string[];
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// Extend @fastify/jwt module with our payload type
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: Auth0JwtPayload;
|
||||
user: Auth0JwtPayload;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
interface FastifyRequest {
|
||||
jwtVerify(): Promise<void>;
|
||||
user?: any;
|
||||
userContext?: {
|
||||
userId: string;
|
||||
email?: string;
|
||||
@@ -41,7 +62,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
|
||||
// Register @fastify/jwt with Auth0 JWKS validation
|
||||
await fastify.register(require('@fastify/jwt'), {
|
||||
await fastify.register(fastifyJwt, {
|
||||
decode: { complete: true },
|
||||
secret: async (_request: FastifyRequest, token: any) => {
|
||||
try {
|
||||
|
||||
38
backend/src/core/scheduler/index.ts
Normal file
38
backend/src/core/scheduler/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @ai-summary Centralized cron job scheduler
|
||||
* @ai-context Uses node-cron for scheduling background tasks
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
import { logger } from '../logging/logger';
|
||||
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
|
||||
|
||||
let schedulerInitialized = false;
|
||||
|
||||
export function initializeScheduler(): void {
|
||||
if (schedulerInitialized) {
|
||||
logger.warn('Scheduler already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Initializing cron scheduler');
|
||||
|
||||
// Daily notification processing at 8 AM
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
logger.info('Running scheduled notification job');
|
||||
try {
|
||||
await processScheduledNotifications();
|
||||
} catch (error) {
|
||||
logger.error('Scheduled notification job failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
schedulerInitialized = true;
|
||||
logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily');
|
||||
}
|
||||
|
||||
export function isSchedulerInitialized(): boolean {
|
||||
return schedulerInitialized;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { app } from '../../../../app';
|
||||
import pool from '../../../../core/config/database';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
|
||||
|
||||
const DEFAULT_ADMIN_SUB = 'test-admin-123';
|
||||
@@ -20,7 +21,6 @@ let currentUser = {
|
||||
|
||||
// Mock auth plugin to inject test admin user
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
const fastifyPlugin = require('fastify-plugin');
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
|
||||
@@ -9,11 +9,11 @@ import pool from '../../../../core/config/database';
|
||||
import { redis } from '../../../../core/config/redis';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
|
||||
|
||||
// Mock auth plugin to inject test admin user
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
const fastifyPlugin = require('fastify-plugin');
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
@@ -82,7 +82,6 @@ describe('Admin Station Oversight Integration Tests', () => {
|
||||
it('should reject non-admin user trying to list stations', async () => {
|
||||
jest.isolateModules(() => {
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
const fastifyPlugin = require('fastify-plugin');
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
|
||||
@@ -41,6 +41,11 @@ export class MaintenanceRepository {
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isActive: row.is_active,
|
||||
emailNotifications: row.email_notifications,
|
||||
scheduleType: row.schedule_type,
|
||||
fixedDueDate: row.fixed_due_date,
|
||||
reminderDays1: row.reminder_days_1,
|
||||
reminderDays2: row.reminder_days_2,
|
||||
reminderDays3: row.reminder_days_3,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
@@ -192,12 +197,18 @@ export class MaintenanceRepository {
|
||||
nextDueMileage?: number | null;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
scheduleType?: string;
|
||||
fixedDueDate?: string | null;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
}): Promise<MaintenanceSchedule> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO maintenance_schedules (
|
||||
id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles,
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications,
|
||||
schedule_type, fixed_due_date, reminder_days_1, reminder_days_2, reminder_days_3
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING *`,
|
||||
[
|
||||
schedule.id,
|
||||
@@ -213,6 +224,11 @@ export class MaintenanceRepository {
|
||||
schedule.nextDueMileage ?? null,
|
||||
schedule.isActive,
|
||||
schedule.emailNotifications ?? false,
|
||||
schedule.scheduleType ?? 'interval',
|
||||
schedule.fixedDueDate ?? null,
|
||||
schedule.reminderDays1 ?? null,
|
||||
schedule.reminderDays2 ?? null,
|
||||
schedule.reminderDays3 ?? null,
|
||||
]
|
||||
);
|
||||
return this.mapMaintenanceSchedule(res.rows[0]);
|
||||
@@ -245,7 +261,7 @@ export class MaintenanceRepository {
|
||||
async updateSchedule(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications' | 'scheduleType' | 'fixedDueDate' | 'reminderDays1' | 'reminderDays2' | 'reminderDays3'>>
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -291,6 +307,26 @@ export class MaintenanceRepository {
|
||||
fields.push(`email_notifications = $${i++}`);
|
||||
params.push(patch.emailNotifications);
|
||||
}
|
||||
if (patch.scheduleType !== undefined) {
|
||||
fields.push(`schedule_type = $${i++}`);
|
||||
params.push(patch.scheduleType);
|
||||
}
|
||||
if (patch.fixedDueDate !== undefined) {
|
||||
fields.push(`fixed_due_date = $${i++}`);
|
||||
params.push(patch.fixedDueDate);
|
||||
}
|
||||
if (patch.reminderDays1 !== undefined) {
|
||||
fields.push(`reminder_days_1 = $${i++}`);
|
||||
params.push(patch.reminderDays1);
|
||||
}
|
||||
if (patch.reminderDays2 !== undefined) {
|
||||
fields.push(`reminder_days_2 = $${i++}`);
|
||||
params.push(patch.reminderDays2);
|
||||
}
|
||||
if (patch.reminderDays3 !== undefined) {
|
||||
fields.push(`reminder_days_3 = $${i++}`);
|
||||
params.push(patch.reminderDays3);
|
||||
}
|
||||
|
||||
if (!fields.length) return this.findScheduleById(id, userId);
|
||||
|
||||
@@ -306,4 +342,46 @@ export class MaintenanceRepository {
|
||||
[id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
async findMatchingSchedules(
|
||||
userId: string,
|
||||
vehicleId: string,
|
||||
category: MaintenanceCategory,
|
||||
subtypes: string[]
|
||||
): Promise<MaintenanceSchedule[]> {
|
||||
const result = await this.db.query(
|
||||
`SELECT * FROM maintenance_schedules
|
||||
WHERE user_id = $1
|
||||
AND vehicle_id = $2
|
||||
AND category = $3
|
||||
AND is_active = true
|
||||
AND schedule_type = 'time_since_last'
|
||||
AND subtypes && $4::text[]
|
||||
ORDER BY created_at DESC`,
|
||||
[userId, vehicleId, category, subtypes]
|
||||
);
|
||||
return result.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async updateScheduleLastService(
|
||||
id: string,
|
||||
userId: string,
|
||||
lastServiceDate: string,
|
||||
lastServiceMileage?: number | null,
|
||||
nextDueDate?: string | null,
|
||||
nextDueMileage?: number | null
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const result = await this.db.query(
|
||||
`UPDATE maintenance_schedules SET
|
||||
last_service_date = $1,
|
||||
last_service_mileage = $2,
|
||||
next_due_date = $3,
|
||||
next_due_mileage = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5 AND user_id = $6
|
||||
RETURNING *`,
|
||||
[lastServiceDate, lastServiceMileage, nextDueDate, nextDueMileage, id, userId]
|
||||
);
|
||||
return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
MaintenanceSchedule,
|
||||
MaintenanceRecordResponse,
|
||||
MaintenanceScheduleResponse,
|
||||
MaintenanceCategory
|
||||
MaintenanceCategory,
|
||||
ScheduleType
|
||||
} from './maintenance.types';
|
||||
import { validateSubtypes } from './maintenance.types';
|
||||
import { MaintenanceRepository } from '../data/maintenance.repository';
|
||||
@@ -27,7 +28,7 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
return this.repo.insertRecord({
|
||||
const record = await this.repo.insertRecord({
|
||||
id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
@@ -39,6 +40,11 @@ export class MaintenanceService {
|
||||
shopName: body.shopName,
|
||||
notes: body.notes,
|
||||
});
|
||||
|
||||
// Auto-link: Find and update matching 'time_since_last' schedules
|
||||
await this.autoLinkRecordToSchedules(userId, record);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
|
||||
@@ -191,12 +197,50 @@ export class MaintenanceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async autoLinkRecordToSchedules(userId: string, record: MaintenanceRecord): Promise<void> {
|
||||
// Find active 'time_since_last' schedules with matching category and overlapping subtypes
|
||||
const matchingSchedules = await this.repo.findMatchingSchedules(
|
||||
userId,
|
||||
record.vehicleId,
|
||||
record.category,
|
||||
record.subtypes
|
||||
);
|
||||
|
||||
for (const schedule of matchingSchedules) {
|
||||
// Calculate new next due dates based on intervals
|
||||
const nextDue = this.calculateNextDue({
|
||||
lastServiceDate: record.date,
|
||||
lastServiceMileage: record.odometerReading,
|
||||
intervalMonths: schedule.intervalMonths,
|
||||
intervalMiles: schedule.intervalMiles,
|
||||
});
|
||||
|
||||
// Update the schedule with new last service info
|
||||
await this.repo.updateScheduleLastService(
|
||||
schedule.id,
|
||||
userId,
|
||||
record.date,
|
||||
record.odometerReading,
|
||||
nextDue.nextDueDate,
|
||||
nextDue.nextDueMileage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateNextDue(schedule: {
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
scheduleType?: ScheduleType;
|
||||
fixedDueDate?: string | null;
|
||||
}): { nextDueDate: string | null; nextDueMileage: number | null } {
|
||||
// Handle fixed_date type - just return the fixed date
|
||||
if (schedule.scheduleType === 'fixed_date' && schedule.fixedDueDate) {
|
||||
return { nextDueDate: schedule.fixedDueDate, nextDueMileage: null };
|
||||
}
|
||||
|
||||
// For interval and time_since_last types, calculate based on last service
|
||||
let nextDueDate: string | null = null;
|
||||
let nextDueMileage: number | null = null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
// Category types
|
||||
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
|
||||
export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last';
|
||||
|
||||
// Subtype definitions (constants for validation)
|
||||
export const ROUTINE_MAINTENANCE_SUBTYPES = [
|
||||
@@ -85,12 +86,23 @@ export interface MaintenanceSchedule {
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications: boolean;
|
||||
scheduleType: ScheduleType;
|
||||
fixedDueDate?: string | null;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Zod schemas for validation (camelCase for API)
|
||||
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
|
||||
export const ScheduleTypeSchema = z.enum(['interval', 'fixed_date', 'time_since_last']);
|
||||
|
||||
const reminderDaysValidator = z.number().int().positive().refine(
|
||||
(val) => [1, 7, 14, 30, 60].includes(val),
|
||||
{ message: 'Reminder days must be one of: 1, 7, 14, 30, 60' }
|
||||
);
|
||||
|
||||
export const CreateMaintenanceRecordSchema = z.object({
|
||||
vehicleId: z.string().uuid(),
|
||||
@@ -122,6 +134,11 @@ export const CreateScheduleSchema = z.object({
|
||||
intervalMonths: z.number().int().positive().optional(),
|
||||
intervalMiles: z.number().int().positive().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
scheduleType: ScheduleTypeSchema.optional().default('interval'),
|
||||
fixedDueDate: z.string().optional(),
|
||||
reminderDays1: reminderDaysValidator.optional(),
|
||||
reminderDays2: reminderDaysValidator.optional(),
|
||||
reminderDays3: reminderDaysValidator.optional(),
|
||||
});
|
||||
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
|
||||
|
||||
@@ -132,6 +149,11 @@ export const UpdateScheduleSchema = z.object({
|
||||
intervalMiles: z.number().int().positive().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
scheduleType: ScheduleTypeSchema.optional(),
|
||||
fixedDueDate: z.string().nullable().optional(),
|
||||
reminderDays1: reminderDaysValidator.nullable().optional(),
|
||||
reminderDays2: reminderDaysValidator.nullable().optional(),
|
||||
reminderDays3: reminderDaysValidator.nullable().optional(),
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Add schedule_type to support different scheduling methods
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN schedule_type VARCHAR(20) NOT NULL DEFAULT 'interval'
|
||||
CHECK (schedule_type IN ('interval', 'fixed_date', 'time_since_last'));
|
||||
|
||||
-- Add fixed_due_date for fixed date schedules
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN fixed_due_date DATE;
|
||||
|
||||
-- Add reminder columns for progressive notifications
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_1 INTEGER
|
||||
CHECK (reminder_days_1 IN (1, 7, 14, 30, 60));
|
||||
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_2 INTEGER
|
||||
CHECK (reminder_days_2 IN (1, 7, 14, 30, 60));
|
||||
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_3 INTEGER
|
||||
CHECK (reminder_days_3 IN (1, 7, 14, 30, 60));
|
||||
|
||||
-- Index for filtering by schedule type
|
||||
CREATE INDEX idx_maintenance_schedules_schedule_type ON maintenance_schedules(schedule_type);
|
||||
@@ -65,6 +65,78 @@ export class NotificationsController {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// In-App Notifications
|
||||
// ========================
|
||||
|
||||
async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const query = request.query as { limit?: string; includeRead?: string };
|
||||
const limit = query.limit ? parseInt(query.limit, 10) : 20;
|
||||
const includeRead = query.includeRead === 'true';
|
||||
|
||||
try {
|
||||
const notifications = await this.service.getUserNotifications(userId, limit, includeRead);
|
||||
return reply.send(notifications);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get in-app notifications');
|
||||
return reply.code(500).send({ error: 'Failed to get notifications' });
|
||||
}
|
||||
}
|
||||
|
||||
async getUnreadCount(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
|
||||
try {
|
||||
const count = await this.service.getUnreadCount(userId);
|
||||
return reply.send(count);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to get unread count');
|
||||
return reply.code(500).send({ error: 'Failed to get unread count' });
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const notificationId = request.params.id;
|
||||
|
||||
try {
|
||||
const notification = await this.service.markAsRead(userId, notificationId);
|
||||
if (!notification) {
|
||||
return reply.code(404).send({ error: 'Notification not found' });
|
||||
}
|
||||
return reply.send(notification);
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to mark notification as read');
|
||||
return reply.code(500).send({ error: 'Failed to mark as read' });
|
||||
}
|
||||
}
|
||||
|
||||
async markAllAsRead(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
|
||||
try {
|
||||
const count = await this.service.markAllAsRead(userId);
|
||||
return reply.send({ markedAsRead: count });
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to mark all notifications as read');
|
||||
return reply.code(500).send({ error: 'Failed to mark all as read' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const notificationId = request.params.id;
|
||||
|
||||
try {
|
||||
await this.service.deleteNotification(notificationId, userId);
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
request.log.error({ error }, 'Failed to delete notification');
|
||||
return reply.code(500).send({ error: 'Failed to delete notification' });
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
@@ -36,6 +36,40 @@ export const notificationsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: controller.getExpiringDocuments.bind(controller)
|
||||
});
|
||||
|
||||
// ========================
|
||||
// In-App Notifications
|
||||
// ========================
|
||||
|
||||
// GET /api/notifications/in-app - Get user's in-app notifications
|
||||
fastify.get('/notifications/in-app', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getInAppNotifications.bind(controller)
|
||||
});
|
||||
|
||||
// GET /api/notifications/in-app/count - Get unread notification count
|
||||
fastify.get('/notifications/in-app/count', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getUnreadCount.bind(controller)
|
||||
});
|
||||
|
||||
// PUT /api/notifications/in-app/:id/read - Mark single notification as read
|
||||
fastify.put<{ Params: { id: string } }>('/notifications/in-app/:id/read', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.markAsRead.bind(controller)
|
||||
});
|
||||
|
||||
// PUT /api/notifications/in-app/read-all - Mark all notifications as read
|
||||
fastify.put('/notifications/in-app/read-all', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.markAllAsRead.bind(controller)
|
||||
});
|
||||
|
||||
// DELETE /api/notifications/in-app/:id - Delete a notification
|
||||
fastify.delete<{ Params: { id: string } }>('/notifications/in-app/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.deleteNotification.bind(controller)
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Admin Endpoints
|
||||
// ========================
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
TemplateKey
|
||||
TemplateKey,
|
||||
UserNotification,
|
||||
UnreadNotificationCount,
|
||||
SentNotificationRecord
|
||||
} from '../domain/notifications.types';
|
||||
|
||||
export class NotificationsRepository {
|
||||
@@ -42,7 +45,10 @@ export class NotificationsRepository {
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isDueSoon: row.is_due_soon,
|
||||
isOverdue: row.is_overdue,
|
||||
emailNotifications: row.email_notifications
|
||||
emailNotifications: row.email_notifications,
|
||||
reminderDays1: row.reminder_1_days,
|
||||
reminderDays2: row.reminder_2_days,
|
||||
reminderDays3: row.reminder_3_days
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +66,33 @@ export class NotificationsRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private mapUserNotification(row: any): UserNotification {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
notificationType: row.notification_type,
|
||||
title: row.title,
|
||||
message: row.message,
|
||||
referenceType: row.reference_type,
|
||||
referenceId: row.reference_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
isRead: row.is_read,
|
||||
createdAt: row.created_at,
|
||||
readAt: row.read_at
|
||||
};
|
||||
}
|
||||
|
||||
private mapSentNotificationRecord(row: any): SentNotificationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
scheduleId: row.schedule_id,
|
||||
notificationDate: row.notification_date,
|
||||
reminderLevel: row.reminder_level,
|
||||
deliveryMethod: row.delivery_method,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
@@ -208,6 +241,9 @@ export class NotificationsRepository {
|
||||
ms.next_due_date,
|
||||
ms.next_due_mileage,
|
||||
ms.email_notifications,
|
||||
ms.reminder_1_days,
|
||||
ms.reminder_2_days,
|
||||
ms.reminder_3_days,
|
||||
CASE
|
||||
WHEN ms.next_due_date <= CURRENT_DATE THEN true
|
||||
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage THEN true
|
||||
@@ -269,4 +305,175 @@ export class NotificationsRepository {
|
||||
);
|
||||
return res.rows.map(row => this.mapExpiringDocument(row));
|
||||
}
|
||||
|
||||
// ========================
|
||||
// User Notifications
|
||||
// ========================
|
||||
|
||||
async insertUserNotification(notification: {
|
||||
userId: string;
|
||||
notificationType: string;
|
||||
title: string;
|
||||
message: string;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
vehicleId?: string;
|
||||
}): Promise<UserNotification> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO user_notifications (
|
||||
id, user_id, notification_type, title, message,
|
||||
reference_type, reference_id, vehicle_id
|
||||
) VALUES (uuid_generate_v4(), $1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
notification.userId,
|
||||
notification.notificationType,
|
||||
notification.title,
|
||||
notification.message,
|
||||
notification.referenceType ?? null,
|
||||
notification.referenceId ?? null,
|
||||
notification.vehicleId ?? null
|
||||
]
|
||||
);
|
||||
return this.mapUserNotification(res.rows[0]);
|
||||
}
|
||||
|
||||
async findUserNotifications(
|
||||
userId: string,
|
||||
limit: number = 20,
|
||||
includeRead: boolean = false
|
||||
): Promise<UserNotification[]> {
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM user_notifications
|
||||
WHERE user_id = $1
|
||||
${includeRead ? '' : 'AND is_read = false'}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
|
||||
const res = await this.db.query(sql, [userId, limit]);
|
||||
return res.rows.map(row => this.mapUserNotification(row));
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<UnreadNotificationCount> {
|
||||
const res = await this.db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE notification_type = 'maintenance') as maintenance,
|
||||
COUNT(*) FILTER (WHERE notification_type = 'document') as documents
|
||||
FROM user_notifications
|
||||
WHERE user_id = $1
|
||||
AND is_read = false`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const row = res.rows[0];
|
||||
return {
|
||||
total: parseInt(row.total || '0', 10),
|
||||
maintenance: parseInt(row.maintenance || '0', 10),
|
||||
documents: parseInt(row.documents || '0', 10)
|
||||
};
|
||||
}
|
||||
|
||||
async markAsRead(id: string, userId: string): Promise<UserNotification | null> {
|
||||
const res = await this.db.query(
|
||||
`UPDATE user_notifications
|
||||
SET is_read = true, read_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *`,
|
||||
[id, userId]
|
||||
);
|
||||
return res.rows[0] ? this.mapUserNotification(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<number> {
|
||||
const res = await this.db.query(
|
||||
`UPDATE user_notifications
|
||||
SET is_read = true, read_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $1 AND is_read = false
|
||||
RETURNING id`,
|
||||
[userId]
|
||||
);
|
||||
return res.rowCount || 0;
|
||||
}
|
||||
|
||||
async deleteUserNotification(id: string, userId: string): Promise<void> {
|
||||
await this.db.query(
|
||||
`DELETE FROM user_notifications
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Sent Notification Tracker
|
||||
// ========================
|
||||
|
||||
async hasNotificationBeenSent(
|
||||
scheduleId: string,
|
||||
notificationDate: string,
|
||||
reminderLevel: 1 | 2 | 3
|
||||
): Promise<boolean> {
|
||||
const res = await this.db.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM sent_notification_tracker
|
||||
WHERE schedule_id = $1
|
||||
AND notification_date = $2
|
||||
AND reminder_level = $3
|
||||
) as exists`,
|
||||
[scheduleId, notificationDate, reminderLevel]
|
||||
);
|
||||
return res.rows[0]?.exists || false;
|
||||
}
|
||||
|
||||
async recordSentNotification(record: {
|
||||
scheduleId: string;
|
||||
notificationDate: string;
|
||||
reminderLevel: 1 | 2 | 3;
|
||||
deliveryMethod: 'email' | 'in_app' | 'both';
|
||||
}): Promise<SentNotificationRecord> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO sent_notification_tracker (
|
||||
schedule_id, notification_date, reminder_level, delivery_method
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
record.scheduleId,
|
||||
record.notificationDate,
|
||||
record.reminderLevel,
|
||||
record.deliveryMethod
|
||||
]
|
||||
);
|
||||
return this.mapSentNotificationRecord(res.rows[0]);
|
||||
}
|
||||
|
||||
async getUsersWithActiveSchedules(): Promise<Array<{
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
}>> {
|
||||
const res = await this.db.query(
|
||||
`SELECT DISTINCT
|
||||
ms.user_id,
|
||||
up.email as user_email,
|
||||
up.name as user_name
|
||||
FROM maintenance_schedules ms
|
||||
JOIN user_profiles up ON ms.user_id = up.user_id
|
||||
WHERE ms.is_active = true
|
||||
AND (
|
||||
ms.reminder_1_days IS NOT NULL
|
||||
OR ms.reminder_2_days IS NOT NULL
|
||||
OR ms.reminder_3_days IS NOT NULL
|
||||
)
|
||||
ORDER BY ms.user_id`
|
||||
);
|
||||
|
||||
return res.rows.map(row => ({
|
||||
userId: row.user_id,
|
||||
userEmail: row.user_email,
|
||||
userName: row.user_name
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
EmailTemplate,
|
||||
TemplateKey
|
||||
TemplateKey,
|
||||
UserNotification,
|
||||
UnreadNotificationCount
|
||||
} from './notifications.types';
|
||||
|
||||
export class NotificationsService {
|
||||
@@ -45,6 +47,30 @@ export class NotificationsService {
|
||||
return this.repository.getExpiringDocuments(userId);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// In-App Notifications
|
||||
// ========================
|
||||
|
||||
async getUserNotifications(userId: string, limit?: number, includeRead?: boolean): Promise<UserNotification[]> {
|
||||
return this.repository.findUserNotifications(userId, limit, includeRead);
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<UnreadNotificationCount> {
|
||||
return this.repository.getUnreadCount(userId);
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, notificationId: string): Promise<UserNotification | null> {
|
||||
return this.repository.markAsRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<number> {
|
||||
return this.repository.markAllAsRead(userId);
|
||||
}
|
||||
|
||||
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||
return this.repository.deleteUserNotification(notificationId, userId);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Email Templates
|
||||
// ========================
|
||||
@@ -274,17 +300,133 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async createInAppNotification(
|
||||
userId: string,
|
||||
item: DueMaintenanceItem,
|
||||
_reminderLevel: 1 | 2 | 3
|
||||
): Promise<void> {
|
||||
const title = item.isOverdue
|
||||
? `OVERDUE: ${this.getCategoryDisplayName(item.category)} for ${item.vehicleName}`
|
||||
: `Due Soon: ${this.getCategoryDisplayName(item.category)} for ${item.vehicleName}`;
|
||||
|
||||
const message = item.isOverdue
|
||||
? `Your ${item.subtypes.join(', ')} maintenance was due ${item.nextDueDate || `at ${item.nextDueMileage?.toLocaleString()} miles`}`
|
||||
: `Your ${item.subtypes.join(', ')} maintenance is due ${item.nextDueDate || `at ${item.nextDueMileage?.toLocaleString()} miles`}`;
|
||||
|
||||
await this.repository.insertUserNotification({
|
||||
userId,
|
||||
notificationType: 'maintenance',
|
||||
title,
|
||||
message,
|
||||
referenceType: 'maintenance_schedule',
|
||||
referenceId: item.id,
|
||||
vehicleId: item.vehicleId,
|
||||
});
|
||||
}
|
||||
|
||||
private getCategoryDisplayName(category: string): string {
|
||||
const names: Record<string, string> = {
|
||||
routine_maintenance: 'Routine Maintenance',
|
||||
repair: 'Repair',
|
||||
performance_upgrade: 'Performance Upgrade',
|
||||
};
|
||||
return names[category] || category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a reminder should be sent based on the due date and reminder days
|
||||
*/
|
||||
private shouldSendReminder(item: DueMaintenanceItem, reminderDays: number, today: Date): boolean {
|
||||
if (!item.nextDueDate) return false;
|
||||
|
||||
const dueDate = new Date(item.nextDueDate);
|
||||
const diffTime = dueDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Send notification if today is exactly reminderDays before due date
|
||||
return diffDays === reminderDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending notifications (called by scheduled job)
|
||||
* This would typically be called by a cron job or scheduler
|
||||
* Checks all active schedules and sends notifications based on reminder_days settings
|
||||
*/
|
||||
async processNotifications(): Promise<void> {
|
||||
// This is a placeholder for batch notification processing
|
||||
// In a production system, this would:
|
||||
// 1. Query for users with email_notifications enabled
|
||||
// 2. Check which items need notifications
|
||||
// 3. Send batch emails
|
||||
// 4. Track sent notifications to avoid duplicates
|
||||
throw new Error('Batch notification processing not yet implemented');
|
||||
async processNotifications(): Promise<{
|
||||
processed: number;
|
||||
emailsSent: number;
|
||||
inAppCreated: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const results = { processed: 0, emailsSent: 0, inAppCreated: 0, errors: [] as string[] };
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
// Get all users with active schedules that have reminders configured
|
||||
const usersWithSchedules = await this.repository.getUsersWithActiveSchedules();
|
||||
|
||||
for (const userData of usersWithSchedules) {
|
||||
const { userId, userEmail, userName } = userData;
|
||||
|
||||
try {
|
||||
// Get due maintenance items for this user
|
||||
const dueItems = await this.repository.getDueMaintenanceItems(userId);
|
||||
|
||||
for (const item of dueItems) {
|
||||
// Check each reminder level (1, 2, 3) for this schedule
|
||||
const reminderLevels: Array<{ level: 1 | 2 | 3; days: number | null | undefined }> = [
|
||||
{ level: 1, days: item.reminderDays1 },
|
||||
{ level: 2, days: item.reminderDays2 },
|
||||
{ level: 3, days: item.reminderDays3 },
|
||||
];
|
||||
|
||||
for (const { level, days } of reminderLevels) {
|
||||
if (!days) continue; // Skip if reminder not configured
|
||||
|
||||
// Calculate if today matches the reminder day
|
||||
const shouldNotify = this.shouldSendReminder(item, days, today);
|
||||
if (!shouldNotify && !item.isOverdue) continue;
|
||||
|
||||
// Check if we've already sent this notification today
|
||||
const alreadySent = await this.repository.hasNotificationBeenSent(
|
||||
item.id,
|
||||
todayStr,
|
||||
level
|
||||
);
|
||||
|
||||
if (alreadySent) continue;
|
||||
|
||||
results.processed++;
|
||||
|
||||
try {
|
||||
// Create in-app notification
|
||||
await this.createInAppNotification(userId, item, level);
|
||||
results.inAppCreated++;
|
||||
|
||||
// Send email if enabled
|
||||
if (item.emailNotifications) {
|
||||
await this.sendMaintenanceNotification(userId, userEmail, userName, item);
|
||||
results.emailsSent++;
|
||||
}
|
||||
|
||||
// Record that notification was sent
|
||||
await this.repository.recordSentNotification({
|
||||
scheduleId: item.id,
|
||||
notificationDate: todayStr,
|
||||
reminderLevel: level,
|
||||
deliveryMethod: item.emailNotifications ? 'both' : 'in_app',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(`Failed for schedule ${item.id} (level ${level}): ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(`Failed processing user ${userId}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ export interface DueMaintenanceItem {
|
||||
isDueSoon: boolean;
|
||||
isOverdue: boolean;
|
||||
emailNotifications: boolean;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
}
|
||||
|
||||
// Expiring document (camelCase for frontend)
|
||||
@@ -97,3 +100,33 @@ export const PreviewTemplateSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;
|
||||
|
||||
// User notification types (camelCase for frontend)
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
userId: string;
|
||||
notificationType: string;
|
||||
title: string;
|
||||
message: string;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
readAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UnreadNotificationCount {
|
||||
total: number;
|
||||
maintenance: number;
|
||||
documents: number;
|
||||
}
|
||||
|
||||
export interface SentNotificationRecord {
|
||||
id: string;
|
||||
scheduleId: string;
|
||||
notificationDate: string;
|
||||
reminderLevel: 1 | 2 | 3;
|
||||
deliveryMethod: 'email' | 'in_app' | 'both';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @ai-summary Daily scheduled job to process maintenance notifications
|
||||
* @ai-context Runs at 8 AM daily to check schedules and send notifications
|
||||
*/
|
||||
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { NotificationsService } from '../domain/notifications.service';
|
||||
|
||||
export async function processScheduledNotifications(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const service = new NotificationsService();
|
||||
|
||||
try {
|
||||
logger.info('Starting scheduled notification processing');
|
||||
|
||||
const results = await service.processNotifications();
|
||||
|
||||
logger.info('Notification processing completed', {
|
||||
durationMs: Date.now() - startTime,
|
||||
processed: results.processed,
|
||||
emailsSent: results.emailsSent,
|
||||
inAppCreated: results.inAppCreated,
|
||||
errorCount: results.errors.length
|
||||
});
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
logger.warn('Some notifications failed', {
|
||||
errors: results.errors.slice(0, 10) // Log first 10 errors
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Notification processing failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startTime
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- user_notifications: In-app notification center for users
|
||||
CREATE TABLE user_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
notification_type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
reference_type VARCHAR(50),
|
||||
reference_id UUID,
|
||||
vehicle_id UUID,
|
||||
is_read BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
read_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id);
|
||||
CREATE INDEX idx_user_notifications_created_at ON user_notifications(created_at DESC);
|
||||
CREATE INDEX idx_user_notifications_unread ON user_notifications(user_id, created_at DESC) WHERE is_read = false;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- sent_notification_tracker: Prevent duplicate notifications for schedules
|
||||
CREATE TABLE sent_notification_tracker (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
schedule_id UUID NOT NULL,
|
||||
notification_date DATE NOT NULL,
|
||||
reminder_level INTEGER NOT NULL CHECK (reminder_level IN (1, 2, 3)),
|
||||
delivery_method VARCHAR(20) NOT NULL CHECK (delivery_method IN ('email', 'in_app', 'both')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT fk_sent_notification_schedule
|
||||
FOREIGN KEY (schedule_id)
|
||||
REFERENCES maintenance_schedules(id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT unique_notification_per_schedule_date_level
|
||||
UNIQUE (schedule_id, notification_date, reminder_level)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_sent_notification_tracker_schedule_id ON sent_notification_tracker(schedule_id);
|
||||
CREATE INDEX idx_sent_notification_tracker_notification_date ON sent_notification_tracker(notification_date);
|
||||
@@ -9,10 +9,10 @@ import pool from '../../../../core/config/database';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
// Mock auth plugin to bypass JWT validation in tests
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
const fastifyPlugin = require('fastify-plugin');
|
||||
return {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { buildApp } from './app';
|
||||
import { appConfig } from './core/config/config-loader';
|
||||
import { logger } from './core/logging/logger';
|
||||
import { initializeScheduler } from './core/scheduler';
|
||||
|
||||
const PORT = appConfig.config.server.port;
|
||||
|
||||
@@ -23,6 +24,9 @@ async function start() {
|
||||
nodeVersion: process.version,
|
||||
framework: 'Fastify'
|
||||
});
|
||||
|
||||
// Initialize cron scheduler for background tasks
|
||||
initializeScheduler();
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server', { error });
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
*** CRITICAL ***: Never read this file. Stop and move on.
|
||||
|
||||
|
||||
*** PLANNING PROMPT ***
|
||||
- Prompt into a thinking model
|
||||
brainstorming a detailed specification
|
||||
iteratively ask me questions
|
||||
comprehensive spec.md - containing requirements, architecture decisions, data models, and even a testing strategy. This spec forms the foundation for development.
|
||||
|
||||
- Prompt into a thinking model
|
||||
- generate a project plan
|
||||
- break into bite-sized tasks and milestones
|
||||
|
||||
|
||||
- generate a structured “prompt plan” file that contains a sequence of prompts for each task
|
||||
|
||||
|
||||
*** ROLE ***
|
||||
You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate.
|
||||
|
||||
*** ACTION ***
|
||||
- You will be implementing the "User Management" feature of this web application.
|
||||
- You will be enhancing the maintenance record feature.
|
||||
- Make no assumptions.
|
||||
- Ask clarifying questions.
|
||||
- Ultrathink
|
||||
@@ -13,13 +27,11 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
|
||||
*** CONTEXT ***
|
||||
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
|
||||
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
|
||||
- There currently is no user management system in this application.
|
||||
- We need to do basic CRUD operations on user accounts
|
||||
- We need to set the groundwork for a tiered paid system in the future. Start with four types of users.
|
||||
- 1. Free 2. Pro 3. Enterprise 4. Administrator
|
||||
- There is a basic maintenance record system implemented.
|
||||
- We need to implement schedule maintenance records with notifications tied into the existing notification system.
|
||||
|
||||
*** CHANGES TO IMPLEMENT ***
|
||||
- Research this code base and look for any gaps in user account management.
|
||||
- Research this code base and ask iterative questions to compile a complete plan.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useAppStore } from '../core/store';
|
||||
import { Button } from '../shared-minimal/components/Button';
|
||||
import { NotificationBell } from '../features/notifications';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -67,9 +68,12 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<div className="text-xs text-slate-500">v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div className="flex-1 px-5 pb-20 space-y-5 overflow-y-auto">
|
||||
<div className="min-h-[560px]">
|
||||
@@ -231,10 +235,13 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<NotificationBell />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Welcome back, {user?.name || user?.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Page content */}
|
||||
|
||||
@@ -48,7 +48,7 @@ const baseEngine: CatalogEngine = {
|
||||
name: '2.0T',
|
||||
displacement: '2.0L',
|
||||
cylinders: 4,
|
||||
fuel_type: 'Gasoline',
|
||||
fuelType: 'Gasoline',
|
||||
createdAt: '2024-01-05T00:00:00Z',
|
||||
updatedAt: '2024-01-05T00:00:00Z',
|
||||
};
|
||||
@@ -129,6 +129,6 @@ describe('buildDefaultValues', () => {
|
||||
expect(defaults.trimId).toBe(baseTrim.id);
|
||||
expect(defaults.displacement).toBe('2.0L');
|
||||
expect(defaults.cylinders).toBe(4);
|
||||
expect(defaults.fuel_type).toBe('Gasoline');
|
||||
expect(defaults.fuelType).toBe('Gasoline');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,35 +25,35 @@ Object.defineProperty(global.URL, 'revokeObjectURL', {
|
||||
describe('DocumentPreview', () => {
|
||||
const mockPdfDocument: DocumentRecord = {
|
||||
id: 'doc-1',
|
||||
user_id: 'user-1',
|
||||
vehicle_id: 'vehicle-1',
|
||||
document_type: 'insurance',
|
||||
userId: 'user-1',
|
||||
vehicleId: 'vehicle-1',
|
||||
documentType: 'insurance',
|
||||
title: 'Insurance Document',
|
||||
content_type: 'application/pdf',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
contentType: 'application/pdf',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockImageDocument: DocumentRecord = {
|
||||
id: 'doc-2',
|
||||
user_id: 'user-1',
|
||||
vehicle_id: 'vehicle-1',
|
||||
document_type: 'registration',
|
||||
userId: 'user-1',
|
||||
vehicleId: 'vehicle-1',
|
||||
documentType: 'registration',
|
||||
title: 'Registration Photo',
|
||||
content_type: 'image/jpeg',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
contentType: 'image/jpeg',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockNonPreviewableDocument: DocumentRecord = {
|
||||
id: 'doc-3',
|
||||
user_id: 'user-1',
|
||||
vehicle_id: 'vehicle-1',
|
||||
document_type: 'insurance',
|
||||
userId: 'user-1',
|
||||
vehicleId: 'vehicle-1',
|
||||
documentType: 'insurance',
|
||||
title: 'Text Document',
|
||||
content_type: 'text/plain',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
contentType: 'text/plain',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -27,21 +27,21 @@ describe('DocumentsMobileScreen', () => {
|
||||
const mockDocuments: DocumentRecord[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
user_id: 'user-1',
|
||||
vehicle_id: 'vehicle-1',
|
||||
document_type: 'insurance',
|
||||
userId: 'user-1',
|
||||
vehicleId: 'vehicle-1',
|
||||
documentType: 'insurance',
|
||||
title: 'Car Insurance',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
user_id: 'user-1',
|
||||
vehicle_id: 'vehicle-2',
|
||||
document_type: 'registration',
|
||||
userId: 'user-1',
|
||||
vehicleId: 'vehicle-2',
|
||||
documentType: 'registration',
|
||||
title: 'Vehicle Registration',
|
||||
created_at: '2024-01-02T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -12,14 +12,7 @@ export const DocumentsMobileScreen: React.FC = () => {
|
||||
console.log('[DocumentsMobileScreen] Component initializing');
|
||||
|
||||
// Auth is managed at App level; keep hook to support session-expired UI.
|
||||
// In test environments without provider, fall back gracefully.
|
||||
let auth = { isAuthenticated: true, isLoading: false, loginWithRedirect: () => {} } as any;
|
||||
try {
|
||||
auth = useAuth0();
|
||||
} catch {
|
||||
// Tests render without Auth0Provider; assume authenticated for unit tests.
|
||||
}
|
||||
|
||||
const auth = useAuth0();
|
||||
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = auth;
|
||||
|
||||
// Data hooks (unconditional per React rules)
|
||||
|
||||
@@ -150,7 +150,9 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
localEffLabel = `${(miles / gallons).toFixed(1)} MPG`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Efficiency calculation failed, leave localEffLabel as null
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @ai-summary Dialog for editing maintenance schedules
|
||||
* @ai-context Modal form following MaintenanceRecordEditDialog pattern
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
FormLabel,
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
MaintenanceScheduleResponse,
|
||||
UpdateScheduleRequest,
|
||||
MaintenanceCategory,
|
||||
ScheduleType,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface MaintenanceScheduleEditDialogProps {
|
||||
open: boolean;
|
||||
schedule: MaintenanceScheduleResponse | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, data: UpdateScheduleRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const MaintenanceScheduleEditDialog: React.FC<MaintenanceScheduleEditDialogProps> = ({
|
||||
open,
|
||||
schedule,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<UpdateScheduleRequest>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const vehiclesQuery = useVehicles();
|
||||
const vehicles = vehiclesQuery.data;
|
||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||
|
||||
// Reset form when schedule changes
|
||||
useEffect(() => {
|
||||
if (schedule && schedule.id) {
|
||||
try {
|
||||
setFormData({
|
||||
category: schedule.category,
|
||||
subtypes: schedule.subtypes,
|
||||
scheduleType: schedule.scheduleType,
|
||||
intervalMonths: schedule.intervalMonths || undefined,
|
||||
intervalMiles: schedule.intervalMiles || undefined,
|
||||
fixedDueDate: schedule.fixedDueDate || undefined,
|
||||
isActive: schedule.isActive,
|
||||
emailNotifications: schedule.emailNotifications || false,
|
||||
reminderDays1: schedule.reminderDays1 || undefined,
|
||||
reminderDays2: schedule.reminderDays2 || undefined,
|
||||
reminderDays3: schedule.reminderDays3 || undefined,
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[MaintenanceScheduleEditDialog] Error setting form data:', err);
|
||||
setError(err as Error);
|
||||
}
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
const handleInputChange = (field: keyof UpdateScheduleRequest, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleScheduleTypeChange = (newType: ScheduleType) => {
|
||||
setFormData((prev) => {
|
||||
const updated: UpdateScheduleRequest = {
|
||||
...prev,
|
||||
scheduleType: newType,
|
||||
};
|
||||
|
||||
// Clear fields that don't apply to new schedule type
|
||||
if (newType === 'interval') {
|
||||
updated.fixedDueDate = null;
|
||||
} else if (newType === 'fixed_date') {
|
||||
updated.intervalMonths = null;
|
||||
updated.intervalMiles = null;
|
||||
} else if (newType === 'time_since_last') {
|
||||
updated.fixedDueDate = null;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!schedule || !schedule.id) {
|
||||
console.error('[MaintenanceScheduleEditDialog] No valid schedule to save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Filter out unchanged fields
|
||||
const changedData: UpdateScheduleRequest = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const typedKey = key as keyof UpdateScheduleRequest;
|
||||
const scheduleValue = schedule[typedKey as keyof MaintenanceScheduleResponse];
|
||||
|
||||
// Special handling for arrays
|
||||
if (Array.isArray(value) && Array.isArray(scheduleValue)) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(scheduleValue)) {
|
||||
(changedData as any)[key] = value;
|
||||
}
|
||||
} else if (value !== scheduleValue) {
|
||||
(changedData as any)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Only send update if there are actual changes
|
||||
if (Object.keys(changedData).length > 0) {
|
||||
await onSave(schedule.id, changedData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('[MaintenanceScheduleEditDialog] Failed to save schedule:', err);
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Early bailout if dialog not open or no schedule to edit
|
||||
if (!open || !schedule) return null;
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Error Loading Maintenance Schedule</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="error">
|
||||
Failed to load maintenance schedule data. Please try again.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{error.message}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const currentScheduleType = formData.scheduleType || 'interval';
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Maintenance Schedule</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Vehicle (Read-only display) */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Vehicle"
|
||||
fullWidth
|
||||
disabled
|
||||
value={(() => {
|
||||
const vehicle = vehicles?.find((v: Vehicle) => v.id === schedule.vehicleId);
|
||||
if (!vehicle) return 'Unknown Vehicle';
|
||||
if (vehicle.nickname?.trim()) return vehicle.nickname.trim();
|
||||
const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : 'Vehicle';
|
||||
})()}
|
||||
helperText="Vehicle cannot be changed when editing"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Active Status */}
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isActive ?? true}
|
||||
onChange={(e) => handleInputChange('isActive', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Schedule is active"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ ml: 4 }}>
|
||||
Inactive schedules will not trigger reminders
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{/* Category */}
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Category</InputLabel>
|
||||
<Select
|
||||
value={formData.category || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('category', e.target.value as MaintenanceCategory)
|
||||
}
|
||||
label="Category"
|
||||
>
|
||||
<MenuItem value="routine_maintenance">
|
||||
{getCategoryDisplayName('routine_maintenance')}
|
||||
</MenuItem>
|
||||
<MenuItem value="repair">{getCategoryDisplayName('repair')}</MenuItem>
|
||||
<MenuItem value="performance_upgrade">
|
||||
{getCategoryDisplayName('performance_upgrade')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Subtypes */}
|
||||
{formData.category && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Service Types *
|
||||
</Typography>
|
||||
<SubtypeCheckboxGroup
|
||||
category={formData.category}
|
||||
selected={formData.subtypes || []}
|
||||
onChange={(subtypes) => handleInputChange('subtypes', subtypes)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Schedule Type */}
|
||||
<Grid item xs={12}>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">Schedule Type</FormLabel>
|
||||
<RadioGroup
|
||||
value={currentScheduleType}
|
||||
onChange={(e) => handleScheduleTypeChange(e.target.value as ScheduleType)}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="interval"
|
||||
control={<Radio />}
|
||||
label="Interval-based (months or miles)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="fixed_date"
|
||||
control={<Radio />}
|
||||
label="Fixed due date"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="time_since_last"
|
||||
control={<Radio />}
|
||||
label="Time since last service"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Interval-based fields */}
|
||||
{currentScheduleType === 'interval' && (
|
||||
<>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Interval (months)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.intervalMonths || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'intervalMonths',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Service every X months"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Interval (miles)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.intervalMiles || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'intervalMiles',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Service every X miles"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fixed date field */}
|
||||
{currentScheduleType === 'fixed_date' && (
|
||||
<Grid item xs={12}>
|
||||
<DatePicker
|
||||
label="Due Date"
|
||||
value={formData.fixedDueDate ? dayjs(formData.fixedDueDate) : null}
|
||||
onChange={(newValue) =>
|
||||
handleInputChange('fixedDueDate', newValue?.toISOString().split('T')[0] || undefined)
|
||||
}
|
||||
format="MM/DD/YYYY"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
helperText: 'One-time service due date',
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Time since last fields */}
|
||||
{currentScheduleType === 'time_since_last' && (
|
||||
<>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Interval (months)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.intervalMonths || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'intervalMonths',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Months after last service"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Interval (miles)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.intervalMiles || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'intervalMiles',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Miles after last service"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email Notifications */}
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.emailNotifications ?? false}
|
||||
onChange={(e) => handleInputChange('emailNotifications', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Email notifications"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Reminder Days (only if email notifications enabled) */}
|
||||
{formData.emailNotifications && (
|
||||
<>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Reminder Days Before Due Date
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Reminder 1"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.reminderDays1 || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'reminderDays1',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Days before"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Reminder 2"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.reminderDays2 || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'reminderDays2',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Days before"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Reminder 3"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.reminderDays3 || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'reminderDays3',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Days before"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* @ai-summary Form component for creating maintenance schedules
|
||||
* @ai-context Mobile-first responsive design with proper validation
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Button,
|
||||
Box,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
FormHelperText,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
FormLabel,
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
|
||||
import { EmailNotificationToggle } from '../../notifications/components/EmailNotificationToggle';
|
||||
import {
|
||||
MaintenanceCategory,
|
||||
ScheduleType,
|
||||
CreateScheduleRequest,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
vehicle_id: z.string().uuid({ message: 'Please select a vehicle' }),
|
||||
category: z.enum(['routine_maintenance', 'repair', 'performance_upgrade'], {
|
||||
errorMap: () => ({ message: 'Please select a category' }),
|
||||
}),
|
||||
subtypes: z.array(z.string()).min(1, { message: 'Please select at least one subtype' }),
|
||||
schedule_type: z.enum(['interval', 'fixed_date', 'time_since_last'], {
|
||||
errorMap: () => ({ message: 'Please select a schedule type' }),
|
||||
}),
|
||||
interval_months: z.coerce.number().positive().optional().or(z.literal('')),
|
||||
interval_miles: z.coerce.number().positive().optional().or(z.literal('')),
|
||||
fixed_due_date: z.string().optional(),
|
||||
email_notifications: z.boolean().optional(),
|
||||
reminder_days_1: z.coerce.number().optional().or(z.literal('')),
|
||||
reminder_days_2: z.coerce.number().optional().or(z.literal('')),
|
||||
reminder_days_3: z.coerce.number().optional().or(z.literal('')),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.schedule_type === 'fixed_date') {
|
||||
return !!data.fixed_due_date;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Fixed due date is required for fixed date schedules',
|
||||
path: ['fixed_due_date'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.schedule_type === 'interval' || data.schedule_type === 'time_since_last') {
|
||||
return !!data.interval_months || !!data.interval_miles;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'At least one of interval months or interval miles is required',
|
||||
path: ['interval_months'],
|
||||
}
|
||||
);
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
const REMINDER_OPTIONS = [
|
||||
{ value: '', label: 'None' },
|
||||
{ value: '1', label: '1 day' },
|
||||
{ value: '7', label: '7 days' },
|
||||
{ value: '14', label: '14 days' },
|
||||
{ value: '30', label: '30 days' },
|
||||
{ value: '60', label: '60 days' },
|
||||
];
|
||||
|
||||
export const MaintenanceScheduleForm: React.FC = () => {
|
||||
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
|
||||
const { createSchedule, isScheduleMutating } = useMaintenanceRecords();
|
||||
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
vehicle_id: '',
|
||||
category: undefined as any,
|
||||
subtypes: [],
|
||||
schedule_type: 'interval' as ScheduleType,
|
||||
interval_months: '' as any,
|
||||
interval_miles: '' as any,
|
||||
fixed_due_date: '',
|
||||
email_notifications: false,
|
||||
reminder_days_1: '' as any,
|
||||
reminder_days_2: '' as any,
|
||||
reminder_days_3: '' as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Watch category and schedule type changes
|
||||
const watchedCategory = watch('category');
|
||||
const watchedScheduleType = watch('schedule_type');
|
||||
|
||||
useEffect(() => {
|
||||
if (watchedCategory) {
|
||||
setSelectedCategory(watchedCategory as MaintenanceCategory);
|
||||
setValue('subtypes', []);
|
||||
}
|
||||
}, [watchedCategory, setValue]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload: CreateScheduleRequest = {
|
||||
vehicleId: data.vehicle_id,
|
||||
category: data.category as MaintenanceCategory,
|
||||
subtypes: data.subtypes,
|
||||
scheduleType: data.schedule_type as ScheduleType,
|
||||
intervalMonths: data.interval_months ? Number(data.interval_months) : undefined,
|
||||
intervalMiles: data.interval_miles ? Number(data.interval_miles) : undefined,
|
||||
fixedDueDate: data.fixed_due_date || undefined,
|
||||
emailNotifications: data.email_notifications,
|
||||
reminderDays1: data.reminder_days_1 ? Number(data.reminder_days_1) : undefined,
|
||||
reminderDays2: data.reminder_days_2 ? Number(data.reminder_days_2) : undefined,
|
||||
reminderDays3: data.reminder_days_3 ? Number(data.reminder_days_3) : undefined,
|
||||
};
|
||||
|
||||
await createSchedule(payload);
|
||||
toast.success('Maintenance schedule created successfully');
|
||||
|
||||
// Reset form
|
||||
reset({
|
||||
vehicle_id: '',
|
||||
category: undefined as any,
|
||||
subtypes: [],
|
||||
schedule_type: 'interval' as ScheduleType,
|
||||
interval_months: '' as any,
|
||||
interval_miles: '' as any,
|
||||
fixed_due_date: '',
|
||||
email_notifications: false,
|
||||
reminder_days_1: '' as any,
|
||||
reminder_days_2: '' as any,
|
||||
reminder_days_3: '' as any,
|
||||
});
|
||||
setSelectedCategory(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create maintenance schedule:', error);
|
||||
toast.error('Failed to create maintenance schedule');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingVehicles) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Card>
|
||||
<CardHeader title="Create Maintenance Schedule" />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Vehicle Selection */}
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="vehicle_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!!errors.vehicle_id}>
|
||||
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
labelId="vehicle-select-label"
|
||||
label="Vehicle *"
|
||||
sx={{ minHeight: 56 }}
|
||||
>
|
||||
{vehicles && vehicles.length > 0 ? (
|
||||
vehicles.map((vehicle) => (
|
||||
<MenuItem key={vehicle.id} value={vehicle.id}>
|
||||
{vehicle.year} {vehicle.make} {vehicle.model}
|
||||
</MenuItem>
|
||||
))
|
||||
) : (
|
||||
<MenuItem disabled>No vehicles available</MenuItem>
|
||||
)}
|
||||
</Select>
|
||||
{errors.vehicle_id && (
|
||||
<FormHelperText>{errors.vehicle_id.message}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Category Selection */}
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!!errors.category}>
|
||||
<InputLabel id="category-select-label">Category *</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
labelId="category-select-label"
|
||||
label="Category *"
|
||||
sx={{ minHeight: 56 }}
|
||||
>
|
||||
<MenuItem value="routine_maintenance">
|
||||
{getCategoryDisplayName('routine_maintenance')}
|
||||
</MenuItem>
|
||||
<MenuItem value="repair">{getCategoryDisplayName('repair')}</MenuItem>
|
||||
<MenuItem value="performance_upgrade">
|
||||
{getCategoryDisplayName('performance_upgrade')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
{errors.category && (
|
||||
<FormHelperText>{errors.category.message}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Subtypes */}
|
||||
{selectedCategory && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mb: 1 }}>
|
||||
Subtypes *
|
||||
</Typography>
|
||||
<Controller
|
||||
name="subtypes"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Box>
|
||||
<SubtypeCheckboxGroup
|
||||
category={selectedCategory}
|
||||
selected={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
{errors.subtypes && (
|
||||
<FormHelperText error sx={{ mt: 1 }}>
|
||||
{errors.subtypes.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Schedule Type */}
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="schedule_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl component="fieldset" error={!!errors.schedule_type}>
|
||||
<FormLabel component="legend" sx={{ mb: 1, fontSize: { xs: 14, sm: 16 } }}>
|
||||
Schedule Type *
|
||||
</FormLabel>
|
||||
<RadioGroup {...field} row={false}>
|
||||
<FormControlLabel
|
||||
value="interval"
|
||||
control={<Radio sx={{ '&.MuiRadio-root': { minWidth: 44, minHeight: 44 } }} />}
|
||||
label="Interval-based (every X months/miles)"
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontSize: { xs: 14, sm: 16 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="fixed_date"
|
||||
control={<Radio sx={{ '&.MuiRadio-root': { minWidth: 44, minHeight: 44 } }} />}
|
||||
label="Fixed date"
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontSize: { xs: 14, sm: 16 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="time_since_last"
|
||||
control={<Radio sx={{ '&.MuiRadio-root': { minWidth: 44, minHeight: 44 } }} />}
|
||||
label="Time since last service"
|
||||
sx={{
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontSize: { xs: 14, sm: 16 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{errors.schedule_type && (
|
||||
<FormHelperText>{errors.schedule_type.message}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Conditional fields based on schedule type */}
|
||||
{(watchedScheduleType === 'interval' || watchedScheduleType === 'time_since_last') && (
|
||||
<>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="interval_months"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Interval (Months)"
|
||||
type="number"
|
||||
inputProps={{ step: 1, min: 0 }}
|
||||
fullWidth
|
||||
error={!!errors.interval_months}
|
||||
helperText={errors.interval_months?.message || 'Optional if miles specified'}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="interval_miles"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Interval (Miles)"
|
||||
type="number"
|
||||
inputProps={{ step: 1, min: 0 }}
|
||||
fullWidth
|
||||
error={!!errors.interval_miles}
|
||||
helperText={errors.interval_miles?.message || 'Optional if months specified'}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedScheduleType === 'fixed_date' && (
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="fixed_due_date"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
label="Fixed Due Date *"
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(newValue) =>
|
||||
field.onChange(newValue?.toISOString().split('T')[0] || '')
|
||||
}
|
||||
format="MM/DD/YYYY"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !!errors.fixed_due_date,
|
||||
helperText: errors.fixed_due_date?.message,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Reminder Dropdowns */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mb: 1 }}>
|
||||
Reminders
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Controller
|
||||
name="reminder_days_1"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="reminder1-label">Reminder 1</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
labelId="reminder1-label"
|
||||
label="Reminder 1"
|
||||
sx={{ minHeight: 56 }}
|
||||
>
|
||||
{REMINDER_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Controller
|
||||
name="reminder_days_2"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="reminder2-label">Reminder 2</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
labelId="reminder2-label"
|
||||
label="Reminder 2"
|
||||
sx={{ minHeight: 56 }}
|
||||
>
|
||||
{REMINDER_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Controller
|
||||
name="reminder_days_3"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="reminder3-label">Reminder 3</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
labelId="reminder3-label"
|
||||
label="Reminder 3"
|
||||
sx={{ minHeight: 56 }}
|
||||
>
|
||||
{REMINDER_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Email Notifications Toggle */}
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="email_notifications"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EmailNotificationToggle
|
||||
enabled={field.value || false}
|
||||
onChange={field.onChange}
|
||||
label="Email notifications"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Grid item xs={12}>
|
||||
<Box display="flex" gap={2} justifyContent="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isValid || isScheduleMutating}
|
||||
startIcon={isScheduleMutating ? <CircularProgress size={18} /> : undefined}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
minWidth: { xs: '100%', sm: 200 },
|
||||
}}
|
||||
>
|
||||
Create Schedule
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @ai-summary List component for displaying maintenance schedules
|
||||
* @ai-context Shows schedule status with due/overdue indicators
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
IconButton,
|
||||
Stack,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { Edit, Delete, Notifications } from '@mui/icons-material';
|
||||
import {
|
||||
MaintenanceScheduleResponse,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
|
||||
interface MaintenanceSchedulesListProps {
|
||||
schedules?: MaintenanceScheduleResponse[];
|
||||
onEdit?: (schedule: MaintenanceScheduleResponse) => void;
|
||||
onDelete?: (scheduleId: string) => void;
|
||||
}
|
||||
|
||||
export const MaintenanceSchedulesList: React.FC<MaintenanceSchedulesListProps> = ({
|
||||
schedules,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [scheduleToDelete, setScheduleToDelete] = useState<MaintenanceScheduleResponse | null>(null);
|
||||
|
||||
const handleDeleteClick = (schedule: MaintenanceScheduleResponse) => {
|
||||
setScheduleToDelete(schedule);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (scheduleToDelete && onDelete) {
|
||||
onDelete(scheduleToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setScheduleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setScheduleToDelete(null);
|
||||
};
|
||||
|
||||
const getScheduleTypeDisplay = (schedule: MaintenanceScheduleResponse): string => {
|
||||
if (schedule.scheduleType === 'interval') {
|
||||
const parts: string[] = [];
|
||||
if (schedule.intervalMonths) {
|
||||
parts.push(`Every ${schedule.intervalMonths} month${schedule.intervalMonths > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (schedule.intervalMiles) {
|
||||
parts.push(`${schedule.intervalMiles.toLocaleString()} miles`);
|
||||
}
|
||||
return parts.join(' or ') || 'Interval-based';
|
||||
} else if (schedule.scheduleType === 'fixed_date') {
|
||||
return 'Fixed Date';
|
||||
} else if (schedule.scheduleType === 'time_since_last') {
|
||||
return 'Time Since Last';
|
||||
}
|
||||
return 'Interval-based';
|
||||
};
|
||||
|
||||
const getScheduleStatus = (schedule: MaintenanceScheduleResponse): {
|
||||
label: string;
|
||||
color: 'default' | 'warning' | 'error' | 'success';
|
||||
} => {
|
||||
if (!schedule.isActive) {
|
||||
return { label: 'Inactive', color: 'default' };
|
||||
}
|
||||
if (schedule.isOverdue) {
|
||||
return { label: 'Overdue', color: 'error' };
|
||||
}
|
||||
if (schedule.isDueSoon) {
|
||||
return { label: 'Due Soon', color: 'warning' };
|
||||
}
|
||||
return { label: 'Active', color: 'success' };
|
||||
};
|
||||
|
||||
const getNextDueDisplay = (schedule: MaintenanceScheduleResponse): string | null => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (schedule.nextDueDate) {
|
||||
const date = new Date(schedule.nextDueDate);
|
||||
parts.push(date.toLocaleDateString());
|
||||
}
|
||||
|
||||
if (schedule.nextDueMileage) {
|
||||
parts.push(`${schedule.nextDueMileage.toLocaleString()} miles`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' or ') : null;
|
||||
};
|
||||
|
||||
const getReminderDisplay = (schedule: MaintenanceScheduleResponse): string | null => {
|
||||
if (!schedule.emailNotifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reminderDays = [
|
||||
schedule.reminderDays1,
|
||||
schedule.reminderDays2,
|
||||
schedule.reminderDays3,
|
||||
].filter((day): day is number => day !== null && day !== undefined);
|
||||
|
||||
if (reminderDays.length === 0) {
|
||||
return 'Email notifications enabled';
|
||||
}
|
||||
|
||||
const sortedDays = reminderDays.sort((a, b) => b - a);
|
||||
return `Reminders: ${sortedDays.join(', ')} days before`;
|
||||
};
|
||||
|
||||
if (!schedules || schedules.length === 0) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No maintenance schedules yet.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort schedules: overdue first, then due soon, then active, then inactive
|
||||
const sortedSchedules = [...schedules].sort((a, b) => {
|
||||
// Inactive schedules go last
|
||||
if (!a.isActive && b.isActive) return 1;
|
||||
if (a.isActive && !b.isActive) return -1;
|
||||
if (!a.isActive && !b.isActive) return 0;
|
||||
|
||||
// Both active: overdue first
|
||||
if (a.isOverdue && !b.isOverdue) return -1;
|
||||
if (!a.isOverdue && b.isOverdue) return 1;
|
||||
|
||||
// Both active and same overdue status: due soon next
|
||||
if (a.isDueSoon && !b.isDueSoon) return -1;
|
||||
if (!a.isDueSoon && b.isDueSoon) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={2}>
|
||||
{sortedSchedules.map((schedule) => {
|
||||
const categoryDisplay = getCategoryDisplayName(schedule.category);
|
||||
const subtypeCount = schedule.subtypeCount || schedule.subtypes?.length || 0;
|
||||
const scheduleTypeDisplay = getScheduleTypeDisplay(schedule);
|
||||
const status = getScheduleStatus(schedule);
|
||||
const nextDueDisplay = getNextDueDisplay(schedule);
|
||||
const reminderDisplay = getReminderDisplay(schedule);
|
||||
|
||||
return (
|
||||
<Card key={schedule.id} variant="outlined">
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="h6">
|
||||
{categoryDisplay}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={status.label}
|
||||
color={status.color}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{scheduleTypeDisplay} • {subtypeCount} service type{subtypeCount !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1, gap: 1 }}>
|
||||
{schedule.subtypes && schedule.subtypes.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{schedule.subtypes.slice(0, 3).map((subtype) => (
|
||||
<Chip
|
||||
key={subtype}
|
||||
label={subtype}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
{schedule.subtypes.length > 3 && (
|
||||
<Chip
|
||||
label={`+${schedule.subtypes.length - 3} more`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{nextDueDisplay && (
|
||||
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
|
||||
Next due: {nextDueDisplay}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{reminderDisplay && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
|
||||
<Notifications fontSize="small" color="action" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{reminderDisplay}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: isMobile ? 'center' : 'flex-end',
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() => onEdit(schedule)}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
},
|
||||
...(isMobile && {
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 2,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Edit fontSize={isMobile ? 'medium' : 'small'} />
|
||||
</IconButton>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconButton
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() => handleDeleteClick(schedule)}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
backgroundColor: 'error.main',
|
||||
color: 'white',
|
||||
},
|
||||
...(isMobile && {
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Delete fontSize={isMobile ? 'medium' : 'small'} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel}>
|
||||
<DialogTitle>Delete Maintenance Schedule</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete this maintenance schedule? This action cannot be undone.
|
||||
</Typography>
|
||||
{scheduleToDelete && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -12,10 +12,16 @@ export * from './api/maintenance.api';
|
||||
// Hooks
|
||||
export * from './hooks/useMaintenanceRecords';
|
||||
|
||||
// Components
|
||||
// Components - Records
|
||||
export { SubtypeCheckboxGroup } from './components/SubtypeCheckboxGroup';
|
||||
export { MaintenanceRecordForm } from './components/MaintenanceRecordForm';
|
||||
export { MaintenanceRecordsList } from './components/MaintenanceRecordsList';
|
||||
export { MaintenanceRecordEditDialog } from './components/MaintenanceRecordEditDialog';
|
||||
|
||||
// Components - Schedules
|
||||
export { MaintenanceScheduleForm } from './components/MaintenanceScheduleForm';
|
||||
export { MaintenanceSchedulesList } from './components/MaintenanceSchedulesList';
|
||||
export { MaintenanceScheduleEditDialog } from './components/MaintenanceScheduleEditDialog';
|
||||
|
||||
// Pages
|
||||
export { MaintenancePage } from './pages/MaintenancePage';
|
||||
|
||||
@@ -4,20 +4,26 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Typography, Box } from '@mui/material';
|
||||
import { Grid, Typography, Box, Tabs, Tab } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { MaintenanceRecordForm } from '../components/MaintenanceRecordForm';
|
||||
import { MaintenanceRecordsList } from '../components/MaintenanceRecordsList';
|
||||
import { MaintenanceRecordEditDialog } from '../components/MaintenanceRecordEditDialog';
|
||||
import { MaintenanceScheduleForm } from '../components/MaintenanceScheduleForm';
|
||||
import { MaintenanceSchedulesList } from '../components/MaintenanceSchedulesList';
|
||||
import { MaintenanceScheduleEditDialog } from '../components/MaintenanceScheduleEditDialog';
|
||||
import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
|
||||
import { FormSuspense } from '../../../components/SuspenseWrappers';
|
||||
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest } from '../types/maintenance.types';
|
||||
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types';
|
||||
|
||||
export const MaintenancePage: React.FC = () => {
|
||||
const { records, isRecordsLoading, recordsError, updateRecord, deleteRecord } = useMaintenanceRecords();
|
||||
const { records, schedules, isRecordsLoading, isSchedulesLoading, recordsError, schedulesError, updateRecord, deleteRecord, updateSchedule, deleteSchedule } = useMaintenanceRecords();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<'records' | 'schedules'>('records');
|
||||
const [editingRecord, setEditingRecord] = useState<MaintenanceRecordResponse | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<MaintenanceScheduleResponse | null>(null);
|
||||
const [scheduleEditDialogOpen, setScheduleEditDialogOpen] = useState(false);
|
||||
|
||||
const handleEdit = (record: MaintenanceRecordResponse) => {
|
||||
setEditingRecord(record);
|
||||
@@ -52,7 +58,43 @@ export const MaintenancePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isRecordsLoading) {
|
||||
const handleScheduleEdit = (schedule: MaintenanceScheduleResponse) => {
|
||||
setEditingSchedule(schedule);
|
||||
setScheduleEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleScheduleEditSave = async (id: string, data: UpdateScheduleRequest) => {
|
||||
try {
|
||||
await updateSchedule({ id, data });
|
||||
// Refetch queries after update
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] });
|
||||
setScheduleEditDialogOpen(false);
|
||||
setEditingSchedule(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update maintenance schedule:', error);
|
||||
throw error; // Re-throw to let dialog handle the error
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleEditClose = () => {
|
||||
setScheduleEditDialogOpen(false);
|
||||
setEditingSchedule(null);
|
||||
};
|
||||
|
||||
const handleScheduleDelete = async (scheduleId: string) => {
|
||||
try {
|
||||
await deleteSchedule(scheduleId);
|
||||
// Refetch queries after delete
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete maintenance schedule:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = activeTab === 'records' ? isRecordsLoading : isSchedulesLoading;
|
||||
const hasError = activeTab === 'records' ? recordsError : schedulesError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -62,12 +104,14 @@ export const MaintenancePage: React.FC = () => {
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Loading maintenance records...</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Loading maintenance {activeTab}...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (recordsError) {
|
||||
if (hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -78,7 +122,7 @@ export const MaintenancePage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Typography color="error">
|
||||
Failed to load maintenance records. Please try again.
|
||||
Failed to load maintenance {activeTab}. Please try again.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
@@ -86,6 +130,18 @@ export const MaintenancePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<FormSuspense>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v as 'records' | 'schedules')}
|
||||
aria-label="Maintenance tabs"
|
||||
>
|
||||
<Tab label="Records" value="records" />
|
||||
<Tab label="Schedules" value="schedules" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{activeTab === 'records' && (
|
||||
<Grid container spacing={3}>
|
||||
{/* Top: Form */}
|
||||
<Grid item xs={12}>
|
||||
@@ -104,14 +160,42 @@ export const MaintenancePage: React.FC = () => {
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{activeTab === 'schedules' && (
|
||||
<Grid container spacing={3}>
|
||||
{/* Top: Form */}
|
||||
<Grid item xs={12}>
|
||||
<MaintenanceScheduleForm />
|
||||
</Grid>
|
||||
|
||||
{/* Bottom: Schedules List */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Maintenance Schedules
|
||||
</Typography>
|
||||
<MaintenanceSchedulesList
|
||||
schedules={schedules || []}
|
||||
onEdit={handleScheduleEdit}
|
||||
onDelete={handleScheduleDelete}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Edit Dialogs */}
|
||||
<MaintenanceRecordEditDialog
|
||||
open={editDialogOpen}
|
||||
record={editingRecord}
|
||||
onClose={handleEditClose}
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
<MaintenanceScheduleEditDialog
|
||||
open={scheduleEditDialogOpen}
|
||||
schedule={editingSchedule}
|
||||
onClose={handleScheduleEditClose}
|
||||
onSave={handleScheduleEditSave}
|
||||
/>
|
||||
</FormSuspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// Category types
|
||||
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
|
||||
|
||||
// Schedule types
|
||||
export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last';
|
||||
|
||||
// Subtype definitions (constants for validation)
|
||||
export const ROUTINE_MAINTENANCE_SUBTYPES = [
|
||||
'Accelerator Pedal',
|
||||
@@ -75,14 +78,19 @@ export interface MaintenanceSchedule {
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
scheduleType: ScheduleType;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
fixedDueDate?: string | null;
|
||||
lastServiceDate?: string;
|
||||
lastServiceMileage?: number;
|
||||
nextDueDate?: string;
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -113,18 +121,28 @@ export interface CreateScheduleRequest {
|
||||
vehicleId: string;
|
||||
category: MaintenanceCategory;
|
||||
subtypes: string[];
|
||||
scheduleType?: ScheduleType;
|
||||
intervalMonths?: number;
|
||||
intervalMiles?: number;
|
||||
fixedDueDate?: string;
|
||||
emailNotifications?: boolean;
|
||||
reminderDays1?: number;
|
||||
reminderDays2?: number;
|
||||
reminderDays3?: number;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleRequest {
|
||||
category?: MaintenanceCategory;
|
||||
subtypes?: string[];
|
||||
scheduleType?: ScheduleType;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
fixedDueDate?: string | null;
|
||||
isActive?: boolean;
|
||||
emailNotifications?: boolean;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
}
|
||||
|
||||
// Response types (camelCase)
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { NotificationSummary, DueMaintenanceItem, ExpiringDocument } from '../types/notifications.types';
|
||||
import {
|
||||
NotificationSummary,
|
||||
DueMaintenanceItem,
|
||||
ExpiringDocument,
|
||||
UserNotification,
|
||||
UnreadNotificationCount
|
||||
} from '../types/notifications.types';
|
||||
|
||||
export const notificationsApi = {
|
||||
getSummary: async (): Promise<NotificationSummary> => {
|
||||
@@ -20,4 +26,31 @@ export const notificationsApi = {
|
||||
const response = await apiClient.get('/notifications/documents');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// In-App Notifications
|
||||
getInAppNotifications: async (limit = 20, includeRead = false): Promise<UserNotification[]> => {
|
||||
const response = await apiClient.get('/notifications/in-app', {
|
||||
params: { limit, includeRead: includeRead.toString() }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUnreadCount: async (): Promise<UnreadNotificationCount> => {
|
||||
const response = await apiClient.get('/notifications/in-app/count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsRead: async (id: string): Promise<UserNotification> => {
|
||||
const response = await apiClient.put(`/notifications/in-app/${id}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAsRead: async (): Promise<{ markedAsRead: number }> => {
|
||||
const response = await apiClient.put('/notifications/in-app/read-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteNotification: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/notifications/in-app/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @ai-summary Bell icon with dropdown for in-app notifications
|
||||
* @ai-context Displays in header with unread count badge
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Badge,
|
||||
Popover,
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Button,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { useInAppNotifications } from '../hooks/useInAppNotifications';
|
||||
|
||||
// Helper function for relative time
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export const NotificationBell: React.FC = () => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
} = useInAppNotifications();
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
await markAsRead(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllAsRead();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteNotification(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
}}
|
||||
aria-label="notifications"
|
||||
>
|
||||
<Badge badgeContent={unreadCount} color="error" max={99}>
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100vw', sm: 360 },
|
||||
maxWidth: 360,
|
||||
maxHeight: 480
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>Notifications</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="small" onClick={handleMarkAllAsRead}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : notifications.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">No notifications</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}>
|
||||
{notifications.map((notification) => (
|
||||
<ListItem
|
||||
key={notification.id}
|
||||
sx={{
|
||||
bgcolor: notification.isRead ? 'transparent' : 'action.hover',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||
{!notification.isRead && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
title="Mark as read"
|
||||
sx={{ minWidth: 36, minHeight: 36 }}
|
||||
>
|
||||
<CheckIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="Delete"
|
||||
sx={{ minWidth: 36, minHeight: 36 }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
sx={{ pr: 8 }}
|
||||
primary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: notification.isRead ? 400 : 600,
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{notification.message}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @ai-summary Hook for in-app notifications with real-time count
|
||||
* @ai-context Provides notification list and unread count with polling
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { notificationsApi } from '../api/notifications.api';
|
||||
import type { UserNotification, UnreadNotificationCount } from '../types/notifications.types';
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { isAuthenticated } = useAuth0();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Unread count - polls every 60 seconds
|
||||
const countQuery = useQuery<UnreadNotificationCount>({
|
||||
queryKey: ['notificationCount'],
|
||||
queryFn: notificationsApi.getUnreadCount,
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60 * 1000, // Poll every minute
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Notification list
|
||||
const listQuery = useQuery<UserNotification[]>({
|
||||
queryKey: ['inAppNotifications'],
|
||||
queryFn: () => notificationsApi.getInAppNotifications(20, false),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Mark as read mutation
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: notificationsApi.markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all as read mutation
|
||||
const markAllAsReadMutation = useMutation({
|
||||
mutationFn: notificationsApi.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: notificationsApi.deleteNotification,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications: listQuery.data ?? [],
|
||||
unreadCount: countQuery.data?.total ?? 0,
|
||||
countByType: countQuery.data,
|
||||
isLoading: listQuery.isLoading || countQuery.isLoading,
|
||||
isError: listQuery.isError || countQuery.isError,
|
||||
markAsRead: markAsReadMutation.mutateAsync,
|
||||
markAllAsRead: markAllAsReadMutation.mutateAsync,
|
||||
deleteNotification: deleteMutation.mutateAsync,
|
||||
refetch: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,4 +5,6 @@
|
||||
export * from './api/notifications.api';
|
||||
export * from './types/notifications.types';
|
||||
export * from './hooks/useLoginNotifications';
|
||||
export * from './hooks/useInAppNotifications';
|
||||
export * from './components/EmailNotificationToggle';
|
||||
export * from './components/NotificationBell';
|
||||
|
||||
@@ -34,3 +34,22 @@ export interface ExpiringDocument {
|
||||
isExpired: boolean;
|
||||
emailNotifications: boolean;
|
||||
}
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
notificationType: string;
|
||||
title: string;
|
||||
message: string;
|
||||
referenceType?: string | null;
|
||||
referenceId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
readAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UnreadNotificationCount {
|
||||
total: number;
|
||||
maintenance: number;
|
||||
documents: number;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ export const StationMap: React.FC<StationMapProps> = ({
|
||||
infoWindows.current = [];
|
||||
|
||||
getGoogleMapsApi();
|
||||
let allMarkers: google.maps.marker.AdvancedMarkerElement[] = [];
|
||||
const allMarkers: google.maps.marker.AdvancedMarkerElement[] = [];
|
||||
|
||||
// Add station markers
|
||||
stations.forEach((station) => {
|
||||
|
||||
Reference in New Issue
Block a user