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
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
10
frontend/src/features/email-ingestion/index.ts
Normal file
10
frontend/src/features/email-ingestion/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user