feat: add pending vehicle association resolution UI (refs #160)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 8m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

Backend: Add authenticated endpoints for pending association CRUD
(GET/POST/DELETE /api/email-ingestion/pending). Service methods for
resolving (creates fuel/maintenance record) and dismissing associations.

Frontend: New email-ingestion feature with types, API client, hooks,
PendingAssociationBanner (dashboard), PendingAssociationList, and
ResolveAssociationDialog. Mobile-first responsive with 44px touch
targets and full-screen dialogs on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 09:39:03 -06:00
parent 8bcac80818
commit 1bf550ae9b
14 changed files with 965 additions and 8 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;
}