Files
motovaultpro/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts
Eric Gullickson d78ba24c5e
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 32s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: integrate receipt capture with fuel log form (refs #70)
- Add useReceiptOcr hook for OCR extraction orchestration
- Add ReceiptCameraButton component for triggering capture
- Add ReceiptOcrReviewModal for reviewing/editing extracted fields
- Add ReceiptPreview component with zoom capability
- Integrate camera capture, OCR processing, and form population
- Include confidence indicators and low-confidence field highlighting
- Support inline editing of extracted fields before acceptance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:01:42 -06:00

319 lines
8.6 KiB
TypeScript

/**
* @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<void>;
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<string, FuelGrade> = {
'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<ReceiptOcrResult | 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);
// 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,
};
}