feat: add maintenance receipt OCR hook and review modal (refs #152)
Add useMaintenanceReceiptOcr hook mirroring fuel receipt OCR pattern, MaintenanceReceiptReviewModal with confidence indicators and inline editing, and maintenance-receipt.types.ts for extraction field types. Includes category/subtype suggestion via keyword matching from service descriptions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Modal for reviewing and editing OCR-extracted maintenance receipt fields
|
||||||
|
* @ai-context Mirrors ReceiptOcrReviewModal: confidence indicators, inline editing, category suggestion display
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Grid,
|
||||||
|
useTheme,
|
||||||
|
useMediaQuery,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
Alert,
|
||||||
|
Chip,
|
||||||
|
} 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 BuildIcon from '@mui/icons-material/Build';
|
||||||
|
import {
|
||||||
|
ExtractedMaintenanceReceiptFields,
|
||||||
|
ExtractedMaintenanceField,
|
||||||
|
CategorySuggestion,
|
||||||
|
} from '../types/maintenance-receipt.types';
|
||||||
|
import { LOW_CONFIDENCE_THRESHOLD } from '../hooks/useMaintenanceReceiptOcr';
|
||||||
|
import { getCategoryDisplayName } from '../types/maintenance.types';
|
||||||
|
import { ReceiptPreview } from '../../fuel-logs/components/ReceiptPreview';
|
||||||
|
|
||||||
|
export interface MaintenanceReceiptReviewModalProps {
|
||||||
|
open: boolean;
|
||||||
|
extractedFields: ExtractedMaintenanceReceiptFields;
|
||||||
|
receiptImageUrl: string | null;
|
||||||
|
categorySuggestion: CategorySuggestion | null;
|
||||||
|
onAccept: () => void;
|
||||||
|
onRetake: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onFieldEdit: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confidence indicator component (4-dot system) */
|
||||||
|
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: ExtractedMaintenanceField;
|
||||||
|
onEdit: (value: string | number | null) => void;
|
||||||
|
type?: 'text' | 'number';
|
||||||
|
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.01 : 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 MaintenanceReceiptReviewModal: React.FC<MaintenanceReceiptReviewModalProps> = ({
|
||||||
|
open,
|
||||||
|
extractedFields,
|
||||||
|
receiptImageUrl,
|
||||||
|
categorySuggestion,
|
||||||
|
onAccept,
|
||||||
|
onRetake,
|
||||||
|
onCancel,
|
||||||
|
onFieldEdit,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const [showAllFields, setShowAllFields] = useState(false);
|
||||||
|
|
||||||
|
const hasLowConfidenceFields = Object.values(extractedFields).some(
|
||||||
|
(field) => field.value !== null && field.confidence < LOW_CONFIDENCE_THRESHOLD
|
||||||
|
);
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string | number | null): string => {
|
||||||
|
if (value === null) return '-';
|
||||||
|
const dateStr = String(value);
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
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">
|
||||||
|
Maintenance Receipt 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="Service"
|
||||||
|
field={extractedFields.serviceName}
|
||||||
|
onEdit={(value) => onFieldEdit('serviceName', value)}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Date"
|
||||||
|
field={extractedFields.serviceDate}
|
||||||
|
onEdit={(value) => onFieldEdit('serviceDate', value)}
|
||||||
|
formatDisplay={formatDate}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Total Cost"
|
||||||
|
field={extractedFields.totalCost}
|
||||||
|
onEdit={(value) => onFieldEdit('totalCost', value)}
|
||||||
|
type="number"
|
||||||
|
formatDisplay={formatCurrency}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Shop"
|
||||||
|
field={extractedFields.shopName}
|
||||||
|
onEdit={(value) => onFieldEdit('shopName', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category suggestion */}
|
||||||
|
{categorySuggestion && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 1,
|
||||||
|
px: 1,
|
||||||
|
gap: 1,
|
||||||
|
backgroundColor: 'success.light',
|
||||||
|
borderRadius: 1,
|
||||||
|
my: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BuildIcon fontSize="small" color="success" />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{getCategoryDisplayName(categorySuggestion.category)}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||||
|
{categorySuggestion.subtypes.map((subtype) => (
|
||||||
|
<Chip
|
||||||
|
key={subtype}
|
||||||
|
label={subtype}
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary fields (collapsible on mobile) */}
|
||||||
|
<Collapse in={!isMobile || showAllFields}>
|
||||||
|
<FieldRow
|
||||||
|
label="Odometer"
|
||||||
|
field={extractedFields.odometerReading}
|
||||||
|
onEdit={(value) => onFieldEdit('odometerReading', value)}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Labor"
|
||||||
|
field={extractedFields.laborCost}
|
||||||
|
onEdit={(value) => onFieldEdit('laborCost', value)}
|
||||||
|
type="number"
|
||||||
|
formatDisplay={formatCurrency}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Parts"
|
||||||
|
field={extractedFields.partsCost}
|
||||||
|
onEdit={(value) => onFieldEdit('partsCost', value)}
|
||||||
|
type="number"
|
||||||
|
formatDisplay={formatCurrency}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
label="Vehicle"
|
||||||
|
field={extractedFields.vehicleInfo}
|
||||||
|
onEdit={(value) => onFieldEdit('vehicleInfo', value)}
|
||||||
|
/>
|
||||||
|
</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, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Retake Photo
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ flex: 1, display: isMobile ? 'none' : 'block' }} />
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
sx={{ order: isMobile ? 3 : 2, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onAccept}
|
||||||
|
startIcon={<CheckIcon />}
|
||||||
|
sx={{ order: isMobile ? 1 : 3, width: isMobile ? '100%' : 'auto', minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaintenanceReceiptReviewModal;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Hook to orchestrate maintenance receipt OCR extraction
|
||||||
|
* @ai-context Mirrors useReceiptOcr pattern: capture -> OCR -> category suggestion -> review -> accept
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { apiClient } from '../../../core/api/client';
|
||||||
|
import {
|
||||||
|
ExtractedMaintenanceReceiptFields,
|
||||||
|
ExtractedMaintenanceField,
|
||||||
|
MappedMaintenanceFields,
|
||||||
|
MaintenanceReceiptOcrResult,
|
||||||
|
CategorySuggestion,
|
||||||
|
UseMaintenanceReceiptOcrReturn,
|
||||||
|
} from '../types/maintenance-receipt.types';
|
||||||
|
import { MaintenanceCategory } from '../types/maintenance.types';
|
||||||
|
|
||||||
|
/** Confidence threshold for highlighting low-confidence fields */
|
||||||
|
export const LOW_CONFIDENCE_THRESHOLD = 0.7;
|
||||||
|
|
||||||
|
/** Keyword-to-category/subtype mapping for service name suggestion */
|
||||||
|
const SERVICE_KEYWORD_MAP: Array<{
|
||||||
|
keywords: string[];
|
||||||
|
category: MaintenanceCategory;
|
||||||
|
subtypes: string[];
|
||||||
|
}> = [
|
||||||
|
// Routine maintenance mappings
|
||||||
|
{ keywords: ['oil change', 'oil filter', 'engine oil', 'synthetic oil', 'conventional oil', 'oil & filter'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Engine Oil'] },
|
||||||
|
{ keywords: ['tire rotation', 'tire balance', 'wheel balance', 'tire alignment', 'alignment'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Tires'] },
|
||||||
|
{ keywords: ['brake pad', 'brake rotor', 'brake fluid', 'brake inspection', 'brake service', 'brakes'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Brakes and Traction Control'] },
|
||||||
|
{ keywords: ['air filter', 'engine air filter'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Air Filter Element'] },
|
||||||
|
{ keywords: ['cabin filter', 'cabin air', 'a/c filter'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Cabin Air Filter / Purifier'] },
|
||||||
|
{ keywords: ['spark plug', 'ignition'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Spark Plug'] },
|
||||||
|
{ keywords: ['coolant', 'antifreeze', 'radiator flush', 'cooling system'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Coolant'] },
|
||||||
|
{ keywords: ['transmission fluid', 'trans fluid', 'atf'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Fluid - A/T'] },
|
||||||
|
{ keywords: ['differential fluid', 'diff fluid'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Fluid - Differential'] },
|
||||||
|
{ keywords: ['wiper blade', 'wiper', 'windshield wiper'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Wiper Blade'] },
|
||||||
|
{ keywords: ['washer fluid', 'windshield washer'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Washer Fluid'] },
|
||||||
|
{ keywords: ['drive belt', 'serpentine belt', 'timing belt', 'belt replacement'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Drive Belt'] },
|
||||||
|
{ keywords: ['exhaust', 'muffler', 'catalytic'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Exhaust System'] },
|
||||||
|
{ keywords: ['suspension', 'shock', 'strut', 'ball joint', 'tie rod'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Steering and Suspension'] },
|
||||||
|
{ keywords: ['fuel filter', 'fuel injection', 'fuel system', 'fuel delivery'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Fuel Delivery and Air Induction'] },
|
||||||
|
{ keywords: ['parking brake', 'e-brake', 'emergency brake'],
|
||||||
|
category: 'routine_maintenance', subtypes: ['Parking Brake System'] },
|
||||||
|
// Repair mappings
|
||||||
|
{ keywords: ['engine repair', 'engine rebuild', 'head gasket', 'valve cover'],
|
||||||
|
category: 'repair', subtypes: ['Engine'] },
|
||||||
|
{ keywords: ['transmission repair', 'trans rebuild'],
|
||||||
|
category: 'repair', subtypes: ['Transmission'] },
|
||||||
|
{ keywords: ['axle', 'cv joint', 'driveshaft', 'drivetrain'],
|
||||||
|
category: 'repair', subtypes: ['Drivetrain'] },
|
||||||
|
{ keywords: ['body work', 'dent', 'paint', 'bumper', 'fender'],
|
||||||
|
category: 'repair', subtypes: ['Exterior'] },
|
||||||
|
{ keywords: ['upholstery', 'dashboard', 'seat repair', 'interior repair'],
|
||||||
|
category: 'repair', subtypes: ['Interior'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Suggest category and subtypes from service name using keyword matching */
|
||||||
|
function suggestCategory(serviceName: string | number | null): CategorySuggestion | null {
|
||||||
|
if (!serviceName) return null;
|
||||||
|
const normalized = String(serviceName).toLowerCase().trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
for (const mapping of SERVICE_KEYWORD_MAP) {
|
||||||
|
for (const keyword of mapping.keywords) {
|
||||||
|
if (normalized.includes(keyword)) {
|
||||||
|
return {
|
||||||
|
category: mapping.category,
|
||||||
|
subtypes: mapping.subtypes,
|
||||||
|
confidence: 0.8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found - default to routine_maintenance with no subtypes
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse date string to YYYY-MM-DD format */
|
||||||
|
function parseServiceDate(value: string | number | null): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const dateStr = String(value);
|
||||||
|
|
||||||
|
// Already in YYYY-MM-DD format
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
if (!isNaN(date.getTime())) return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try standard parsing
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const cleaned = value.replace(/[$,\s]/g, '');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? undefined : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract maintenance receipt data from image via OCR service */
|
||||||
|
async function extractMaintenanceReceiptFromImage(file: File): Promise<{
|
||||||
|
extractedFields: ExtractedMaintenanceReceiptFields;
|
||||||
|
rawText: string;
|
||||||
|
confidence: number;
|
||||||
|
}> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post('/ocr/extract/maintenance-receipt', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('Maintenance receipt OCR extraction failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = data.extractedFields || {};
|
||||||
|
|
||||||
|
const makeField = (key: string): ExtractedMaintenanceField => ({
|
||||||
|
value: fields[key]?.value ?? null,
|
||||||
|
confidence: fields[key]?.confidence ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractedFields: ExtractedMaintenanceReceiptFields = {
|
||||||
|
serviceName: makeField('serviceName'),
|
||||||
|
serviceDate: makeField('serviceDate'),
|
||||||
|
totalCost: makeField('totalCost'),
|
||||||
|
shopName: makeField('shopName'),
|
||||||
|
laborCost: makeField('laborCost'),
|
||||||
|
partsCost: makeField('partsCost'),
|
||||||
|
odometerReading: makeField('odometerReading'),
|
||||||
|
vehicleInfo: makeField('vehicleInfo'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
extractedFields,
|
||||||
|
rawText: data.rawText || '',
|
||||||
|
confidence: data.confidence || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map extracted fields to maintenance record form fields */
|
||||||
|
function mapFieldsToMaintenanceRecord(
|
||||||
|
fields: ExtractedMaintenanceReceiptFields,
|
||||||
|
categorySuggestion: CategorySuggestion | null
|
||||||
|
): MappedMaintenanceFields {
|
||||||
|
// Build notes from supplementary fields
|
||||||
|
const noteParts: string[] = [];
|
||||||
|
if (fields.laborCost.value !== null) {
|
||||||
|
noteParts.push(`Labor: $${parseNumber(fields.laborCost.value)?.toFixed(2) ?? fields.laborCost.value}`);
|
||||||
|
}
|
||||||
|
if (fields.partsCost.value !== null) {
|
||||||
|
noteParts.push(`Parts: $${parseNumber(fields.partsCost.value)?.toFixed(2) ?? fields.partsCost.value}`);
|
||||||
|
}
|
||||||
|
if (fields.vehicleInfo.value !== null) {
|
||||||
|
noteParts.push(`Vehicle: ${fields.vehicleInfo.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: parseServiceDate(fields.serviceDate.value),
|
||||||
|
cost: parseNumber(fields.totalCost.value),
|
||||||
|
shopName: fields.shopName.value ? String(fields.shopName.value) : undefined,
|
||||||
|
odometerReading: parseNumber(fields.odometerReading.value),
|
||||||
|
category: categorySuggestion?.category,
|
||||||
|
subtypes: categorySuggestion?.subtypes,
|
||||||
|
notes: noteParts.length > 0 ? noteParts.join(' | ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to orchestrate maintenance receipt photo capture and OCR extraction.
|
||||||
|
* Mirrors useReceiptOcr pattern: startCapture -> processImage -> review -> acceptResult
|
||||||
|
*/
|
||||||
|
export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn {
|
||||||
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [result, setResult] = useState<MaintenanceReceiptOcrResult | 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);
|
||||||
|
|
||||||
|
const imageToProcess = croppedFile || file;
|
||||||
|
const imageUrl = URL.createObjectURL(imageToProcess);
|
||||||
|
setReceiptImageUrl(imageUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { extractedFields, rawText, confidence } = await extractMaintenanceReceiptFromImage(imageToProcess);
|
||||||
|
|
||||||
|
const categorySuggestion = suggestCategory(extractedFields.serviceName.value);
|
||||||
|
const mappedFields = mapFieldsToMaintenanceRecord(extractedFields, categorySuggestion);
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
extractedFields,
|
||||||
|
mappedFields,
|
||||||
|
categorySuggestion,
|
||||||
|
rawText,
|
||||||
|
overallConfidence: confidence,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Maintenance receipt OCR processing failed:', err);
|
||||||
|
const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image';
|
||||||
|
setError(message);
|
||||||
|
URL.revokeObjectURL(imageUrl);
|
||||||
|
setReceiptImageUrl(null);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateField = useCallback((
|
||||||
|
fieldName: keyof ExtractedMaintenanceReceiptFields,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-run category suggestion if service name was edited
|
||||||
|
const categorySuggestion = fieldName === 'serviceName'
|
||||||
|
? suggestCategory(value)
|
||||||
|
: prev.categorySuggestion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
extractedFields: updatedFields,
|
||||||
|
categorySuggestion,
|
||||||
|
mappedFields: mapFieldsToMaintenanceRecord(updatedFields, categorySuggestion),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const acceptResult = useCallback(() => {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const mappedFields = result.mappedFields;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Type definitions for maintenance receipt OCR extraction
|
||||||
|
* @ai-context Mirrors fuel-logs ExtractedReceiptField pattern; maps OCR fields to maintenance record form values
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MaintenanceCategory } from './maintenance.types';
|
||||||
|
|
||||||
|
/** OCR-extracted field with confidence score */
|
||||||
|
export interface ExtractedMaintenanceField {
|
||||||
|
value: string | number | null;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields extracted from a maintenance receipt via OCR */
|
||||||
|
export interface ExtractedMaintenanceReceiptFields {
|
||||||
|
serviceName: ExtractedMaintenanceField;
|
||||||
|
serviceDate: ExtractedMaintenanceField;
|
||||||
|
totalCost: ExtractedMaintenanceField;
|
||||||
|
shopName: ExtractedMaintenanceField;
|
||||||
|
laborCost: ExtractedMaintenanceField;
|
||||||
|
partsCost: ExtractedMaintenanceField;
|
||||||
|
odometerReading: ExtractedMaintenanceField;
|
||||||
|
vehicleInfo: ExtractedMaintenanceField;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggested category and subtypes from service name keyword matching */
|
||||||
|
export interface CategorySuggestion {
|
||||||
|
category: MaintenanceCategory;
|
||||||
|
subtypes: string[];
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mapped fields ready for maintenance record form population */
|
||||||
|
export interface MappedMaintenanceFields {
|
||||||
|
date?: string;
|
||||||
|
cost?: number;
|
||||||
|
shopName?: string;
|
||||||
|
odometerReading?: number;
|
||||||
|
category?: MaintenanceCategory;
|
||||||
|
subtypes?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maintenance receipt OCR result */
|
||||||
|
export interface MaintenanceReceiptOcrResult {
|
||||||
|
extractedFields: ExtractedMaintenanceReceiptFields;
|
||||||
|
mappedFields: MappedMaintenanceFields;
|
||||||
|
categorySuggestion: CategorySuggestion | null;
|
||||||
|
rawText: string;
|
||||||
|
overallConfidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook state */
|
||||||
|
export interface UseMaintenanceReceiptOcrState {
|
||||||
|
isCapturing: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
result: MaintenanceReceiptOcrResult | null;
|
||||||
|
receiptImageUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook return type */
|
||||||
|
export interface UseMaintenanceReceiptOcrReturn extends UseMaintenanceReceiptOcrState {
|
||||||
|
startCapture: () => void;
|
||||||
|
cancelCapture: () => void;
|
||||||
|
processImage: (file: File, croppedFile?: File) => Promise<void>;
|
||||||
|
acceptResult: () => MappedMaintenanceFields | null;
|
||||||
|
reset: () => void;
|
||||||
|
updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user