diff --git a/frontend/src/features/fuel-logs/components/FuelLogForm.tsx b/frontend/src/features/fuel-logs/components/FuelLogForm.tsx index f379a2b..051c87f 100644 --- a/frontend/src/features/fuel-logs/components/FuelLogForm.tsx +++ b/frontend/src/features/fuel-logs/components/FuelLogForm.tsx @@ -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({ 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 } /> + {/* Receipt Scan Button */} + + + +
{/* Row 1: Select Vehicle */} @@ -316,6 +391,72 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
+ + {/* Camera Capture Modal */} + + + + + {/* OCR Processing Overlay */} + theme.zIndex.drawer + 1, + flexDirection: 'column', + gap: 2, + }} + > + + Extracting receipt data... + + + {/* OCR Review Modal */} + {ocrResult && ( + + )} + + {/* OCR Error Display */} + {ocrError && ( + + + + OCR Error + + + {ocrError} + + + + + + + + )} ); }; diff --git a/frontend/src/features/fuel-logs/components/ReceiptCameraButton.tsx b/frontend/src/features/fuel-logs/components/ReceiptCameraButton.tsx new file mode 100644 index 0000000..4dad8ed --- /dev/null +++ b/frontend/src/features/fuel-logs/components/ReceiptCameraButton.tsx @@ -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 = ({ + 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 ( + + + + + + + + ); + } + + return ( + + ); +}; + +export default ReceiptCameraButton; diff --git a/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx b/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx new file mode 100644 index 0000000..f2c997e --- /dev/null +++ b/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx @@ -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 ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ); +}; + +/** 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( + 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 ( + + + {label} + + + {isEditing ? ( + + 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(); + }} + /> + + + + + + + + ) : ( + setIsEditing(true)} + role="button" + tabIndex={0} + aria-label={`Edit ${label}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsEditing(true); + } + }} + > + + {displayValue} + + {field.value !== null && } + + + + + )} + + ); +}; + +export const ReceiptOcrReviewModal: React.FC = ({ + 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 ( + + + + Receipt Data Extracted + + + + + + + + {hasLowConfidenceFields && ( + + Some fields have low confidence. Please review and edit if needed. + + )} + + + {/* Receipt thumbnail */} + {receiptImageUrl && ( + + + + + Tap to zoom + + + + )} + + {/* Extracted fields */} + + + {/* Primary fields */} + onFieldEdit('transactionDate', value)} + type="text" + formatDisplay={formatDate} + /> + onFieldEdit('fuelQuantity', value)} + type="number" + /> + onFieldEdit('pricePerUnit', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('totalAmount', value)} + type="number" + formatDisplay={formatCurrency} + /> + + {/* Secondary fields (collapsible on mobile) */} + + onFieldEdit('fuelGrade', value)} + type="text" + /> + onFieldEdit('merchantName', value)} + type="text" + /> + + + {isMobile && ( + + )} + + + + + + Tap any field to edit before saving. + + + + + + + + + + + ); +}; + +export default ReceiptOcrReviewModal; diff --git a/frontend/src/features/fuel-logs/components/ReceiptPreview.tsx b/frontend/src/features/fuel-logs/components/ReceiptPreview.tsx new file mode 100644 index 0000000..04e21b9 --- /dev/null +++ b/frontend/src/features/fuel-logs/components/ReceiptPreview.tsx @@ -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 = ({ + 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 */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleOpenZoom(); + } + }} + > + + + {/* Zoom overlay */} + + + + + + {/* Zoom dialog */} + + {/* Close button */} + + + + + {/* Full-size image */} + + + + + + ); +}; + +export default ReceiptPreview; diff --git a/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts b/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts new file mode 100644 index 0000000..a12cdba --- /dev/null +++ b/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts @@ -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; + 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 = { + '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(null); + const [receiptImageUrl, setReceiptImageUrl] = useState(null); + const [error, setError] = useState(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, + }; +}