feat: Scheduled Maintenance feature complete

This commit is contained in:
Eric Gullickson
2025-12-22 14:12:33 -06:00
parent c017b8816f
commit 91b4534e76
44 changed files with 2740 additions and 117 deletions

View File

@@ -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,7 +68,10 @@ 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="text-xs text-slate-500">v1.0</div>
<div className="flex items-center gap-2">
<NotificationBell />
<div className="text-xs text-slate-500">v1.0</div>
</div>
</div>
</div>
{/* Content area */}
@@ -231,9 +235,12 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
>
<MenuIcon />
</IconButton>
<Typography variant="body2" color="text.secondary">
Welcome back, {user?.name || user?.email}
</Typography>
<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>

View File

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

View File

@@ -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(() => {

View File

@@ -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',
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,32 +130,72 @@ export const MaintenancePage: React.FC = () => {
return (
<FormSuspense>
<Grid container spacing={3}>
{/* Top: Form */}
<Grid item xs={12}>
<MaintenanceRecordForm />
</Grid>
<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>
{/* Bottom: Records List */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Recent Maintenance Records
</Typography>
<MaintenanceRecordsList
records={records}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Grid>
</Grid>
{activeTab === 'records' && (
<Grid container spacing={3}>
{/* Top: Form */}
<Grid item xs={12}>
<MaintenanceRecordForm />
</Grid>
{/* Edit Dialog */}
{/* Bottom: Records List */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Recent Maintenance Records
</Typography>
<MaintenanceRecordsList
records={records}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Grid>
</Grid>
)}
{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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {