diff --git a/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx
new file mode 100644
index 0000000..b504074
--- /dev/null
+++ b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx
@@ -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 (
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+};
+
+/** 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(
+ 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.01 : 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 MaintenanceReceiptReviewModal: React.FC = ({
+ 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 (
+
+ );
+};
+
+export default MaintenanceReceiptReviewModal;
diff --git a/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts
new file mode 100644
index 0000000..725ff1a
--- /dev/null
+++ b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts
@@ -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(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);
+
+ 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,
+ };
+}
diff --git a/frontend/src/features/maintenance/types/maintenance-receipt.types.ts b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts
new file mode 100644
index 0000000..35c7081
--- /dev/null
+++ b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts
@@ -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;
+ acceptResult: () => MappedMaintenanceFields | null;
+ reset: () => void;
+ updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
+}