feat: Receipt Capture Integration (#70) #78

Merged
egullickson merged 1 commits from issue-70-receipt-capture-integration into main 2026-02-02 03:10:28 +00:00
5 changed files with 1127 additions and 1 deletions

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState, useRef, memo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Grid, Card, CardHeader, CardContent, TextField, Box, Button, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material';
import { Grid, Card, CardHeader, CardContent, TextField, Box, Button, CircularProgress, ToggleButton, ToggleButtonGroup, Dialog, Backdrop, Typography } from '@mui/material';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
@@ -13,9 +13,13 @@ import { FuelTypeSelector } from './FuelTypeSelector';
import { UnitSystemDisplay } from './UnitSystemDisplay';
import { StationPicker } from './StationPicker';
import { CostCalculator } from './CostCalculator';
import { ReceiptCameraButton } from './ReceiptCameraButton';
import { ReceiptOcrReviewModal } from './ReceiptOcrReviewModal';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { useUserSettings } from '../hooks/useUserSettings';
import { useReceiptOcr } from '../hooks/useReceiptOcr';
import { useGeolocation } from '../../stations/hooks/useGeolocation';
import { CameraCapture } from '../../../shared/components/CameraCapture';
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
const schema = z.object({
@@ -44,6 +48,21 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
// Get user location for nearby station search
const { coordinates: userLocation } = useGeolocation();
// Receipt OCR integration
const {
isCapturing,
isProcessing,
result: ocrResult,
receiptImageUrl,
error: ocrError,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset: resetOcr,
updateField,
} = useReceiptOcr();
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
resolver: zodResolver(schema),
mode: 'onChange',
@@ -115,6 +134,44 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
return 0;
}, [distanceValue, fuelUnits]);
// Handle accepting OCR results and populating the form
const handleAcceptOcrResult = () => {
const mappedFields = acceptResult();
if (!mappedFields) return;
// Populate form fields from OCR result
if (mappedFields.dateTime) {
setValue('dateTime', mappedFields.dateTime);
}
if (mappedFields.fuelUnits !== undefined) {
setValue('fuelUnits', mappedFields.fuelUnits);
}
if (mappedFields.costPerUnit !== undefined) {
setValue('costPerUnit', mappedFields.costPerUnit);
}
if (mappedFields.fuelGrade) {
setValue('fuelGrade', mappedFields.fuelGrade);
}
if (mappedFields.locationData?.stationName) {
// Set station name in locationData if no station is already selected
const currentLocation = watch('locationData');
if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) {
setValue('locationData', {
...currentLocation,
stationName: mappedFields.locationData.stationName,
});
}
}
console.log('[FuelLogForm] Form populated from OCR result', mappedFields);
};
// Handle retaking photo
const handleRetakePhoto = () => {
resetOcr();
startCapture();
};
const onSubmit = async (data: CreateFuelLogRequest) => {
const payload: CreateFuelLogRequest = {
...data,
@@ -148,6 +205,24 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
<Card>
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
<CardContent>
{/* Receipt Scan Button */}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3,
pb: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReceiptCameraButton
onClick={startCapture}
disabled={isProcessing || isLoading}
variant="button"
/>
</Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Row 1: Select Vehicle */}
@@ -316,6 +391,72 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
</form>
</CardContent>
</Card>
{/* Camera Capture Modal */}
<Dialog
open={isCapturing}
onClose={cancelCapture}
fullScreen
PaperProps={{
sx: { backgroundColor: 'black' },
}}
>
<CameraCapture
onCapture={processImage}
onCancel={cancelCapture}
guidanceType="receipt"
allowCrop={true}
/>
</Dialog>
{/* OCR Processing Overlay */}
<Backdrop
open={isProcessing}
sx={{
color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1,
flexDirection: 'column',
gap: 2,
}}
>
<CircularProgress color="inherit" />
<Typography variant="body1">Extracting receipt data...</Typography>
</Backdrop>
{/* OCR Review Modal */}
{ocrResult && (
<ReceiptOcrReviewModal
open={!!ocrResult}
extractedFields={ocrResult.extractedFields}
receiptImageUrl={receiptImageUrl}
onAccept={handleAcceptOcrResult}
onRetake={handleRetakePhoto}
onCancel={resetOcr}
onFieldEdit={updateField}
/>
)}
{/* OCR Error Display */}
{ocrError && (
<Dialog open={!!ocrError} onClose={resetOcr} maxWidth="xs">
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
OCR Error
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{ocrError}
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button onClick={startCapture} variant="contained">
Try Again
</Button>
<Button onClick={resetOcr} variant="outlined">
Cancel
</Button>
</Box>
</Box>
</Dialog>
)}
</LocalizationProvider>
);
};

View File

@@ -0,0 +1,83 @@
/**
* @ai-summary Button component to trigger receipt camera capture
* @ai-context Styled button for mobile and desktop that opens CameraCapture
*/
import React from 'react';
import { Button, IconButton, Tooltip, useTheme, useMediaQuery } from '@mui/material';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import ReceiptIcon from '@mui/icons-material/Receipt';
export interface ReceiptCameraButtonProps {
/** Called when user clicks to start capture */
onClick: () => void;
/** Whether the button is disabled */
disabled?: boolean;
/** Display variant */
variant?: 'icon' | 'button' | 'auto';
/** Size of the button */
size?: 'small' | 'medium' | 'large';
}
export const ReceiptCameraButton: React.FC<ReceiptCameraButtonProps> = ({
onClick,
disabled = false,
variant = 'auto',
size = 'medium',
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Determine display variant
const displayVariant = variant === 'auto' ? (isMobile ? 'icon' : 'button') : variant;
if (displayVariant === 'icon') {
return (
<Tooltip title="Scan Receipt">
<span>
<IconButton
onClick={onClick}
disabled={disabled}
color="primary"
size={size}
aria-label="Scan receipt with camera"
sx={{
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
'&.Mui-disabled': {
backgroundColor: 'action.disabledBackground',
},
}}
>
<CameraAltIcon />
</IconButton>
</span>
</Tooltip>
);
}
return (
<Button
onClick={onClick}
disabled={disabled}
variant="outlined"
color="primary"
size={size}
startIcon={<ReceiptIcon />}
endIcon={<CameraAltIcon />}
sx={{
borderStyle: 'dashed',
'&:hover': {
borderStyle: 'solid',
},
}}
>
Scan Receipt
</Button>
);
};
export default ReceiptCameraButton;

View File

@@ -0,0 +1,415 @@
/**
* @ai-summary Modal for reviewing and editing OCR-extracted receipt fields
* @ai-context Shows extracted fields with confidence indicators and allows editing before accepting
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
IconButton,
Collapse,
Alert,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import {
ExtractedReceiptFields,
ExtractedReceiptField,
LOW_CONFIDENCE_THRESHOLD,
} from '../hooks/useReceiptOcr';
import { ReceiptPreview } from './ReceiptPreview';
export interface ReceiptOcrReviewModalProps {
/** Whether the modal is open */
open: boolean;
/** Extracted fields from OCR */
extractedFields: ExtractedReceiptFields;
/** Receipt image URL for preview */
receiptImageUrl: string | null;
/** Called when user accepts the fields */
onAccept: () => void;
/** Called when user wants to retake the photo */
onRetake: () => void;
/** Called when user cancels */
onCancel: () => void;
/** Called when user edits a field */
onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
}
/** Confidence indicator component */
const ConfidenceIndicator: React.FC<{ confidence: number }> = ({ confidence }) => {
const filledDots = Math.round(confidence * 4);
const isLow = confidence < LOW_CONFIDENCE_THRESHOLD;
return (
<Box
sx={{
display: 'flex',
gap: 0.25,
ml: 1,
}}
aria-label={`Confidence: ${Math.round(confidence * 100)}%`}
>
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: i < filledDots
? (isLow ? 'warning.main' : 'success.main')
: 'grey.300',
}}
/>
))}
</Box>
);
};
/** Field row component with inline editing */
const FieldRow: React.FC<{
label: string;
field: ExtractedReceiptField;
onEdit: (value: string | number | null) => void;
type?: 'text' | 'number' | 'date';
formatDisplay?: (value: string | number | null) => string;
}> = ({ label, field, onEdit, type = 'text', formatDisplay }) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState<string>(
field.value !== null ? String(field.value) : ''
);
const isLowConfidence = field.confidence < LOW_CONFIDENCE_THRESHOLD && field.value !== null;
const displayValue = formatDisplay
? formatDisplay(field.value)
: field.value !== null
? String(field.value)
: '-';
const handleSave = () => {
let parsedValue: string | number | null = editValue || null;
if (type === 'number' && editValue) {
const num = parseFloat(editValue);
parsedValue = isNaN(num) ? null : num;
}
onEdit(parsedValue);
setIsEditing(false);
};
const handleCancel = () => {
setEditValue(field.value !== null ? String(field.value) : '');
setIsEditing(false);
};
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
py: 1.5,
px: 1,
borderRadius: 1,
backgroundColor: isLowConfidence ? 'warning.light' : 'transparent',
'&:hover': {
backgroundColor: isLowConfidence ? 'warning.light' : 'action.hover',
},
}}
>
<Typography
variant="body2"
sx={{
width: 100,
flexShrink: 0,
color: 'text.secondary',
fontWeight: 500,
}}
>
{label}
</Typography>
{isEditing ? (
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1, gap: 1 }}>
<TextField
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
type={type === 'number' ? 'number' : 'text'}
inputProps={{
step: type === 'number' ? 0.001 : undefined,
}}
autoFocus
sx={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
/>
<IconButton size="small" onClick={handleSave} color="primary">
<CheckIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={handleCancel}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flex: 1,
cursor: 'pointer',
}}
onClick={() => setIsEditing(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${label}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsEditing(true);
}
}}
>
<Typography
variant="body1"
sx={{
flex: 1,
fontWeight: field.value !== null ? 500 : 400,
color: field.value !== null ? 'text.primary' : 'text.disabled',
}}
>
{displayValue}
</Typography>
{field.value !== null && <ConfidenceIndicator confidence={field.confidence} />}
<IconButton size="small" sx={{ ml: 1 }}>
<EditIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
};
export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
open,
extractedFields,
receiptImageUrl,
onAccept,
onRetake,
onCancel,
onFieldEdit,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [showAllFields, setShowAllFields] = useState(false);
// Check if any fields have low confidence
const hasLowConfidenceFields = Object.values(extractedFields).some(
(field) => field.value !== null && field.confidence < LOW_CONFIDENCE_THRESHOLD
);
// Format currency display
const formatCurrency = (value: string | number | null): string => {
if (value === null) return '-';
const num = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(num) ? String(value) : `$${num.toFixed(2)}`;
};
// Format date display
const formatDate = (value: string | number | null): string => {
if (value === null) return '-';
const dateStr = String(value);
try {
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
} catch {
// Return as-is if parsing fails
}
return dateStr;
};
return (
<Dialog
open={open}
onClose={onCancel}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: {
maxHeight: isMobile ? '100vh' : '90vh',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="h6" component="span">
Receipt Data Extracted
</Typography>
<IconButton onClick={onCancel} size="small" aria-label="Close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{hasLowConfidenceFields && (
<Alert severity="warning" sx={{ mb: 2 }}>
Some fields have low confidence. Please review and edit if needed.
</Alert>
)}
<Grid container spacing={2}>
{/* Receipt thumbnail */}
{receiptImageUrl && (
<Grid item xs={12} sm={4}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
}}
>
<ReceiptPreview
imageUrl={receiptImageUrl}
maxWidth={isMobile ? 100 : 120}
maxHeight={isMobile ? 150 : 180}
/>
<Typography variant="caption" color="text.secondary">
Tap to zoom
</Typography>
</Box>
</Grid>
)}
{/* Extracted fields */}
<Grid item xs={12} sm={receiptImageUrl ? 8 : 12}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Primary fields */}
<FieldRow
label="Date"
field={extractedFields.transactionDate}
onEdit={(value) => onFieldEdit('transactionDate', value)}
type="text"
formatDisplay={formatDate}
/>
<FieldRow
label="Gallons"
field={extractedFields.fuelQuantity}
onEdit={(value) => onFieldEdit('fuelQuantity', value)}
type="number"
/>
<FieldRow
label="$/Gallon"
field={extractedFields.pricePerUnit}
onEdit={(value) => onFieldEdit('pricePerUnit', value)}
type="number"
formatDisplay={formatCurrency}
/>
<FieldRow
label="Total"
field={extractedFields.totalAmount}
onEdit={(value) => onFieldEdit('totalAmount', value)}
type="number"
formatDisplay={formatCurrency}
/>
{/* Secondary fields (collapsible on mobile) */}
<Collapse in={!isMobile || showAllFields}>
<FieldRow
label="Grade"
field={extractedFields.fuelGrade}
onEdit={(value) => onFieldEdit('fuelGrade', value)}
type="text"
/>
<FieldRow
label="Station"
field={extractedFields.merchantName}
onEdit={(value) => onFieldEdit('merchantName', value)}
type="text"
/>
</Collapse>
{isMobile && (
<Button
size="small"
onClick={() => setShowAllFields(!showAllFields)}
sx={{ mt: 1, alignSelf: 'flex-start' }}
>
{showAllFields ? 'Show Less' : 'Show More Fields'}
</Button>
)}
</Box>
</Grid>
</Grid>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 2, textAlign: 'center' }}
>
Tap any field to edit before saving.
</Typography>
</DialogContent>
<DialogActions
sx={{
flexDirection: isMobile ? 'column' : 'row',
gap: 1,
p: 2,
}}
>
<Button
onClick={onRetake}
startIcon={<CameraAltIcon />}
sx={{ order: isMobile ? 2 : 1 }}
>
Retake Photo
</Button>
<Box sx={{ flex: 1, display: isMobile ? 'none' : 'block' }} />
<Button
onClick={onCancel}
sx={{ order: isMobile ? 3 : 2 }}
>
Cancel
</Button>
<Button
variant="contained"
onClick={onAccept}
startIcon={<CheckIcon />}
sx={{ order: isMobile ? 1 : 3, width: isMobile ? '100%' : 'auto' }}
>
Accept
</Button>
</DialogActions>
</Dialog>
);
};
export default ReceiptOcrReviewModal;

View File

@@ -0,0 +1,169 @@
/**
* @ai-summary Receipt thumbnail preview component with zoom capability
* @ai-context Displays captured receipt image with tap-to-zoom on mobile
*/
import React, { useState } from 'react';
import {
Box,
Dialog,
IconButton,
useTheme,
useMediaQuery,
} from '@mui/material';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import CloseIcon from '@mui/icons-material/Close';
export interface ReceiptPreviewProps {
/** URL of the receipt image (blob URL) */
imageUrl: string;
/** Alt text for accessibility */
alt?: string;
/** Maximum width of thumbnail in pixels */
maxWidth?: number;
/** Maximum height of thumbnail in pixels */
maxHeight?: number;
}
export const ReceiptPreview: React.FC<ReceiptPreviewProps> = ({
imageUrl,
alt = 'Receipt preview',
maxWidth = 120,
maxHeight = 180,
}) => {
const [isZoomed, setIsZoomed] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const handleOpenZoom = () => {
setIsZoomed(true);
};
const handleCloseZoom = () => {
setIsZoomed(false);
};
return (
<>
{/* Thumbnail */}
<Box
sx={{
position: 'relative',
width: maxWidth,
height: maxHeight,
borderRadius: 1,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
flexShrink: 0,
'&:hover': {
'& .zoom-overlay': {
opacity: 1,
},
},
}}
onClick={handleOpenZoom}
role="button"
tabIndex={0}
aria-label="Click to zoom receipt image"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenZoom();
}
}}
>
<Box
component="img"
src={imageUrl}
alt={alt}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{/* Zoom overlay */}
<Box
className="zoom-overlay"
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isMobile ? 0 : 0,
transition: 'opacity 0.2s',
}}
>
<ZoomInIcon sx={{ color: 'white', fontSize: 32 }} />
</Box>
</Box>
{/* Zoom dialog */}
<Dialog
open={isZoomed}
onClose={handleCloseZoom}
maxWidth="lg"
fullScreen={isMobile}
PaperProps={{
sx: {
backgroundColor: 'black',
...(isMobile ? {} : { maxHeight: '90vh' }),
},
}}
>
{/* Close button */}
<IconButton
onClick={handleCloseZoom}
aria-label="Close zoom view"
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1,
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
}}
>
<CloseIcon />
</IconButton>
{/* Full-size image */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: isMobile ? '100vh' : 'auto',
overflow: 'auto',
p: 2,
}}
>
<Box
component="img"
src={imageUrl}
alt={alt}
sx={{
maxWidth: '100%',
maxHeight: isMobile ? 'calc(100vh - 80px)' : '80vh',
objectFit: 'contain',
}}
/>
</Box>
</Dialog>
</>
);
};
export default ReceiptPreview;

View 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,
};
}