diff --git a/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx new file mode 100644 index 0000000..b504074 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx @@ -0,0 +1,427 @@ +/** + * @ai-summary Modal for reviewing and editing OCR-extracted maintenance receipt fields + * @ai-context Mirrors ReceiptOcrReviewModal: confidence indicators, inline editing, category suggestion display + */ + +import React, { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + TextField, + Grid, + useTheme, + useMediaQuery, + IconButton, + Collapse, + Alert, + Chip, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; +import BuildIcon from '@mui/icons-material/Build'; +import { + ExtractedMaintenanceReceiptFields, + ExtractedMaintenanceField, + CategorySuggestion, +} from '../types/maintenance-receipt.types'; +import { LOW_CONFIDENCE_THRESHOLD } from '../hooks/useMaintenanceReceiptOcr'; +import { getCategoryDisplayName } from '../types/maintenance.types'; +import { ReceiptPreview } from '../../fuel-logs/components/ReceiptPreview'; + +export interface MaintenanceReceiptReviewModalProps { + open: boolean; + extractedFields: ExtractedMaintenanceReceiptFields; + receiptImageUrl: string | null; + categorySuggestion: CategorySuggestion | null; + onAccept: () => void; + onRetake: () => void; + onCancel: () => void; + onFieldEdit: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void; +} + +/** Confidence indicator component (4-dot system) */ +const ConfidenceIndicator: React.FC<{ confidence: number }> = ({ confidence }) => { + const filledDots = Math.round(confidence * 4); + const isLow = confidence < LOW_CONFIDENCE_THRESHOLD; + + return ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ); +}; + +/** Field row component with inline editing */ +const FieldRow: React.FC<{ + label: string; + field: ExtractedMaintenanceField; + onEdit: (value: string | number | null) => void; + type?: 'text' | 'number'; + formatDisplay?: (value: string | number | null) => string; +}> = ({ label, field, onEdit, type = 'text', formatDisplay }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState( + field.value !== null ? String(field.value) : '' + ); + const isLowConfidence = field.confidence < LOW_CONFIDENCE_THRESHOLD && field.value !== null; + + const displayValue = formatDisplay + ? formatDisplay(field.value) + : field.value !== null + ? String(field.value) + : '-'; + + const handleSave = () => { + let parsedValue: string | number | null = editValue || null; + if (type === 'number' && editValue) { + const num = parseFloat(editValue); + parsedValue = isNaN(num) ? null : num; + } + onEdit(parsedValue); + setIsEditing(false); + }; + + const handleCancel = () => { + setEditValue(field.value !== null ? String(field.value) : ''); + setIsEditing(false); + }; + + return ( + + + {label} + + + {isEditing ? ( + + setEditValue(e.target.value)} + type={type === 'number' ? 'number' : 'text'} + inputProps={{ + step: type === 'number' ? 0.01 : undefined, + }} + autoFocus + sx={{ flex: 1 }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') handleCancel(); + }} + /> + + + + + + + + ) : ( + setIsEditing(true)} + role="button" + tabIndex={0} + aria-label={`Edit ${label}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsEditing(true); + } + }} + > + + {displayValue} + + {field.value !== null && } + + + + + )} + + ); +}; + +export const MaintenanceReceiptReviewModal: React.FC = ({ + open, + extractedFields, + receiptImageUrl, + categorySuggestion, + onAccept, + onRetake, + onCancel, + onFieldEdit, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [showAllFields, setShowAllFields] = useState(false); + + const hasLowConfidenceFields = Object.values(extractedFields).some( + (field) => field.value !== null && field.confidence < LOW_CONFIDENCE_THRESHOLD + ); + + const formatCurrency = (value: string | number | null): string => { + if (value === null) return '-'; + const num = typeof value === 'string' ? parseFloat(value) : value; + return isNaN(num) ? String(value) : `$${num.toFixed(2)}`; + }; + + const formatDate = (value: string | number | null): string => { + if (value === null) return '-'; + const dateStr = String(value); + try { + const date = new Date(dateStr + 'T00:00:00'); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + } catch { + // Return as-is if parsing fails + } + return dateStr; + }; + + return ( + + + + Maintenance Receipt Extracted + + + + + + + + {hasLowConfidenceFields && ( + + Some fields have low confidence. Please review and edit if needed. + + )} + + + {/* Receipt thumbnail */} + {receiptImageUrl && ( + + + + + Tap to zoom + + + + )} + + {/* Extracted fields */} + + + {/* Primary fields */} + onFieldEdit('serviceName', value)} + /> + onFieldEdit('serviceDate', value)} + formatDisplay={formatDate} + /> + onFieldEdit('totalCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('shopName', value)} + /> + + {/* Category suggestion */} + {categorySuggestion && ( + + + + + {getCategoryDisplayName(categorySuggestion.category)} + + + {categorySuggestion.subtypes.map((subtype) => ( + + ))} + + + + )} + + {/* Secondary fields (collapsible on mobile) */} + + onFieldEdit('odometerReading', value)} + type="number" + /> + onFieldEdit('laborCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('partsCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('vehicleInfo', value)} + /> + + + {isMobile && ( + + )} + + + + + + Tap any field to edit before saving. + + + + + + + + + + + ); +}; + +export default MaintenanceReceiptReviewModal; diff --git a/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts new file mode 100644 index 0000000..725ff1a --- /dev/null +++ b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Hook to orchestrate maintenance receipt OCR extraction + * @ai-context Mirrors useReceiptOcr pattern: capture -> OCR -> category suggestion -> review -> accept + */ + +import { useState, useCallback } from 'react'; +import { apiClient } from '../../../core/api/client'; +import { + ExtractedMaintenanceReceiptFields, + ExtractedMaintenanceField, + MappedMaintenanceFields, + MaintenanceReceiptOcrResult, + CategorySuggestion, + UseMaintenanceReceiptOcrReturn, +} from '../types/maintenance-receipt.types'; +import { MaintenanceCategory } from '../types/maintenance.types'; + +/** Confidence threshold for highlighting low-confidence fields */ +export const LOW_CONFIDENCE_THRESHOLD = 0.7; + +/** Keyword-to-category/subtype mapping for service name suggestion */ +const SERVICE_KEYWORD_MAP: Array<{ + keywords: string[]; + category: MaintenanceCategory; + subtypes: string[]; +}> = [ + // Routine maintenance mappings + { keywords: ['oil change', 'oil filter', 'engine oil', 'synthetic oil', 'conventional oil', 'oil & filter'], + category: 'routine_maintenance', subtypes: ['Engine Oil'] }, + { keywords: ['tire rotation', 'tire balance', 'wheel balance', 'tire alignment', 'alignment'], + category: 'routine_maintenance', subtypes: ['Tires'] }, + { keywords: ['brake pad', 'brake rotor', 'brake fluid', 'brake inspection', 'brake service', 'brakes'], + category: 'routine_maintenance', subtypes: ['Brakes and Traction Control'] }, + { keywords: ['air filter', 'engine air filter'], + category: 'routine_maintenance', subtypes: ['Air Filter Element'] }, + { keywords: ['cabin filter', 'cabin air', 'a/c filter'], + category: 'routine_maintenance', subtypes: ['Cabin Air Filter / Purifier'] }, + { keywords: ['spark plug', 'ignition'], + category: 'routine_maintenance', subtypes: ['Spark Plug'] }, + { keywords: ['coolant', 'antifreeze', 'radiator flush', 'cooling system'], + category: 'routine_maintenance', subtypes: ['Coolant'] }, + { keywords: ['transmission fluid', 'trans fluid', 'atf'], + category: 'routine_maintenance', subtypes: ['Fluid - A/T'] }, + { keywords: ['differential fluid', 'diff fluid'], + category: 'routine_maintenance', subtypes: ['Fluid - Differential'] }, + { keywords: ['wiper blade', 'wiper', 'windshield wiper'], + category: 'routine_maintenance', subtypes: ['Wiper Blade'] }, + { keywords: ['washer fluid', 'windshield washer'], + category: 'routine_maintenance', subtypes: ['Washer Fluid'] }, + { keywords: ['drive belt', 'serpentine belt', 'timing belt', 'belt replacement'], + category: 'routine_maintenance', subtypes: ['Drive Belt'] }, + { keywords: ['exhaust', 'muffler', 'catalytic'], + category: 'routine_maintenance', subtypes: ['Exhaust System'] }, + { keywords: ['suspension', 'shock', 'strut', 'ball joint', 'tie rod'], + category: 'routine_maintenance', subtypes: ['Steering and Suspension'] }, + { keywords: ['fuel filter', 'fuel injection', 'fuel system', 'fuel delivery'], + category: 'routine_maintenance', subtypes: ['Fuel Delivery and Air Induction'] }, + { keywords: ['parking brake', 'e-brake', 'emergency brake'], + category: 'routine_maintenance', subtypes: ['Parking Brake System'] }, + // Repair mappings + { keywords: ['engine repair', 'engine rebuild', 'head gasket', 'valve cover'], + category: 'repair', subtypes: ['Engine'] }, + { keywords: ['transmission repair', 'trans rebuild'], + category: 'repair', subtypes: ['Transmission'] }, + { keywords: ['axle', 'cv joint', 'driveshaft', 'drivetrain'], + category: 'repair', subtypes: ['Drivetrain'] }, + { keywords: ['body work', 'dent', 'paint', 'bumper', 'fender'], + category: 'repair', subtypes: ['Exterior'] }, + { keywords: ['upholstery', 'dashboard', 'seat repair', 'interior repair'], + category: 'repair', subtypes: ['Interior'] }, +]; + +/** Suggest category and subtypes from service name using keyword matching */ +function suggestCategory(serviceName: string | number | null): CategorySuggestion | null { + if (!serviceName) return null; + const normalized = String(serviceName).toLowerCase().trim(); + if (!normalized) return null; + + for (const mapping of SERVICE_KEYWORD_MAP) { + for (const keyword of mapping.keywords) { + if (normalized.includes(keyword)) { + return { + category: mapping.category, + subtypes: mapping.subtypes, + confidence: 0.8, + }; + } + } + } + + // No match found - default to routine_maintenance with no subtypes + return null; +} + +/** Parse date string to YYYY-MM-DD format */ +function parseServiceDate(value: string | number | null): string | undefined { + if (!value) return undefined; + + const dateStr = String(value); + + // Already in YYYY-MM-DD format + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const date = new Date(dateStr + 'T00:00:00'); + if (!isNaN(date.getTime())) return dateStr; + } + + // Try standard parsing + const date = new Date(dateStr); + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0]; + } + + // Try MM/DD/YYYY format + const mdyMatch = dateStr.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (mdyMatch) { + const [, month, day, year] = mdyMatch; + const parsed = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); + if (!isNaN(parsed.getTime())) { + return parsed.toISOString().split('T')[0]; + } + } + + return undefined; +} + +/** Parse numeric value */ +function parseNumber(value: string | number | null): number | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === 'number') return value; + + const cleaned = value.replace(/[$,\s]/g, ''); + const num = parseFloat(cleaned); + return isNaN(num) ? undefined : num; +} + +/** Extract maintenance receipt data from image via OCR service */ +async function extractMaintenanceReceiptFromImage(file: File): Promise<{ + extractedFields: ExtractedMaintenanceReceiptFields; + rawText: string; + confidence: number; +}> { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/ocr/extract/maintenance-receipt', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30000, + }); + + const data = response.data; + + if (!data.success) { + throw new Error('Maintenance receipt OCR extraction failed'); + } + + const fields = data.extractedFields || {}; + + const makeField = (key: string): ExtractedMaintenanceField => ({ + value: fields[key]?.value ?? null, + confidence: fields[key]?.confidence ?? 0, + }); + + const extractedFields: ExtractedMaintenanceReceiptFields = { + serviceName: makeField('serviceName'), + serviceDate: makeField('serviceDate'), + totalCost: makeField('totalCost'), + shopName: makeField('shopName'), + laborCost: makeField('laborCost'), + partsCost: makeField('partsCost'), + odometerReading: makeField('odometerReading'), + vehicleInfo: makeField('vehicleInfo'), + }; + + return { + extractedFields, + rawText: data.rawText || '', + confidence: data.confidence || 0, + }; +} + +/** Map extracted fields to maintenance record form fields */ +function mapFieldsToMaintenanceRecord( + fields: ExtractedMaintenanceReceiptFields, + categorySuggestion: CategorySuggestion | null +): MappedMaintenanceFields { + // Build notes from supplementary fields + const noteParts: string[] = []; + if (fields.laborCost.value !== null) { + noteParts.push(`Labor: $${parseNumber(fields.laborCost.value)?.toFixed(2) ?? fields.laborCost.value}`); + } + if (fields.partsCost.value !== null) { + noteParts.push(`Parts: $${parseNumber(fields.partsCost.value)?.toFixed(2) ?? fields.partsCost.value}`); + } + if (fields.vehicleInfo.value !== null) { + noteParts.push(`Vehicle: ${fields.vehicleInfo.value}`); + } + + return { + date: parseServiceDate(fields.serviceDate.value), + cost: parseNumber(fields.totalCost.value), + shopName: fields.shopName.value ? String(fields.shopName.value) : undefined, + odometerReading: parseNumber(fields.odometerReading.value), + category: categorySuggestion?.category, + subtypes: categorySuggestion?.subtypes, + notes: noteParts.length > 0 ? noteParts.join(' | ') : undefined, + }; +} + +/** + * Hook to orchestrate maintenance receipt photo capture and OCR extraction. + * Mirrors useReceiptOcr pattern: startCapture -> processImage -> review -> acceptResult + */ +export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn { + const [isCapturing, setIsCapturing] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [result, setResult] = useState(null); + const [receiptImageUrl, setReceiptImageUrl] = useState(null); + const [error, setError] = useState(null); + + const startCapture = useCallback(() => { + setIsCapturing(true); + setError(null); + setResult(null); + }, []); + + const cancelCapture = useCallback(() => { + setIsCapturing(false); + setError(null); + }, []); + + const processImage = useCallback(async (file: File, croppedFile?: File) => { + setIsCapturing(false); + setIsProcessing(true); + setError(null); + setResult(null); + + const imageToProcess = croppedFile || file; + const imageUrl = URL.createObjectURL(imageToProcess); + setReceiptImageUrl(imageUrl); + + try { + const { extractedFields, rawText, confidence } = await extractMaintenanceReceiptFromImage(imageToProcess); + + const categorySuggestion = suggestCategory(extractedFields.serviceName.value); + const mappedFields = mapFieldsToMaintenanceRecord(extractedFields, categorySuggestion); + + setResult({ + extractedFields, + mappedFields, + categorySuggestion, + rawText, + overallConfidence: confidence, + }); + } catch (err: any) { + console.error('Maintenance receipt OCR processing failed:', err); + const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image'; + setError(message); + URL.revokeObjectURL(imageUrl); + setReceiptImageUrl(null); + } finally { + setIsProcessing(false); + } + }, []); + + const updateField = useCallback(( + fieldName: keyof ExtractedMaintenanceReceiptFields, + value: string | number | null + ) => { + setResult((prev) => { + if (!prev) return null; + + const updatedFields = { + ...prev.extractedFields, + [fieldName]: { + ...prev.extractedFields[fieldName], + value, + confidence: 1.0, // User-edited field has full confidence + }, + }; + + // Re-run category suggestion if service name was edited + const categorySuggestion = fieldName === 'serviceName' + ? suggestCategory(value) + : prev.categorySuggestion; + + return { + ...prev, + extractedFields: updatedFields, + categorySuggestion, + mappedFields: mapFieldsToMaintenanceRecord(updatedFields, categorySuggestion), + }; + }); + }, []); + + const acceptResult = useCallback(() => { + if (!result) return null; + + const mappedFields = result.mappedFields; + + if (receiptImageUrl) { + URL.revokeObjectURL(receiptImageUrl); + } + setResult(null); + setReceiptImageUrl(null); + + return mappedFields; + }, [result, receiptImageUrl]); + + const reset = useCallback(() => { + setIsCapturing(false); + setIsProcessing(false); + if (receiptImageUrl) { + URL.revokeObjectURL(receiptImageUrl); + } + setResult(null); + setReceiptImageUrl(null); + setError(null); + }, [receiptImageUrl]); + + return { + isCapturing, + isProcessing, + result, + receiptImageUrl, + error, + startCapture, + cancelCapture, + processImage, + acceptResult, + reset, + updateField, + }; +} diff --git a/frontend/src/features/maintenance/types/maintenance-receipt.types.ts b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts new file mode 100644 index 0000000..35c7081 --- /dev/null +++ b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts @@ -0,0 +1,70 @@ +/** + * @ai-summary Type definitions for maintenance receipt OCR extraction + * @ai-context Mirrors fuel-logs ExtractedReceiptField pattern; maps OCR fields to maintenance record form values + */ + +import { MaintenanceCategory } from './maintenance.types'; + +/** OCR-extracted field with confidence score */ +export interface ExtractedMaintenanceField { + value: string | number | null; + confidence: number; +} + +/** Fields extracted from a maintenance receipt via OCR */ +export interface ExtractedMaintenanceReceiptFields { + serviceName: ExtractedMaintenanceField; + serviceDate: ExtractedMaintenanceField; + totalCost: ExtractedMaintenanceField; + shopName: ExtractedMaintenanceField; + laborCost: ExtractedMaintenanceField; + partsCost: ExtractedMaintenanceField; + odometerReading: ExtractedMaintenanceField; + vehicleInfo: ExtractedMaintenanceField; +} + +/** Suggested category and subtypes from service name keyword matching */ +export interface CategorySuggestion { + category: MaintenanceCategory; + subtypes: string[]; + confidence: number; +} + +/** Mapped fields ready for maintenance record form population */ +export interface MappedMaintenanceFields { + date?: string; + cost?: number; + shopName?: string; + odometerReading?: number; + category?: MaintenanceCategory; + subtypes?: string[]; + notes?: string; +} + +/** Maintenance receipt OCR result */ +export interface MaintenanceReceiptOcrResult { + extractedFields: ExtractedMaintenanceReceiptFields; + mappedFields: MappedMaintenanceFields; + categorySuggestion: CategorySuggestion | null; + rawText: string; + overallConfidence: number; +} + +/** Hook state */ +export interface UseMaintenanceReceiptOcrState { + isCapturing: boolean; + isProcessing: boolean; + result: MaintenanceReceiptOcrResult | null; + receiptImageUrl: string | null; + error: string | null; +} + +/** Hook return type */ +export interface UseMaintenanceReceiptOcrReturn extends UseMaintenanceReceiptOcrState { + startCapture: () => void; + cancelCapture: () => void; + processImage: (file: File, croppedFile?: File) => Promise; + acceptResult: () => MappedMaintenanceFields | null; + reset: () => void; + updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void; +}