Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m11s
Deploy to Staging / Deploy to Staging (push) Successful in 31s
Deploy to Staging / Verify Staging (push) Successful in 2m29s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m11s
Deploy to Staging / Deploy to Staging (push) Successful in 31s
Deploy to Staging / Verify Staging (push) Successful in 2m29s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
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 { useForm, Controller } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/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 { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||||
@@ -13,9 +13,13 @@ import { FuelTypeSelector } from './FuelTypeSelector';
|
|||||||
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
||||||
import { StationPicker } from './StationPicker';
|
import { StationPicker } from './StationPicker';
|
||||||
import { CostCalculator } from './CostCalculator';
|
import { CostCalculator } from './CostCalculator';
|
||||||
|
import { ReceiptCameraButton } from './ReceiptCameraButton';
|
||||||
|
import { ReceiptOcrReviewModal } from './ReceiptOcrReviewModal';
|
||||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||||
import { useUserSettings } from '../hooks/useUserSettings';
|
import { useUserSettings } from '../hooks/useUserSettings';
|
||||||
|
import { useReceiptOcr } from '../hooks/useReceiptOcr';
|
||||||
import { useGeolocation } from '../../stations/hooks/useGeolocation';
|
import { useGeolocation } from '../../stations/hooks/useGeolocation';
|
||||||
|
import { CameraCapture } from '../../../shared/components/CameraCapture';
|
||||||
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -44,6 +48,21 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
// Get user location for nearby station search
|
// Get user location for nearby station search
|
||||||
const { coordinates: userLocation } = useGeolocation();
|
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>({
|
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@@ -115,6 +134,44 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
return 0;
|
return 0;
|
||||||
}, [distanceValue, fuelUnits]);
|
}, [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 onSubmit = async (data: CreateFuelLogRequest) => {
|
||||||
const payload: CreateFuelLogRequest = {
|
const payload: CreateFuelLogRequest = {
|
||||||
...data,
|
...data,
|
||||||
@@ -148,6 +205,24 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||||
<CardContent>
|
<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)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{/* Row 1: Select Vehicle */}
|
{/* Row 1: Select Vehicle */}
|
||||||
@@ -316,6 +391,72 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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