Files
motovaultpro/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx
Eric Gullickson f0fc427ccd
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m21s
Deploy to Staging / Deploy to Staging (push) Successful in 43s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
fix: Date picker bug
2026-03-23 20:03:49 -05:00

429 lines
14 KiB
TypeScript

/**
* @ai-summary Edit dialog for maintenance records with linked receipt display
* @ai-context Mobile-friendly dialog with proper form handling and receipt thumbnail/view
*/
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import ReceiptIcon from '@mui/icons-material/Receipt';
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 {
MaintenanceRecordResponse,
UpdateMaintenanceRecordRequest,
MaintenanceCategory,
getCategoryDisplayName,
} from '../types/maintenance.types';
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { documentsApi } from '../../documents/api/documents.api';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery('(max-width:600px)');
const [receiptThumbnailUrl, setReceiptThumbnailUrl] = useState<string | null>(null);
// Reset form when record changes
useEffect(() => {
if (record && record.id) {
try {
setFormData({
category: record.category,
subtypes: record.subtypes,
date: record.date,
odometerReading: record.odometerReading || undefined,
cost: record.cost ? Number(record.cost) : undefined,
shopName: record.shopName || undefined,
notes: record.notes || undefined,
});
setError(null);
} catch (err) {
console.error('[MaintenanceRecordEditDialog] Error setting form data:', err);
setError(err as Error);
}
}
}, [record]);
// Load receipt thumbnail when record has a linked receipt document
useEffect(() => {
if (!record?.receiptDocument?.documentId) {
setReceiptThumbnailUrl(null);
return;
}
let revoked = false;
documentsApi.download(record.receiptDocument.documentId).then((blob) => {
if (!revoked) {
const url = URL.createObjectURL(blob);
setReceiptThumbnailUrl(url);
}
}).catch((err) => {
console.error('[MaintenanceRecordEditDialog] Failed to load receipt thumbnail:', err);
});
return () => {
revoked = true;
if (receiptThumbnailUrl) {
URL.revokeObjectURL(receiptThumbnailUrl);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.receiptDocument?.documentId]);
const handleViewReceipt = async () => {
if (!record?.receiptDocument?.documentId) return;
try {
const blob = await documentsApi.download(record.receiptDocument.documentId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Revoke after a delay to allow the new tab to load
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (err) {
console.error('[MaintenanceRecordEditDialog] Failed to open receipt:', err);
}
};
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={AdapterDayjs}>
<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.vehicleId);
return getVehicleLabel(vehicle);
})()}
helperText="Vehicle cannot be changed when editing"
/>
</Grid>
{/* Linked Receipt Display */}
{record.receiptDocument && (
<Grid item xs={12}>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'center' : 'center',
gap: 2,
p: 2,
borderRadius: 1,
backgroundColor: 'action.hover',
}}
>
{receiptThumbnailUrl ? (
<Box
component="img"
src={receiptThumbnailUrl}
alt="Receipt"
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
objectFit: 'cover',
flexShrink: 0,
}}
/>
) : (
<Box
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
backgroundColor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<ReceiptIcon color="action" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0, textAlign: isMobile ? 'center' : 'left' }}>
<Typography variant="body2" fontWeight={500}>
Linked Receipt
</Typography>
{record.receiptDocument.fileName && (
<Typography variant="caption" color="text.secondary" noWrap>
{record.receiptDocument.fileName}
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<ReceiptIcon />}
onClick={handleViewReceipt}
sx={{
minHeight: 44,
width: isMobile ? '100%' : 'auto',
flexShrink: 0,
}}
>
View Receipt
</Button>
</Box>
</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 ? dayjs(String(formData.date).substring(0, 10)) : null}
onChange={(newValue) =>
handleInputChange('date', newValue?.format('YYYY-MM-DD') || '')
}
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.odometerReading || ''}
onChange={(e) =>
handleInputChange(
'odometerReading',
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.shopName || ''}
onChange={(e) => handleInputChange('shopName', 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>
);
};