Update
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @ai-summary Edit dialog for maintenance records
|
||||
* @ai-context Mobile-friendly dialog with proper form handling
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import {
|
||||
MaintenanceRecordResponse,
|
||||
UpdateMaintenanceRecordRequest,
|
||||
MaintenanceCategory,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface MaintenanceRecordEditDialogProps {
|
||||
open: boolean;
|
||||
record: MaintenanceRecordResponse | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, data: UpdateMaintenanceRecordRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogProps> = ({
|
||||
open,
|
||||
record,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<UpdateMaintenanceRecordRequest>({});
|
||||
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 record changes
|
||||
useEffect(() => {
|
||||
if (record && record.id) {
|
||||
try {
|
||||
setFormData({
|
||||
category: record.category,
|
||||
subtypes: record.subtypes,
|
||||
date: record.date,
|
||||
odometer_reading: record.odometer_reading || undefined,
|
||||
cost: record.cost ? Number(record.cost) : undefined,
|
||||
shop_name: record.shop_name || undefined,
|
||||
notes: record.notes || undefined,
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[MaintenanceRecordEditDialog] Error setting form data:', err);
|
||||
setError(err as Error);
|
||||
}
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
const handleInputChange = (field: keyof UpdateMaintenanceRecordRequest, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!record || !record.id) {
|
||||
console.error('[MaintenanceRecordEditDialog] No valid record to save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Filter out unchanged fields
|
||||
const changedData: UpdateMaintenanceRecordRequest = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const typedKey = key as keyof UpdateMaintenanceRecordRequest;
|
||||
const recordValue = record[typedKey as keyof MaintenanceRecordResponse];
|
||||
|
||||
// Special handling for arrays
|
||||
if (Array.isArray(value) && Array.isArray(recordValue)) {
|
||||
if (JSON.stringify(value) !== JSON.stringify(recordValue)) {
|
||||
(changedData as any)[key] = value;
|
||||
}
|
||||
} else if (value !== recordValue) {
|
||||
(changedData as any)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Only send update if there are actual changes
|
||||
if (Object.keys(changedData).length > 0) {
|
||||
await onSave(record.id, changedData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('[MaintenanceRecordEditDialog] Failed to save record:', err);
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Early bailout if dialog not open or no record to edit
|
||||
if (!open || !record) return null;
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Error Loading Maintenance Record</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="error">
|
||||
Failed to load maintenance record 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' },
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Maintenance Record</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 === record.vehicle_id);
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<DatePicker
|
||||
label="Service Date *"
|
||||
value={formData.date ? new Date(formData.date) : null}
|
||||
onChange={(newValue) =>
|
||||
handleInputChange('date', newValue?.toISOString().split('T')[0] || '')
|
||||
}
|
||||
format="MM/dd/yyyy"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Odometer Reading */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Odometer Reading"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.odometer_reading || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'odometer_reading',
|
||||
e.target.value ? parseInt(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
helperText="Current mileage"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Cost */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Cost"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.cost || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('cost', e.target.value ? parseFloat(e.target.value) : undefined)
|
||||
}
|
||||
helperText="Total service cost"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Shop Name */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Shop/Location"
|
||||
fullWidth
|
||||
value={formData.shop_name || ''}
|
||||
onChange={(e) => handleInputChange('shop_name', e.target.value || undefined)}
|
||||
helperText="Service location"
|
||||
inputProps={{ maxLength: 200 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Notes */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value || undefined)}
|
||||
placeholder="Optional notes about this service..."
|
||||
inputProps={{ maxLength: 10000 }}
|
||||
/>
|
||||
</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,378 @@
|
||||
/**
|
||||
* @ai-summary Form component for creating maintenance records
|
||||
* @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,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
|
||||
import {
|
||||
MaintenanceCategory,
|
||||
CreateMaintenanceRecordRequest,
|
||||
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' }),
|
||||
date: z.string().min(1, { message: 'Date is required' }),
|
||||
odometer_reading: z.coerce.number().positive().optional().or(z.literal('')),
|
||||
cost: z.coerce.number().positive().optional().or(z.literal('')),
|
||||
shop_name: z.string().max(200).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const MaintenanceRecordForm: React.FC = () => {
|
||||
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
|
||||
const { createRecord, isRecordMutating } = 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: [],
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
odometer_reading: '' as any,
|
||||
cost: '' as any,
|
||||
shop_name: '',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch category changes to reset subtypes
|
||||
const watchedCategory = watch('category');
|
||||
useEffect(() => {
|
||||
if (watchedCategory) {
|
||||
setSelectedCategory(watchedCategory as MaintenanceCategory);
|
||||
setValue('subtypes', []);
|
||||
}
|
||||
}, [watchedCategory, setValue]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload: CreateMaintenanceRecordRequest = {
|
||||
vehicle_id: data.vehicle_id,
|
||||
category: data.category as MaintenanceCategory,
|
||||
subtypes: data.subtypes,
|
||||
date: data.date,
|
||||
odometer_reading: data.odometer_reading ? Number(data.odometer_reading) : undefined,
|
||||
cost: data.cost ? Number(data.cost) : undefined,
|
||||
shop_name: data.shop_name || undefined,
|
||||
notes: data.notes || undefined,
|
||||
};
|
||||
|
||||
await createRecord(payload);
|
||||
toast.success('Maintenance record added successfully');
|
||||
|
||||
// Reset form
|
||||
reset({
|
||||
vehicle_id: '',
|
||||
category: undefined as any,
|
||||
subtypes: [],
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
odometer_reading: '' as any,
|
||||
cost: '' as any,
|
||||
shop_name: '',
|
||||
notes: '',
|
||||
});
|
||||
setSelectedCategory(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create maintenance record:', error);
|
||||
toast.error('Failed to add maintenance record');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingVehicles) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Card>
|
||||
<CardHeader title="Add Maintenance Record" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
label="Date *"
|
||||
value={field.value ? new Date(field.value) : null}
|
||||
onChange={(newValue) =>
|
||||
field.onChange(newValue?.toISOString().split('T')[0] || '')
|
||||
}
|
||||
format="MM/dd/yyyy"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !!errors.date,
|
||||
helperText: errors.date?.message,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Odometer Reading */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="odometer_reading"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Odometer Reading"
|
||||
type="number"
|
||||
inputProps={{ step: 1, min: 0 }}
|
||||
fullWidth
|
||||
error={!!errors.odometer_reading}
|
||||
helperText={errors.odometer_reading?.message}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Cost */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="cost"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Cost"
|
||||
type="number"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||
}}
|
||||
fullWidth
|
||||
error={!!errors.cost}
|
||||
helperText={errors.cost?.message}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Shop Name */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="shop_name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Shop Name"
|
||||
fullWidth
|
||||
error={!!errors.shop_name}
|
||||
helperText={errors.shop_name?.message}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: 56,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Notes */}
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Notes"
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
error={!!errors.notes}
|
||||
helperText={errors.notes?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Grid item xs={12}>
|
||||
<Box display="flex" gap={2} justifyContent="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isValid || isRecordMutating}
|
||||
startIcon={isRecordMutating ? <CircularProgress size={18} /> : undefined}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
minWidth: { xs: '100%', sm: 200 },
|
||||
}}
|
||||
>
|
||||
Add Record
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @ai-summary List component for displaying maintenance records
|
||||
* @ai-context Mobile-friendly card layout with proper touch targets
|
||||
*/
|
||||
|
||||
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 } from '@mui/icons-material';
|
||||
import {
|
||||
MaintenanceRecordResponse,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
|
||||
interface MaintenanceRecordsListProps {
|
||||
records?: MaintenanceRecordResponse[];
|
||||
onEdit?: (record: MaintenanceRecordResponse) => void;
|
||||
onDelete?: (recordId: string) => void;
|
||||
}
|
||||
|
||||
export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
records,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [recordToDelete, setRecordToDelete] = useState<MaintenanceRecordResponse | null>(null);
|
||||
|
||||
const handleDeleteClick = (record: MaintenanceRecordResponse) => {
|
||||
setRecordToDelete(record);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (recordToDelete && onDelete) {
|
||||
onDelete(recordToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setRecordToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setRecordToDelete(null);
|
||||
};
|
||||
|
||||
if (!records || records.length === 0) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No maintenance records yet.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort records by date DESC (newest first)
|
||||
const sortedRecords = [...records].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={2}>
|
||||
{sortedRecords.map((record) => {
|
||||
const dateText = new Date(record.date).toLocaleDateString();
|
||||
const categoryDisplay = getCategoryDisplayName(record.category);
|
||||
const subtypeCount = record.subtype_count || record.subtypes?.length || 0;
|
||||
|
||||
return (
|
||||
<Card key={record.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 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{dateText}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
{categoryDisplay} ({subtypeCount})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
{record.odometer_reading && (
|
||||
<Chip
|
||||
label={`${Number(record.odometer_reading).toLocaleString()} miles`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.cost && (
|
||||
<Chip
|
||||
label={`$${Number(record.cost).toFixed(2)}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.shop_name && (
|
||||
<Chip
|
||||
label={record.shop_name}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{record.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{record.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
justifyContent: isMobile ? 'center' : 'flex-end',
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() => onEdit(record)}
|
||||
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(record)}
|
||||
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 Record</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete this maintenance record? This action cannot be undone.
|
||||
</Typography>
|
||||
{recordToDelete && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{new Date(recordToDelete.date).toLocaleDateString()} -{' '}
|
||||
{getCategoryDisplayName(recordToDelete.category)}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @ai-summary Reusable checkbox group for maintenance subtype selection
|
||||
* @ai-context Responsive grid layout with proper mobile touch targets
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FormGroup, FormControlLabel, Checkbox, Box } from '@mui/material';
|
||||
import { MaintenanceCategory, getSubtypesForCategory } from '../types/maintenance.types';
|
||||
|
||||
interface SubtypeCheckboxGroupProps {
|
||||
category: MaintenanceCategory;
|
||||
selected: string[];
|
||||
onChange: (subtypes: string[]) => void;
|
||||
}
|
||||
|
||||
export const SubtypeCheckboxGroup: React.FC<SubtypeCheckboxGroupProps> = ({
|
||||
category,
|
||||
selected,
|
||||
onChange,
|
||||
}) => {
|
||||
const availableSubtypes = getSubtypesForCategory(category);
|
||||
|
||||
const handleToggle = (subtype: string) => {
|
||||
const newSelected = selected.includes(subtype)
|
||||
? selected.filter((s) => s !== subtype)
|
||||
: [...selected, subtype];
|
||||
onChange(newSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
},
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<FormGroup>
|
||||
{availableSubtypes.map((subtype) => (
|
||||
<FormControlLabel
|
||||
key={subtype}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selected.includes(subtype)}
|
||||
onChange={() => handleToggle(subtype)}
|
||||
sx={{
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 24,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={subtype}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
'& .MuiFormControlLabel-label': {
|
||||
fontSize: { xs: 14, sm: 16 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user