This commit is contained in:
Eric Gullickson
2025-10-16 19:20:30 -05:00
parent 225520ad30
commit 5638d3960b
68 changed files with 4164 additions and 18995 deletions

View File

@@ -25,6 +25,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ defa
const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage })));
const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage })));
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage })));
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
@@ -551,7 +552,7 @@ function App() {
<Route path="/fuel-logs" element={<FuelLogsPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />

View File

@@ -25,17 +25,16 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user, logout } = useAuth0();
const { sidebarOpen, toggleSidebar } = useAppStore();
const { setSidebarOpen } = useAppStore.getState();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
const location = useLocation();
const theme = useTheme();
// Ensure desktop has a visible navigation by default
// Ensure desktop has a visible navigation by default (only on mount)
React.useEffect(() => {
if (!mobileMode && !sidebarOpen) {
setSidebarOpen(true);
}
}, [mobileMode, sidebarOpen]);
}, [mobileMode, setSidebarOpen]); // Removed sidebarOpen from dependencies
const navigation = [
{ name: 'Vehicles', href: '/vehicles', icon: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },

View File

@@ -0,0 +1,82 @@
/**
* @ai-summary API client for maintenance records and schedules
* @ai-context Follows pattern from documents.api.ts with full CRUD operations
*/
import { apiClient } from '../../../core/api/client';
import type {
CreateMaintenanceRecordRequest,
UpdateMaintenanceRecordRequest,
MaintenanceRecordResponse,
CreateScheduleRequest,
UpdateScheduleRequest,
MaintenanceScheduleResponse,
MaintenanceCategory
} from '../types/maintenance.types';
export const maintenanceApi = {
// Maintenance Records
async createRecord(data: CreateMaintenanceRecordRequest): Promise<MaintenanceRecordResponse> {
const res = await apiClient.post<MaintenanceRecordResponse>('/maintenance/records', data);
return res.data;
},
async getRecords(): Promise<MaintenanceRecordResponse[]> {
const res = await apiClient.get<MaintenanceRecordResponse[]>('/maintenance/records');
return res.data;
},
async getRecord(id: string): Promise<MaintenanceRecordResponse> {
const res = await apiClient.get<MaintenanceRecordResponse>(`/maintenance/records/${id}`);
return res.data;
},
async updateRecord(id: string, data: UpdateMaintenanceRecordRequest): Promise<MaintenanceRecordResponse> {
const res = await apiClient.put<MaintenanceRecordResponse>(`/maintenance/records/${id}`, data);
return res.data;
},
async deleteRecord(id: string): Promise<void> {
await apiClient.delete(`/maintenance/records/${id}`);
},
async getRecordsByVehicle(vehicleId: string): Promise<MaintenanceRecordResponse[]> {
const res = await apiClient.get<MaintenanceRecordResponse[]>(`/maintenance/records/vehicle/${vehicleId}`);
return res.data;
},
// Maintenance Schedules
async createSchedule(data: CreateScheduleRequest): Promise<MaintenanceScheduleResponse> {
const res = await apiClient.post<MaintenanceScheduleResponse>('/maintenance/schedules', data);
return res.data;
},
async getSchedulesByVehicle(vehicleId: string): Promise<MaintenanceScheduleResponse[]> {
const res = await apiClient.get<MaintenanceScheduleResponse[]>(`/maintenance/schedules/vehicle/${vehicleId}`);
return res.data;
},
async updateSchedule(id: string, data: UpdateScheduleRequest): Promise<MaintenanceScheduleResponse> {
const res = await apiClient.put<MaintenanceScheduleResponse>(`/maintenance/schedules/${id}`, data);
return res.data;
},
async deleteSchedule(id: string): Promise<void> {
await apiClient.delete(`/maintenance/schedules/${id}`);
},
async getUpcoming(vehicleId: string, currentMileage?: number): Promise<MaintenanceScheduleResponse[]> {
const params = currentMileage ? { current_mileage: currentMileage } : {};
const res = await apiClient.get<MaintenanceScheduleResponse[]>(
`/maintenance/schedules/vehicle/${vehicleId}/upcoming`,
{ params }
);
return res.data;
},
// Utility endpoints
async getSubtypes(category: MaintenanceCategory): Promise<string[]> {
const res = await apiClient.get<{ subtypes: string[] }>(`/maintenance/subtypes/${category}`);
return res.data.subtypes;
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
/**
* @ai-summary React Query hook for maintenance records
* @ai-context Provides queries and mutations with proper cache invalidation
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { maintenanceApi } from '../api/maintenance.api';
import type {
CreateMaintenanceRecordRequest,
UpdateMaintenanceRecordRequest,
MaintenanceRecordResponse,
CreateScheduleRequest,
UpdateScheduleRequest,
MaintenanceScheduleResponse
} from '../types/maintenance.types';
export const useMaintenanceRecords = (vehicleId?: string) => {
const { isAuthenticated, isLoading } = useAuth0();
const queryClient = useQueryClient();
// Query for maintenance records
const recordsQuery = useQuery<MaintenanceRecordResponse[]>({
queryKey: ['maintenanceRecords', vehicleId || 'all'],
queryFn: () => (vehicleId ? maintenanceApi.getRecordsByVehicle(vehicleId) : maintenanceApi.getRecords()),
enabled: isAuthenticated && !isLoading,
staleTime: 2 * 60 * 1000, // 2 minutes
gcTime: 5 * 60 * 1000, // 5 minutes cache time
retry: (failureCount, error: any) => {
// Retry 401 errors up to 3 times for mobile auth timing issues
if (error?.response?.status === 401 && failureCount < 3) {
console.log(`[Mobile Auth] Maintenance records API retry ${failureCount + 1}/3 for 401 error`);
return true;
}
return false;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
refetchOnMount: false,
});
// Query for maintenance schedules
const schedulesQuery = useQuery<MaintenanceScheduleResponse[]>({
queryKey: ['maintenanceSchedules', vehicleId],
queryFn: () => maintenanceApi.getSchedulesByVehicle(vehicleId!),
enabled: !!vehicleId && isAuthenticated && !isLoading,
staleTime: 5 * 60 * 1000, // 5 minutes - schedules change less frequently
gcTime: 10 * 60 * 1000, // 10 minutes cache time
retry: (failureCount, error: any) => {
if (error?.response?.status === 401 && failureCount < 3) {
console.log(`[Mobile Auth] Maintenance schedules API retry ${failureCount + 1}/3 for 401 error`);
return true;
}
return false;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
refetchOnMount: false,
});
// Query for upcoming maintenance
const upcomingQuery = useQuery<MaintenanceScheduleResponse[]>({
queryKey: ['maintenanceUpcoming', vehicleId],
queryFn: () => maintenanceApi.getUpcoming(vehicleId!),
enabled: !!vehicleId && isAuthenticated && !isLoading,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: (failureCount, error: any) => {
if (error?.response?.status === 401 && failureCount < 3) {
console.log(`[Mobile Auth] Maintenance upcoming API retry ${failureCount + 1}/3 for 401 error`);
return true;
}
return false;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
refetchOnMount: false,
});
// Mutations for records
const createRecordMutation = useMutation({
mutationFn: (data: CreateMaintenanceRecordRequest) => maintenanceApi.createRecord(data),
onSuccess: (_res, variables) => {
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', variables.vehicle_id] });
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', 'all'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
},
});
const updateRecordMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateMaintenanceRecordRequest }) =>
maintenanceApi.updateRecord(id, data),
onSuccess: () => {
// Invalidate all record queries since we don't know the vehicle_id from the response
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming'] });
},
});
const deleteRecordMutation = useMutation({
mutationFn: (id: string) => maintenanceApi.deleteRecord(id),
onSuccess: () => {
// Invalidate all record queries
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming'] });
},
});
// Mutations for schedules
const createScheduleMutation = useMutation({
mutationFn: (data: CreateScheduleRequest) => maintenanceApi.createSchedule(data),
onSuccess: (_res, variables) => {
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicle_id] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicle_id] });
},
});
const updateScheduleMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateScheduleRequest }) =>
maintenanceApi.updateSchedule(id, data),
onSuccess: () => {
// Invalidate all schedule queries
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming'] });
},
});
const deleteScheduleMutation = useMutation({
mutationFn: (id: string) => maintenanceApi.deleteSchedule(id),
onSuccess: () => {
// Invalidate all schedule queries
queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming'] });
},
});
return {
// Records
records: recordsQuery.data,
isRecordsLoading: recordsQuery.isLoading,
recordsError: recordsQuery.error,
createRecord: createRecordMutation.mutateAsync,
updateRecord: updateRecordMutation.mutateAsync,
deleteRecord: deleteRecordMutation.mutateAsync,
isRecordMutating: createRecordMutation.isPending || updateRecordMutation.isPending || deleteRecordMutation.isPending,
// Schedules
schedules: schedulesQuery.data,
isSchedulesLoading: schedulesQuery.isLoading,
schedulesError: schedulesQuery.error,
createSchedule: createScheduleMutation.mutateAsync,
updateSchedule: updateScheduleMutation.mutateAsync,
deleteSchedule: deleteScheduleMutation.mutateAsync,
isScheduleMutating: createScheduleMutation.isPending || updateScheduleMutation.isPending || deleteScheduleMutation.isPending,
// Upcoming
upcoming: upcomingQuery.data,
isUpcomingLoading: upcomingQuery.isLoading,
upcomingError: upcomingQuery.error,
};
};

View File

@@ -0,0 +1,21 @@
/**
* @ai-summary Maintenance feature exports
* @ai-context Central export point for maintenance types, API, hooks, and components
*/
// Types
export * from './types/maintenance.types';
// API
export * from './api/maintenance.api';
// Hooks
export * from './hooks/useMaintenanceRecords';
// Components
export { SubtypeCheckboxGroup } from './components/SubtypeCheckboxGroup';
export { MaintenanceRecordForm } from './components/MaintenanceRecordForm';
export { MaintenanceRecordsList } from './components/MaintenanceRecordsList';
// Pages
export { MaintenancePage } from './pages/MaintenancePage';

View File

@@ -0,0 +1,117 @@
/**
* @ai-summary Main page for maintenance feature
* @ai-context Two-column responsive layout following fuel-logs pattern
*/
import React, { useState } from 'react';
import { Grid, Typography, Box } 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 { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
import { FormSuspense } from '../../../components/SuspenseWrappers';
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest } from '../types/maintenance.types';
export const MaintenancePage: React.FC = () => {
const { records, isRecordsLoading, recordsError, updateRecord, deleteRecord } = useMaintenanceRecords();
const queryClient = useQueryClient();
const [editingRecord, setEditingRecord] = useState<MaintenanceRecordResponse | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const handleEdit = (record: MaintenanceRecordResponse) => {
setEditingRecord(record);
setEditDialogOpen(true);
};
const handleEditSave = async (id: string, data: UpdateMaintenanceRecordRequest) => {
try {
await updateRecord({ id, data });
// Refetch queries after update
queryClient.refetchQueries({ queryKey: ['maintenanceRecords'] });
setEditDialogOpen(false);
setEditingRecord(null);
} catch (error) {
console.error('Failed to update maintenance record:', error);
throw error; // Re-throw to let dialog handle the error
}
};
const handleEditClose = () => {
setEditDialogOpen(false);
setEditingRecord(null);
};
const handleDelete = async (recordId: string) => {
try {
await deleteRecord(recordId);
// Refetch queries after delete
queryClient.refetchQueries({ queryKey: ['maintenanceRecords', 'all'] });
} catch (error) {
console.error('Failed to delete maintenance record:', error);
}
};
if (isRecordsLoading) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50vh',
}}
>
<Typography color="text.secondary">Loading maintenance records...</Typography>
</Box>
);
}
if (recordsError) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50vh',
}}
>
<Typography color="error">
Failed to load maintenance records. Please try again.
</Typography>
</Box>
);
}
return (
<FormSuspense>
<Grid container spacing={2}>
{/* Left Column: Form */}
<Grid item xs={12} md={6}>
<MaintenanceRecordForm />
</Grid>
{/* Right Column: Records List */}
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>
Recent Maintenance Records
</Typography>
<MaintenanceRecordsList
records={records}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</Grid>
</Grid>
{/* Edit Dialog */}
<MaintenanceRecordEditDialog
open={editDialogOpen}
record={editingRecord}
onClose={handleEditClose}
onSave={handleEditSave}
/>
</FormSuspense>
);
};

View File

@@ -0,0 +1,159 @@
/**
* @ai-summary Type definitions for maintenance feature
* @ai-context Supports three categories with specific subtypes, multiple selections allowed
*/
// Category types
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
// Subtype definitions (constants for validation)
export const ROUTINE_MAINTENANCE_SUBTYPES = [
'Accelerator Pedal',
'Air Filter Element',
'Brakes and Traction Control',
'Cabin Air Filter / Purifier',
'Coolant',
'Doors',
'Drive Belt',
'Engine Oil',
'Evaporative Emissions System',
'Exhaust System',
'Fluid - A/T',
'Fluid - Differential',
'Fluid - M/T',
'Fluid Filter - A/T',
'Fluids',
'Fuel Delivery and Air Induction',
'Hood Shock / Support',
'Neutral Safety Switch',
'Parking Brake System',
'Restraints and Safety Systems',
'Shift Interlock, A/T',
'Spark Plug',
'Steering and Suspension',
'Tires',
'Trunk / Liftgate Shock / Support',
'Washer Fluid',
'Wiper Blade'
] as const;
export const REPAIR_SUBTYPES = [
'Engine',
'Transmission',
'Drivetrain',
'Exterior',
'Interior'
] as const;
export const PERFORMANCE_UPGRADE_SUBTYPES = [
'Engine',
'Drivetrain',
'Suspension',
'Wheels/Tires',
'Exterior'
] as const;
// Database record types
export interface MaintenanceRecord {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface MaintenanceSchedule {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number;
interval_miles?: number;
last_service_date?: string;
last_service_mileage?: number;
next_due_date?: string;
next_due_mileage?: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// Request types
export interface CreateMaintenanceRecordRequest {
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
}
export interface UpdateMaintenanceRecordRequest {
category?: MaintenanceCategory;
subtypes?: string[];
date?: string;
odometer_reading?: number | null;
cost?: number | null;
shop_name?: string | null;
notes?: string | null;
}
export interface CreateScheduleRequest {
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number;
interval_miles?: number;
}
export interface UpdateScheduleRequest {
category?: MaintenanceCategory;
subtypes?: string[];
interval_months?: number | null;
interval_miles?: number | null;
is_active?: boolean;
}
// Response types
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtype_count: number;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
subtype_count: number;
is_due_soon?: boolean;
is_overdue?: boolean;
}
// Validation helpers
export function getSubtypesForCategory(category: MaintenanceCategory): readonly string[] {
switch (category) {
case 'routine_maintenance': return ROUTINE_MAINTENANCE_SUBTYPES;
case 'repair': return REPAIR_SUBTYPES;
case 'performance_upgrade': return PERFORMANCE_UPGRADE_SUBTYPES;
}
}
export function validateSubtypes(category: MaintenanceCategory, subtypes: string[]): boolean {
if (!subtypes || subtypes.length === 0) return false;
const validSubtypes = getSubtypesForCategory(category);
return subtypes.every(st => validSubtypes.includes(st as any));
}
export function getCategoryDisplayName(category: MaintenanceCategory): string {
switch (category) {
case 'routine_maintenance': return 'Routine Maintenance';
case 'repair': return 'Repair';
case 'performance_upgrade': return 'Performance Upgrade';
}
}