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