feat: add maintenance receipt OCR hook and review modal (refs #152)
Add useMaintenanceReceiptOcr hook mirroring fuel receipt OCR pattern, MaintenanceReceiptReviewModal with confidence indicators and inline editing, and maintenance-receipt.types.ts for extraction field types. Includes category/subtype suggestion via keyword matching from service descriptions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<MaintenanceReceiptOcrResult | null>(null);
|
||||
const [receiptImageUrl, setReceiptImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user