/** * @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 isPdf = imageToProcess.type === 'application/pdf' || imageToProcess.name.toLowerCase().endsWith('.pdf'); const imageUrl = isPdf ? null : 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); if (imageUrl) 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, }; }