feat: Maintenance Receipt Upload with OCR Auto-populate (#16) #161

Merged
egullickson merged 11 commits from issue-16-maintenance-receipt-upload-ocr into main 2026-02-13 22:19:45 +00:00
4 changed files with 343 additions and 5 deletions
Showing only changes of commit 06ff8101dc - Show all commits

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>

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Form component for creating maintenance records
* @ai-context Mobile-first responsive design with proper validation
* @ai-summary Form component for creating maintenance records with receipt OCR integration
* @ai-context Mobile-first responsive design with tier-gated receipt scanning, mirrors FuelLogForm OCR pattern
*/
import React, { useState, useEffect } from 'react';
@@ -23,6 +23,8 @@ import {
CircularProgress,
Typography,
InputAdornment,
Dialog,
Backdrop,
} from '@mui/material';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
@@ -36,6 +38,13 @@ import {
CreateMaintenanceRecordRequest,
getCategoryDisplayName,
} from '../types/maintenance.types';
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton';
import { CameraCapture } from '../../../shared/components/CameraCapture';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
import { documentsApi } from '../../documents/api/documents.api';
import toast from 'react-hot-toast';
const schema = z.object({
@@ -58,6 +67,29 @@ export const MaintenanceRecordForm: React.FC = () => {
const { createRecord, isRecordMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
// Tier access check for receipt scan feature
const { hasAccess } = useTierAccess();
const hasReceiptScanAccess = hasAccess('maintenance.receiptScan');
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
// Receipt OCR integration
const {
isCapturing,
isProcessing,
result: ocrResult,
receiptImageUrl,
error: ocrError,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset: resetOcr,
updateField,
} = useMaintenanceReceiptOcr();
// Store captured file for document upload on submit
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
const {
control,
handleSubmit,
@@ -89,8 +121,69 @@ export const MaintenanceRecordForm: React.FC = () => {
}
}, [watchedCategory, setValue]);
// Wrap processImage to also save file reference
const handleCaptureImage = async (file: File, croppedFile?: File) => {
setCapturedReceiptFile(croppedFile || file);
await processImage(file, croppedFile);
};
// Handle accepting OCR results and populating the form
const handleAcceptOcrResult = () => {
const mappedFields = acceptResult();
if (!mappedFields) return;
// Populate form fields from OCR result
if (mappedFields.category) {
setValue('category', mappedFields.category);
setSelectedCategory(mappedFields.category);
}
if (mappedFields.subtypes && mappedFields.subtypes.length > 0) {
setValue('subtypes', mappedFields.subtypes);
}
if (mappedFields.date) {
setValue('date', mappedFields.date);
}
if (mappedFields.cost !== undefined) {
setValue('cost', mappedFields.cost as any);
}
if (mappedFields.shopName) {
setValue('shop_name', mappedFields.shopName);
}
if (mappedFields.odometerReading !== undefined) {
setValue('odometer_reading', mappedFields.odometerReading as any);
}
if (mappedFields.notes) {
setValue('notes', mappedFields.notes);
}
};
// Handle retaking photo
const handleRetakePhoto = () => {
resetOcr();
setCapturedReceiptFile(null);
startCapture();
};
const onSubmit = async (data: FormData) => {
try {
let receiptDocumentId: string | undefined;
// Upload receipt as document if we have a captured file
if (capturedReceiptFile) {
try {
const doc = await documentsApi.create({
vehicleId: data.vehicle_id,
documentType: 'manual',
title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`,
});
await documentsApi.upload(doc.id, capturedReceiptFile);
receiptDocumentId = doc.id;
} catch (uploadError) {
console.error('Failed to upload receipt document:', uploadError);
toast.error('Receipt upload failed, but the record will be saved without the receipt.');
}
}
const payload: CreateMaintenanceRecordRequest = {
vehicleId: data.vehicle_id,
category: data.category as MaintenanceCategory,
@@ -100,6 +193,7 @@ export const MaintenanceRecordForm: React.FC = () => {
cost: data.cost ? Number(data.cost) : undefined,
shopName: data.shop_name || undefined,
notes: data.notes || undefined,
receiptDocumentId,
};
await createRecord(payload);
@@ -117,6 +211,7 @@ export const MaintenanceRecordForm: React.FC = () => {
notes: '',
});
setSelectedCategory(null);
setCapturedReceiptFile(null);
} catch (error) {
console.error('Failed to create maintenance record:', error);
toast.error('Failed to add maintenance record');
@@ -140,6 +235,31 @@ export const MaintenanceRecordForm: React.FC = () => {
<Card>
<CardHeader title="Add Maintenance Record" />
<CardContent>
{/* Receipt Scan Button */}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3,
pb: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReceiptCameraButton
onClick={() => {
if (!hasReceiptScanAccess) {
setShowUpgradeDialog(true);
return;
}
startCapture();
}}
disabled={isProcessing || isRecordMutating}
variant="button"
locked={!hasReceiptScanAccess}
/>
</Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Vehicle Selection */}
@@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
</form>
</CardContent>
</Card>
{/* Camera Capture Modal */}
<Dialog
open={isCapturing}
onClose={cancelCapture}
fullScreen
PaperProps={{
sx: { backgroundColor: 'black' },
}}
>
<CameraCapture
onCapture={handleCaptureImage}
onCancel={cancelCapture}
guidanceType="receipt"
allowCrop={true}
/>
</Dialog>
{/* OCR Processing Overlay */}
<Backdrop
open={isProcessing}
sx={{
color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1,
flexDirection: 'column',
gap: 2,
}}
>
<CircularProgress color="inherit" />
<Typography variant="body1">Extracting receipt data...</Typography>
</Backdrop>
{/* OCR Review Modal */}
{ocrResult && (
<MaintenanceReceiptReviewModal
open={!!ocrResult}
extractedFields={ocrResult.extractedFields}
receiptImageUrl={receiptImageUrl}
categorySuggestion={ocrResult.categorySuggestion}
onAccept={handleAcceptOcrResult}
onRetake={handleRetakePhoto}
onCancel={() => {
resetOcr();
setCapturedReceiptFile(null);
}}
onFieldEdit={updateField}
/>
)}
{/* Upgrade Required Dialog for Receipt Scan */}
<UpgradeRequiredDialog
featureKey="maintenance.receiptScan"
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
{/* OCR Error Display */}
{ocrError && (
<Dialog open={!!ocrError} onClose={resetOcr} maxWidth="xs">
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
OCR Error
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{ocrError}
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button onClick={startCapture} variant="contained">
Try Again
</Button>
<Button
onClick={() => {
resetOcr();
setCapturedReceiptFile(null);
}}
variant="outlined"
>
Cancel
</Button>
</Box>
</Box>
</Dialog>
)}
</LocalizationProvider>
);
};

View File

@@ -20,7 +20,7 @@ import {
useTheme,
useMediaQuery,
} from '@mui/material';
import { Edit, Delete } from '@mui/icons-material';
import { Edit, Delete, Receipt } from '@mui/icons-material';
import {
MaintenanceRecordResponse,
getCategoryDisplayName,
@@ -136,6 +136,15 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
variant="outlined"
/>
)}
{record.receiptDocument && (
<Chip
icon={<Receipt fontSize="small" />}
label="Receipt"
size="small"
color="info"
variant="outlined"
/>
)}
</Stack>
{record.notes && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>

View File

@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -105,6 +106,15 @@ export interface CreateMaintenanceRecordRequest {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string;
}
// Receipt document metadata returned on GET
export interface ReceiptDocumentMeta {
documentId: string;
fileName: string;
contentType: string;
storageKey: string;
}
export interface UpdateMaintenanceRecordRequest {
@@ -148,6 +158,7 @@ export interface UpdateScheduleRequest {
// Response types (camelCase)
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtypeCount: number;
receiptDocument?: ReceiptDocumentMeta | null;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {