feat: add form integration, tier gating, and receipt display (refs #153)

- Add tier-gated "Scan Receipt" button to MaintenanceRecordForm
- Wire useMaintenanceReceiptOcr hook with CameraCapture and ReviewModal
- Auto-populate form fields from accepted OCR results via setValue
- Upload receipt as document and pass receiptDocumentId on record create
- Show receipt thumbnail + "View Receipt" button in edit dialog
- Add receipt indicator chip on records list rows
- Add receiptDocumentId and receiptDocument to frontend types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-12 21:40:27 -06:00
parent 91166b021c
commit 06ff8101dc
4 changed files with 343 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Edit dialog for maintenance records
* @ai-context Mobile-friendly dialog with proper form handling
* @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';
@@ -19,7 +19,9 @@ import {
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';
@@ -32,6 +34,7 @@ import {
} 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';
interface MaintenanceRecordEditDialogProps {
@@ -53,7 +56,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
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(() => {
@@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
}
}, [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,
@@ -182,6 +227,76 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
/>
</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>