feat: Scheduled Maintenance feature complete
This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user