Merge pull request 'feat: Receipt Capture Integration (#70)' (#78) from issue-70-receipt-capture-integration into main
Some checks failed
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
Deploy to Staging / Build Images (push) Has been cancelled
Some checks failed
Deploy to Staging / Deploy to Staging (push) Has been cancelled
Deploy to Staging / Verify Staging (push) Has been cancelled
Deploy to Staging / Notify Staging Ready (push) Has been cancelled
Deploy to Staging / Notify Staging Failure (push) Has been cancelled
Deploy to Staging / Build Images (push) Has been cancelled
Reviewed-on: #78
This commit was merged in pull request #78.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
169
frontend/src/features/fuel-logs/components/ReceiptPreview.tsx
Normal file
169
frontend/src/features/fuel-logs/components/ReceiptPreview.tsx
Normal 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;
|
||||
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