/** * @ai-summary Hook to orchestrate receipt OCR extraction for fuel logs * @ai-context Handles camera capture -> OCR extraction -> field mapping flow */ import { useState, useCallback } from 'react'; import { apiClient } from '../../../core/api/client'; import { FuelGrade } from '../types/fuel-logs.types'; /** OCR-extracted receipt field with confidence */ export interface ExtractedReceiptField { value: string | number | null; confidence: number; } /** Fields extracted from a fuel receipt */ export interface ExtractedReceiptFields { transactionDate: ExtractedReceiptField; totalAmount: ExtractedReceiptField; fuelQuantity: ExtractedReceiptField; pricePerUnit: ExtractedReceiptField; fuelGrade: ExtractedReceiptField; merchantName: ExtractedReceiptField; } /** Mapped fields ready for form population */ export interface MappedFuelLogFields { dateTime?: string; fuelUnits?: number; costPerUnit?: number; fuelGrade?: FuelGrade; locationData?: { stationName?: string; }; } /** Receipt OCR result */ export interface ReceiptOcrResult { extractedFields: ExtractedReceiptFields; mappedFields: MappedFuelLogFields; rawText: string; overallConfidence: number; } /** Hook state */ export interface UseReceiptOcrState { isCapturing: boolean; isProcessing: boolean; result: ReceiptOcrResult | null; receiptImageUrl: string | null; error: string | null; } /** Hook return type */ export interface UseReceiptOcrReturn extends UseReceiptOcrState { startCapture: () => void; cancelCapture: () => void; processImage: (file: File, croppedFile?: File) => Promise; acceptResult: () => MappedFuelLogFields | null; reset: () => void; updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void; } /** Confidence threshold for highlighting low-confidence fields */ export const LOW_CONFIDENCE_THRESHOLD = 0.7; /** Map fuel grade string to valid FuelGrade enum */ function mapFuelGrade(value: string | number | null): FuelGrade { if (!value) return null; const stringValue = String(value).toLowerCase().trim(); // Map common grade values const gradeMap: Record = { '87': '87', 'regular': '87', 'unleaded': '87', '88': '88', '89': '89', 'mid': '89', 'midgrade': '89', 'plus': '89', '91': '91', 'premium': '91', '93': '93', 'super': '93', '#1': '#1', '#2': '#2', 'diesel': '#2', }; return gradeMap[stringValue] || null; } /** Parse date string to ISO format */ function parseTransactionDate(value: string | number | null): string | undefined { if (!value) return undefined; const dateStr = String(value); // Try to parse various date formats const date = new Date(dateStr); if (!isNaN(date.getTime())) { return date.toISOString(); } // 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(); } } 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; // Remove currency symbols, commas, and extra whitespace const cleaned = value.replace(/[$,\s]/g, ''); const num = parseFloat(cleaned); return isNaN(num) ? undefined : num; } /** Extract receipt data from image using OCR service */ async function extractReceiptFromImage(file: File): Promise<{ extractedFields: ExtractedReceiptFields; rawText: string; confidence: number; }> { const formData = new FormData(); formData.append('file', file); const response = await apiClient.post('/ocr/extract', formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 30000, // 30 seconds for OCR processing }); const data = response.data; if (!data.success) { throw new Error('OCR extraction failed'); } // Map OCR response to ExtractedReceiptFields const fields = data.extractedFields || {}; const extractedFields: ExtractedReceiptFields = { transactionDate: { value: fields.transactionDate?.value || null, confidence: fields.transactionDate?.confidence || 0, }, totalAmount: { value: fields.totalAmount?.value || null, confidence: fields.totalAmount?.confidence || 0, }, fuelQuantity: { value: fields.fuelQuantity?.value || null, confidence: fields.fuelQuantity?.confidence || 0, }, pricePerUnit: { value: fields.pricePerUnit?.value || null, confidence: fields.pricePerUnit?.confidence || 0, }, fuelGrade: { value: fields.fuelGrade?.value || null, confidence: fields.fuelGrade?.confidence || 0, }, merchantName: { value: fields.merchantName?.value || null, confidence: fields.merchantName?.confidence || 0, }, }; return { extractedFields, rawText: data.rawText || '', confidence: data.confidence || 0, }; } /** Map extracted fields to fuel log form fields */ function mapFieldsToFuelLog(fields: ExtractedReceiptFields): MappedFuelLogFields { return { dateTime: parseTransactionDate(fields.transactionDate.value), fuelUnits: parseNumber(fields.fuelQuantity.value), costPerUnit: parseNumber(fields.pricePerUnit.value), fuelGrade: mapFuelGrade(fields.fuelGrade.value), locationData: fields.merchantName.value ? { stationName: String(fields.merchantName.value) } : undefined, }; } /** * Hook to orchestrate receipt photo capture and OCR extraction */ export function useReceiptOcr(): UseReceiptOcrReturn { 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); // Create blob URL for preview const imageToProcess = croppedFile || file; const imageUrl = URL.createObjectURL(imageToProcess); setReceiptImageUrl(imageUrl); try { const { extractedFields, rawText, confidence } = await extractReceiptFromImage(imageToProcess); const mappedFields = mapFieldsToFuelLog(extractedFields); setResult({ extractedFields, mappedFields, rawText, overallConfidence: confidence, }); } catch (err: any) { console.error('Receipt OCR processing failed:', err); const message = err.response?.data?.message || err.message || 'Failed to process receipt image'; setError(message); // Clean up image URL on error URL.revokeObjectURL(imageUrl); setReceiptImageUrl(null); } finally { setIsProcessing(false); } }, []); const updateField = useCallback(( fieldName: keyof ExtractedReceiptFields, 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 }, }; return { ...prev, extractedFields: updatedFields, mappedFields: mapFieldsToFuelLog(updatedFields), }; }); }, []); const acceptResult = useCallback(() => { if (!result) return null; const mappedFields = result.mappedFields; // Clean up 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, }; }