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
429 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|