Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m25s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 9s
Deploy to Staging / Notify Staging Ready (push) Successful in 8s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

This commit is contained in:
Eric Gullickson
2026-02-13 16:28:52 -06:00
48 changed files with 5541 additions and 31 deletions

View File

@@ -2,16 +2,19 @@
* @ai-summary Main dashboard screen component showing fleet overview
*/
import React from 'react';
import { Box } from '@mui/material';
import React, { useState } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import CloseIcon from '@mui/icons-material/Close';
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList';
import { MobileScreen } from '../../../core/store';
import { Vehicle } from '../../vehicles/types/vehicles.types';
@@ -29,6 +32,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
onViewMaintenance,
onAddVehicle
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const [showPendingReceipts, setShowPendingReceipts] = useState(false);
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
@@ -102,6 +108,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
// Main dashboard view
return (
<div className="space-y-6">
{/* Pending Receipts Banner */}
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
{/* Summary Cards */}
<SummaryCards summary={summary} />
@@ -132,6 +141,35 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
Dashboard updates every 2 minutes
</p>
</div>
{/* Pending Receipts Dialog */}
<Dialog
open={showPendingReceipts}
onClose={() => setShowPendingReceipts(false)}
fullScreen={isSmall}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Pending Receipts
<IconButton
aria-label="close"
onClick={() => setShowPendingReceipts(false)}
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: { xs: 1, sm: 2 } }}>
<PendingAssociationList />
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,33 @@
/**
* @ai-summary API calls for email ingestion pending associations
*/
import { apiClient } from '../../../core/api/client';
import type {
PendingVehicleAssociation,
PendingAssociationCount,
ResolveAssociationResult,
} from '../types/email-ingestion.types';
export const emailIngestionApi = {
getPending: async (): Promise<PendingVehicleAssociation[]> => {
const response = await apiClient.get('/email-ingestion/pending');
return response.data;
},
getPendingCount: async (): Promise<PendingAssociationCount> => {
const response = await apiClient.get('/email-ingestion/pending/count');
return response.data;
},
resolve: async (associationId: string, vehicleId: string): Promise<ResolveAssociationResult> => {
const response = await apiClient.post(`/email-ingestion/pending/${associationId}/resolve`, {
vehicleId,
});
return response.data;
},
dismiss: async (associationId: string): Promise<void> => {
await apiClient.delete(`/email-ingestion/pending/${associationId}`);
},
};

View File

@@ -0,0 +1,76 @@
/**
* @ai-summary Banner shown on dashboard when user has pending vehicle associations from emailed receipts
*/
import React from 'react';
import { Box } from '@mui/material';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { usePendingAssociationCount } from '../hooks/usePendingAssociations';
interface PendingAssociationBannerProps {
onViewPending: () => void;
}
export const PendingAssociationBanner: React.FC<PendingAssociationBannerProps> = ({ onViewPending }) => {
const { data, isLoading } = usePendingAssociationCount();
if (isLoading || !data || data.count === 0) {
return null;
}
const count = data.count;
const label = count === 1 ? '1 emailed receipt' : `${count} emailed receipts`;
return (
<GlassCard padding="md">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: { xs: 'wrap', sm: 'nowrap' },
}}
>
<Box
sx={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 3,
bgcolor: 'warning.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EmailRoundedIcon sx={{ color: 'warning.dark', fontSize: 24 }} />
</Box>
<div className="flex-1 min-w-0">
<Box
component="h4"
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.25 }}
>
Pending Receipts
</Box>
<p className="text-sm text-slate-500 dark:text-titanio">
{label} need a vehicle assigned
</p>
</div>
<Box sx={{ flexShrink: 0, width: { xs: '100%', sm: 'auto' } }}>
<Button
variant="primary"
size="sm"
onClick={onViewPending}
style={{ minHeight: 44, width: '100%' }}
>
Review
</Button>
</Box>
</Box>
</GlassCard>
);
};

View File

@@ -0,0 +1,210 @@
/**
* @ai-summary List of pending vehicle associations with receipt details and action buttons
*/
import React, { useState } from 'react';
import { Box } from '@mui/material';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { usePendingAssociations, useDismissAssociation } from '../hooks/usePendingAssociations';
import { ResolveAssociationDialog } from './ResolveAssociationDialog';
import type { PendingVehicleAssociation } from '../types/email-ingestion.types';
export const PendingAssociationList: React.FC = () => {
const { data: associations, isLoading, error } = usePendingAssociations();
const dismissMutation = useDismissAssociation();
const [resolving, setResolving] = useState<PendingVehicleAssociation | null>(null);
if (isLoading) {
return <PendingAssociationListSkeleton />;
}
if (error) {
return (
<GlassCard padding="md">
<div className="text-center py-8">
<p className="text-slate-500 dark:text-titanio">Failed to load pending receipts</p>
</div>
</GlassCard>
);
}
if (!associations || associations.length === 0) {
return (
<GlassCard padding="md">
<div className="text-center py-12">
<Box sx={{ color: 'success.main', mb: 1.5 }}>
<EmailRoundedIcon sx={{ fontSize: 48 }} />
</Box>
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
No Pending Receipts
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
All emailed receipts have been assigned to vehicles
</p>
</div>
</GlassCard>
);
}
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'Unknown date';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
};
const formatCurrency = (amount: number | null): string => {
if (amount === null || amount === undefined) return '';
return `$${amount.toFixed(2)}`;
};
return (
<>
<GlassCard padding="md">
<div className="mb-4">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
Pending Receipts
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
Assign a vehicle to each emailed receipt
</p>
</div>
<div className="space-y-3">
{associations.map((association) => {
const isFuel = association.recordType === 'fuel_log';
const IconComponent = isFuel ? LocalGasStationRoundedIcon : BuildRoundedIcon;
const typeLabel = isFuel ? 'Fuel Receipt' : 'Maintenance Receipt';
const { extractedData } = association;
const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant';
return (
<Box
key={association.id}
sx={{
p: 2,
borderRadius: 3,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
}}
>
<div className="flex items-start gap-3">
<Box
sx={{
flexShrink: 0,
color: isFuel ? 'info.main' : 'warning.main',
}}
>
<IconComponent sx={{ fontSize: 24 }} />
</Box>
<div className="flex-1 min-w-0">
<Box
component="h4"
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.5 }}
>
{merchant}
</Box>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-titanio">
<span>{typeLabel}</span>
<span>{formatDate(extractedData.date)}</span>
{extractedData.total != null && (
<span className="font-medium">{formatCurrency(extractedData.total)}</span>
)}
</div>
{isFuel && extractedData.gallons != null && (
<p className="text-xs text-slate-400 dark:text-canna mt-1">
{extractedData.gallons} gal
{extractedData.pricePerGallon != null && ` @ $${extractedData.pricePerGallon.toFixed(3)}/gal`}
</p>
)}
{!isFuel && extractedData.category && (
<p className="text-xs text-slate-400 dark:text-canna mt-1">
{extractedData.category}
{extractedData.description && ` - ${extractedData.description}`}
</p>
)}
<p className="text-xs text-slate-400 dark:text-canna mt-1">
Received {formatDate(association.createdAt)}
</p>
<div className="flex gap-2 mt-3">
<Button
variant="primary"
size="sm"
onClick={() => setResolving(association)}
style={{ minHeight: 44 }}
>
Assign Vehicle
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => dismissMutation.mutate(association.id)}
disabled={dismissMutation.isPending}
style={{ minHeight: 44 }}
>
<DeleteOutlineRoundedIcon sx={{ fontSize: 18, mr: 0.5 }} />
Dismiss
</Button>
</div>
</div>
</div>
</Box>
);
})}
</div>
</GlassCard>
{resolving && (
<ResolveAssociationDialog
open={!!resolving}
association={resolving}
onClose={() => setResolving(null)}
/>
)}
</>
);
};
const PendingAssociationListSkeleton: React.FC = () => (
<GlassCard padding="md">
<div className="mb-4">
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40 mb-2" />
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-56" />
</div>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/2" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/3" />
<div className="flex gap-2 mt-3">
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-28" />
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20" />
</div>
</div>
</div>
</div>
))}
</div>
</GlassCard>
);

View File

@@ -0,0 +1,260 @@
/**
* @ai-summary Dialog to select a vehicle and resolve a pending association from an emailed receipt
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
IconButton,
useMediaQuery,
useTheme,
CircularProgress,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { useResolveAssociation } from '../hooks/usePendingAssociations';
import type { PendingVehicleAssociation } from '../types/email-ingestion.types';
interface ResolveAssociationDialogProps {
open: boolean;
association: PendingVehicleAssociation;
onClose: () => void;
}
export const ResolveAssociationDialog: React.FC<ResolveAssociationDialogProps> = ({
open,
association,
onClose,
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
const resolveMutation = useResolveAssociation();
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const isFuel = association.recordType === 'fuel_log';
const { extractedData } = association;
const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant';
const handleResolve = () => {
if (!selectedVehicleId) return;
resolveMutation.mutate(
{ associationId: association.id, vehicleId: selectedVehicleId },
{ onSuccess: () => onClose() }
);
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return '';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
};
return (
<Dialog
open={open}
onClose={onClose}
fullScreen={isSmall}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
{isSmall && (
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
minWidth: 44,
minHeight: 44,
color: theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
)}
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isFuel ? (
<LocalGasStationRoundedIcon color="info" />
) : (
<BuildRoundedIcon color="warning" />
)}
Assign Vehicle
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Receipt summary */}
<Box
sx={{
p: 2,
bgcolor: 'action.hover',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'}
</Typography>
<Typography variant="body2" color="text.primary" fontWeight={600}>
{merchant}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}>
{extractedData.date && (
<Typography variant="body2" color="text.secondary">
{formatDate(extractedData.date)}
</Typography>
)}
{extractedData.total != null && (
<Typography variant="body2" color="text.secondary" fontWeight={500}>
${extractedData.total.toFixed(2)}
</Typography>
)}
{isFuel && extractedData.gallons != null && (
<Typography variant="body2" color="text.secondary">
{extractedData.gallons} gal
</Typography>
)}
{!isFuel && extractedData.category && (
<Typography variant="body2" color="text.secondary">
{extractedData.category}
</Typography>
)}
</Box>
</Box>
{/* Vehicle selection */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Select a vehicle
</Typography>
{vehiclesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} />
</Box>
) : !vehicles || vehicles.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No vehicles found. Add a vehicle first.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{vehicles.map((vehicle) => {
const isSelected = selectedVehicleId === vehicle.id;
const vehicleName = vehicle.nickname
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'Unnamed Vehicle';
return (
<Box
key={vehicle.id}
role="button"
tabIndex={0}
onClick={() => setSelectedVehicleId(vehicle.id)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedVehicleId(vehicle.id);
}
}}
sx={{
p: 2,
borderRadius: 2,
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
bgcolor: isSelected ? 'primary.50' : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
gap: 1.5,
minHeight: 44,
'&:hover': {
borderColor: isSelected ? 'primary.main' : 'primary.light',
bgcolor: isSelected ? 'primary.50' : 'action.hover',
},
}}
>
{isSelected && (
<CheckCircleRoundedIcon color="primary" sx={{ fontSize: 20 }} />
)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{vehicleName}
</Typography>
{vehicle.licensePlate && (
<Typography variant="caption" color="text.secondary">
{vehicle.licensePlate}
</Typography>
)}
</Box>
</Box>
);
})}
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions
sx={{
px: 3,
pb: 3,
pt: 1,
flexDirection: isSmall ? 'column' : 'row',
gap: 1,
}}
>
<Button
onClick={onClose}
variant="outlined"
fullWidth={isSmall}
sx={{ order: isSmall ? 2 : 1, minHeight: 44 }}
disabled={resolveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleResolve}
variant="contained"
color="primary"
fullWidth={isSmall}
sx={{ order: isSmall ? 1 : 2, minHeight: 44 }}
disabled={!selectedVehicleId || resolveMutation.isPending}
>
{resolveMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
'Assign & Create Record'
)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,65 @@
/**
* @ai-summary React Query hooks for pending vehicle association management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { emailIngestionApi } from '../api/email-ingestion.api';
import toast from 'react-hot-toast';
export const usePendingAssociationCount = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['pendingAssociations', 'count'],
queryFn: emailIngestionApi.getPendingCount,
enabled: isAuthenticated && !isLoading,
staleTime: 60 * 1000,
refetchInterval: 2 * 60 * 1000,
});
};
export const usePendingAssociations = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['pendingAssociations'],
queryFn: emailIngestionApi.getPending,
enabled: isAuthenticated && !isLoading,
staleTime: 30 * 1000,
});
};
export const useResolveAssociation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ associationId, vehicleId }: { associationId: string; vehicleId: string }) =>
emailIngestionApi.resolve(associationId, vehicleId),
onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] });
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
queryClient.invalidateQueries({ queryKey: ['maintenance'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success('Receipt assigned to vehicle');
},
onError: () => {
toast.error('Failed to assign receipt');
},
});
};
export const useDismissAssociation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (associationId: string) => emailIngestionApi.dismiss(associationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] });
toast.success('Receipt dismissed');
},
onError: () => {
toast.error('Failed to dismiss receipt');
},
});
};

View File

@@ -0,0 +1,10 @@
/**
* @ai-summary Email ingestion feature barrel export
*/
export { PendingAssociationBanner } from './components/PendingAssociationBanner';
export { PendingAssociationList } from './components/PendingAssociationList';
export { ResolveAssociationDialog } from './components/ResolveAssociationDialog';
export { usePendingAssociationCount, usePendingAssociations, useResolveAssociation, useDismissAssociation } from './hooks/usePendingAssociations';
export { emailIngestionApi } from './api/email-ingestion.api';
export type { PendingVehicleAssociation, ExtractedReceiptData, ResolveAssociationResult } from './types/email-ingestion.types';

View File

@@ -0,0 +1,41 @@
/**
* @ai-summary TypeScript types for email ingestion frontend feature
*/
export type EmailRecordType = 'fuel_log' | 'maintenance_record';
export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired';
export interface ExtractedReceiptData {
vendor: string | null;
date: string | null;
total: number | null;
odometerReading: number | null;
gallons: number | null;
pricePerGallon: number | null;
fuelType: string | null;
category: string | null;
subtypes: string[] | null;
shopName: string | null;
description: string | null;
}
export interface PendingVehicleAssociation {
id: string;
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
status: PendingAssociationStatus;
createdAt: string;
resolvedAt: string | null;
}
export interface PendingAssociationCount {
count: number;
}
export interface ResolveAssociationResult {
recordId: string;
recordType: EmailRecordType;
}

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Edit dialog for maintenance records
* @ai-context Mobile-friendly dialog with proper form handling
* @ai-summary Edit dialog for maintenance records with linked receipt display
* @ai-context Mobile-friendly dialog with proper form handling and receipt thumbnail/view
*/
import React, { useState, useEffect } from 'react';
@@ -19,7 +19,9 @@ import {
MenuItem,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import ReceiptIcon from '@mui/icons-material/Receipt';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
@@ -32,6 +34,7 @@ import {
} from '../types/maintenance.types';
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { documentsApi } from '../../documents/api/documents.api';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
interface MaintenanceRecordEditDialogProps {
@@ -53,7 +56,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
const vehiclesQuery = useVehicles();
const vehicles = vehiclesQuery.data;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery('(max-width:600px)');
const [receiptThumbnailUrl, setReceiptThumbnailUrl] = useState<string | null>(null);
// Reset form when record changes
useEffect(() => {
@@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
}
}, [record]);
// Load receipt thumbnail when record has a linked receipt document
useEffect(() => {
if (!record?.receiptDocument?.documentId) {
setReceiptThumbnailUrl(null);
return;
}
let revoked = false;
documentsApi.download(record.receiptDocument.documentId).then((blob) => {
if (!revoked) {
const url = URL.createObjectURL(blob);
setReceiptThumbnailUrl(url);
}
}).catch((err) => {
console.error('[MaintenanceRecordEditDialog] Failed to load receipt thumbnail:', err);
});
return () => {
revoked = true;
if (receiptThumbnailUrl) {
URL.revokeObjectURL(receiptThumbnailUrl);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.receiptDocument?.documentId]);
const handleViewReceipt = async () => {
if (!record?.receiptDocument?.documentId) return;
try {
const blob = await documentsApi.download(record.receiptDocument.documentId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Revoke after a delay to allow the new tab to load
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (err) {
console.error('[MaintenanceRecordEditDialog] Failed to open receipt:', err);
}
};
const handleInputChange = (field: keyof UpdateMaintenanceRecordRequest, value: any) => {
setFormData((prev) => ({
...prev,
@@ -182,6 +227,76 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
/>
</Grid>
{/* Linked Receipt Display */}
{record.receiptDocument && (
<Grid item xs={12}>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'center' : 'center',
gap: 2,
p: 2,
borderRadius: 1,
backgroundColor: 'action.hover',
}}
>
{receiptThumbnailUrl ? (
<Box
component="img"
src={receiptThumbnailUrl}
alt="Receipt"
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
objectFit: 'cover',
flexShrink: 0,
}}
/>
) : (
<Box
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
backgroundColor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<ReceiptIcon color="action" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0, textAlign: isMobile ? 'center' : 'left' }}>
<Typography variant="body2" fontWeight={500}>
Linked Receipt
</Typography>
{record.receiptDocument.fileName && (
<Typography variant="caption" color="text.secondary" noWrap>
{record.receiptDocument.fileName}
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<ReceiptIcon />}
onClick={handleViewReceipt}
sx={{
minHeight: 44,
width: isMobile ? '100%' : 'auto',
flexShrink: 0,
}}
>
View Receipt
</Button>
</Box>
</Grid>
)}
{/* Category */}
<Grid item xs={12}>
<FormControl fullWidth>

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Form component for creating maintenance records
* @ai-context Mobile-first responsive design with proper validation
* @ai-summary Form component for creating maintenance records with receipt OCR integration
* @ai-context Mobile-first responsive design with tier-gated receipt scanning, mirrors FuelLogForm OCR pattern
*/
import React, { useState, useEffect } from 'react';
@@ -23,6 +23,8 @@ import {
CircularProgress,
Typography,
InputAdornment,
Dialog,
Backdrop,
} from '@mui/material';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
@@ -36,6 +38,13 @@ import {
CreateMaintenanceRecordRequest,
getCategoryDisplayName,
} from '../types/maintenance.types';
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton';
import { CameraCapture } from '../../../shared/components/CameraCapture';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
import { documentsApi } from '../../documents/api/documents.api';
import toast from 'react-hot-toast';
const schema = z.object({
@@ -58,6 +67,29 @@ export const MaintenanceRecordForm: React.FC = () => {
const { createRecord, isRecordMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
// Tier access check for receipt scan feature
const { hasAccess } = useTierAccess();
const hasReceiptScanAccess = hasAccess('maintenance.receiptScan');
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
// Receipt OCR integration
const {
isCapturing,
isProcessing,
result: ocrResult,
receiptImageUrl,
error: ocrError,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset: resetOcr,
updateField,
} = useMaintenanceReceiptOcr();
// Store captured file for document upload on submit
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
const {
control,
handleSubmit,
@@ -89,8 +121,69 @@ export const MaintenanceRecordForm: React.FC = () => {
}
}, [watchedCategory, setValue]);
// Wrap processImage to also save file reference
const handleCaptureImage = async (file: File, croppedFile?: File) => {
setCapturedReceiptFile(croppedFile || file);
await processImage(file, croppedFile);
};
// Handle accepting OCR results and populating the form
const handleAcceptOcrResult = () => {
const mappedFields = acceptResult();
if (!mappedFields) return;
// Populate form fields from OCR result
if (mappedFields.category) {
setValue('category', mappedFields.category);
setSelectedCategory(mappedFields.category);
}
if (mappedFields.subtypes && mappedFields.subtypes.length > 0) {
setValue('subtypes', mappedFields.subtypes);
}
if (mappedFields.date) {
setValue('date', mappedFields.date);
}
if (mappedFields.cost !== undefined) {
setValue('cost', mappedFields.cost as any);
}
if (mappedFields.shopName) {
setValue('shop_name', mappedFields.shopName);
}
if (mappedFields.odometerReading !== undefined) {
setValue('odometer_reading', mappedFields.odometerReading as any);
}
if (mappedFields.notes) {
setValue('notes', mappedFields.notes);
}
};
// Handle retaking photo
const handleRetakePhoto = () => {
resetOcr();
setCapturedReceiptFile(null);
startCapture();
};
const onSubmit = async (data: FormData) => {
try {
let receiptDocumentId: string | undefined;
// Upload receipt as document if we have a captured file
if (capturedReceiptFile) {
try {
const doc = await documentsApi.create({
vehicleId: data.vehicle_id,
documentType: 'manual',
title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`,
});
await documentsApi.upload(doc.id, capturedReceiptFile);
receiptDocumentId = doc.id;
} catch (uploadError) {
console.error('Failed to upload receipt document:', uploadError);
toast.error('Receipt upload failed, but the record will be saved without the receipt.');
}
}
const payload: CreateMaintenanceRecordRequest = {
vehicleId: data.vehicle_id,
category: data.category as MaintenanceCategory,
@@ -100,6 +193,7 @@ export const MaintenanceRecordForm: React.FC = () => {
cost: data.cost ? Number(data.cost) : undefined,
shopName: data.shop_name || undefined,
notes: data.notes || undefined,
receiptDocumentId,
};
await createRecord(payload);
@@ -117,6 +211,7 @@ export const MaintenanceRecordForm: React.FC = () => {
notes: '',
});
setSelectedCategory(null);
setCapturedReceiptFile(null);
} catch (error) {
console.error('Failed to create maintenance record:', error);
toast.error('Failed to add maintenance record');
@@ -140,6 +235,31 @@ export const MaintenanceRecordForm: React.FC = () => {
<Card>
<CardHeader title="Add Maintenance Record" />
<CardContent>
{/* Receipt Scan Button */}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3,
pb: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReceiptCameraButton
onClick={() => {
if (!hasReceiptScanAccess) {
setShowUpgradeDialog(true);
return;
}
startCapture();
}}
disabled={isProcessing || isRecordMutating}
variant="button"
locked={!hasReceiptScanAccess}
/>
</Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Vehicle Selection */}
@@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
</form>
</CardContent>
</Card>
{/* Camera Capture Modal */}
<Dialog
open={isCapturing}
onClose={cancelCapture}
fullScreen
PaperProps={{
sx: { backgroundColor: 'black' },
}}
>
<CameraCapture
onCapture={handleCaptureImage}
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 && (
<MaintenanceReceiptReviewModal
open={!!ocrResult}
extractedFields={ocrResult.extractedFields}
receiptImageUrl={receiptImageUrl}
categorySuggestion={ocrResult.categorySuggestion}
onAccept={handleAcceptOcrResult}
onRetake={handleRetakePhoto}
onCancel={() => {
resetOcr();
setCapturedReceiptFile(null);
}}
onFieldEdit={updateField}
/>
)}
{/* Upgrade Required Dialog for Receipt Scan */}
<UpgradeRequiredDialog
featureKey="maintenance.receiptScan"
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
{/* 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();
setCapturedReceiptFile(null);
}}
variant="outlined"
>
Cancel
</Button>
</Box>
</Box>
</Dialog>
)}
</LocalizationProvider>
);
};

View File

@@ -20,7 +20,7 @@ import {
useTheme,
useMediaQuery,
} from '@mui/material';
import { Edit, Delete } from '@mui/icons-material';
import { Edit, Delete, Receipt } from '@mui/icons-material';
import {
MaintenanceRecordResponse,
getCategoryDisplayName,
@@ -136,6 +136,15 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
variant="outlined"
/>
)}
{record.receiptDocument && (
<Chip
icon={<Receipt fontSize="small" />}
label="Receipt"
size="small"
color="info"
variant="outlined"
/>
)}
</Stack>
{record.notes && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -105,6 +106,15 @@ export interface CreateMaintenanceRecordRequest {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string;
}
// Receipt document metadata returned on GET
export interface ReceiptDocumentMeta {
documentId: string;
fileName: string;
contentType: string;
storageKey: string;
}
export interface UpdateMaintenanceRecordRequest {
@@ -148,6 +158,7 @@ export interface UpdateScheduleRequest {
// Response types (camelCase)
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtypeCount: number;
receiptDocument?: ReceiptDocumentMeta | null;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {