diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx index ff95c0d..5482c47 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx @@ -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(null); // Reset form when record changes useEffect(() => { @@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC { + 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 + {/* Linked Receipt Display */} + {record.receiptDocument && ( + + + {receiptThumbnailUrl ? ( + + ) : ( + + + + )} + + + Linked Receipt + + {record.receiptDocument.fileName && ( + + {record.receiptDocument.fileName} + + )} + + + + + )} + {/* Category */} diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index e3b4343..755373d 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -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(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(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 = () => { + {/* Receipt Scan Button */} + + { + if (!hasReceiptScanAccess) { + setShowUpgradeDialog(true); + return; + } + startCapture(); + }} + disabled={isProcessing || isRecordMutating} + variant="button" + locked={!hasReceiptScanAccess} + /> + +
{/* Vehicle Selection */} @@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
+ + {/* Camera Capture Modal */} + + + + + {/* OCR Processing Overlay */} + theme.zIndex.drawer + 1, + flexDirection: 'column', + gap: 2, + }} + > + + Extracting receipt data... + + + {/* OCR Review Modal */} + {ocrResult && ( + { + resetOcr(); + setCapturedReceiptFile(null); + }} + onFieldEdit={updateField} + /> + )} + + {/* Upgrade Required Dialog for Receipt Scan */} + setShowUpgradeDialog(false)} + /> + + {/* OCR Error Display */} + {ocrError && ( + + + + OCR Error + + + {ocrError} + + + + + + + + )} ); }; diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx index 015029c..0f38234 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx @@ -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 = ({ variant="outlined" /> )} + {record.receiptDocument && ( + } + label="Receipt" + size="small" + color="info" + variant="outlined" + /> + )} {record.notes && ( diff --git a/frontend/src/features/maintenance/types/maintenance.types.ts b/frontend/src/features/maintenance/types/maintenance.types.ts index 26e7d3d..3eebef4 100644 --- a/frontend/src/features/maintenance/types/maintenance.types.ts +++ b/frontend/src/features/maintenance/types/maintenance.types.ts @@ -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 {