feat: integrate receipt capture with fuel log form (refs #70)
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
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>
This commit is contained in:
318
frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts
Normal file
318
frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user