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
- 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>
319 lines
8.6 KiB
TypeScript
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,
|
|
};
|
|
}
|