From 1bf550ae9be92fda47194c57d3347a4fafd16918 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:39:03 -0600 Subject: [PATCH] feat: add pending vehicle association resolution UI (refs #160) 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 --- backend/src/app.ts | 3 +- .../api/email-ingestion.controller.ts | 103 ++++++- .../api/email-ingestion.routes.ts | 40 ++- .../data/email-ingestion.repository.ts | 27 ++ .../domain/email-ingestion.service.ts | 61 ++++ backend/src/features/email-ingestion/index.ts | 2 +- .../dashboard/components/DashboardScreen.tsx | 42 ++- .../api/email-ingestion.api.ts | 33 +++ .../components/PendingAssociationBanner.tsx | 76 +++++ .../components/PendingAssociationList.tsx | 210 ++++++++++++++ .../components/ResolveAssociationDialog.tsx | 260 ++++++++++++++++++ .../hooks/usePendingAssociations.ts | 65 +++++ .../src/features/email-ingestion/index.ts | 10 + .../types/email-ingestion.types.ts | 41 +++ 14 files changed, 965 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/email-ingestion/api/email-ingestion.api.ts create mode 100644 frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx create mode 100644 frontend/src/features/email-ingestion/components/PendingAssociationList.tsx create mode 100644 frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx create mode 100644 frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts create mode 100644 frontend/src/features/email-ingestion/index.ts create mode 100644 frontend/src/features/email-ingestion/types/email-ingestion.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index b2504e8..7e1cf84 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -35,7 +35,7 @@ import { userImportRoutes } from './features/user-import'; import { ownershipCostsRoutes } from './features/ownership-costs'; import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions'; import { ocrRoutes } from './features/ocr'; -import { emailIngestionWebhookRoutes } from './features/email-ingestion'; +import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion'; import { pool } from './core/config/database'; import { configRoutes } from './core/config/config.routes'; @@ -154,6 +154,7 @@ async function buildApp(): Promise { await app.register(donationsRoutes, { prefix: '/api' }); await app.register(webhooksRoutes, { prefix: '/api' }); await app.register(emailIngestionWebhookRoutes, { prefix: '/api' }); + await app.register(emailIngestionRoutes, { prefix: '/api' }); await app.register(ocrRoutes, { prefix: '/api' }); await app.register(configRoutes, { prefix: '/api' }); diff --git a/backend/src/features/email-ingestion/api/email-ingestion.controller.ts b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts index 1a4247e..5169b37 100644 --- a/backend/src/features/email-ingestion/api/email-ingestion.controller.ts +++ b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts @@ -1,6 +1,6 @@ /** - * @ai-summary Controller for Resend inbound email webhook - * @ai-context Verifies signatures, checks idempotency, queues emails for async processing + * @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints + * @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated) */ import { FastifyRequest, FastifyReply } from 'fastify'; @@ -21,6 +21,105 @@ export class EmailIngestionController { this.service = new EmailIngestionService(); } + // ======================== + // Pending Association Endpoints (JWT-authenticated) + // ======================== + + async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + const associations = await this.repository.getPendingAssociations(userId); + return reply.code(200).send(associations); + } catch (error: any) { + logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub }); + return reply.code(500).send({ error: 'Failed to list pending associations' }); + } + } + + async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + const count = await this.repository.getPendingAssociationCount(userId); + return reply.code(200).send({ count }); + } catch (error: any) { + logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub }); + return reply.code(500).send({ error: 'Failed to count pending associations' }); + } + } + + async resolveAssociation( + request: FastifyRequest<{ Params: { id: string }; Body: { vehicleId: string } }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { id } = request.params; + const { vehicleId } = request.body; + + if (!vehicleId || typeof vehicleId !== 'string') { + return reply.code(400).send({ error: 'vehicleId is required' }); + } + + const result = await this.service.resolveAssociation(id, vehicleId, userId); + return reply.code(200).send(result); + } catch (error: any) { + const userId = (request as any).user?.sub; + logger.error('Error resolving pending association', { + error: error.message, + associationId: request.params.id, + userId, + }); + + if (error.message === 'Pending association not found' || error.message === 'Vehicle not found') { + return reply.code(404).send({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return reply.code(403).send({ error: 'Not authorized' }); + } + if (error.message === 'Association already resolved') { + return reply.code(409).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to resolve association' }); + } + } + + async dismissAssociation( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { id } = request.params; + + await this.service.dismissAssociation(id, userId); + return reply.code(204).send(); + } catch (error: any) { + const userId = (request as any).user?.sub; + logger.error('Error dismissing pending association', { + error: error.message, + associationId: request.params.id, + userId, + }); + + if (error.message === 'Pending association not found') { + return reply.code(404).send({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return reply.code(403).send({ error: 'Not authorized' }); + } + if (error.message === 'Association already resolved') { + return reply.code(409).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to dismiss association' }); + } + } + + // ======================== + // Webhook Endpoint (Public) + // ======================== + async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise { try { const rawBody = (request as any).rawBody; diff --git a/backend/src/features/email-ingestion/api/email-ingestion.routes.ts b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts index 6c58683..9d608f0 100644 --- a/backend/src/features/email-ingestion/api/email-ingestion.routes.ts +++ b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts @@ -1,11 +1,12 @@ /** - * @ai-summary Resend inbound webhook route registration - * @ai-context Public endpoint (no JWT auth) with rawBody for signature verification + * @ai-summary Resend inbound webhook + user-facing pending association routes + * @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations */ import { FastifyPluginAsync } from 'fastify'; import { EmailIngestionController } from './email-ingestion.controller'; +/** Public webhook route - no JWT auth, uses Svix signature verification */ export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => { const controller = new EmailIngestionController(); @@ -22,3 +23,38 @@ export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) = controller.handleInboundWebhook.bind(controller) ); }; + +/** Authenticated user-facing routes for pending vehicle associations */ +export const emailIngestionRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new EmailIngestionController(); + + // GET /api/email-ingestion/pending - List pending associations for authenticated user + fastify.get('/email-ingestion/pending', { + preHandler: [fastify.authenticate], + handler: controller.getPendingAssociations.bind(controller), + }); + + // GET /api/email-ingestion/pending/count - Get count of pending associations + fastify.get('/email-ingestion/pending/count', { + preHandler: [fastify.authenticate], + handler: controller.getPendingAssociationCount.bind(controller), + }); + + // POST /api/email-ingestion/pending/:id/resolve - Resolve by selecting vehicle + fastify.post<{ Params: { id: string }; Body: { vehicleId: string } }>( + '/email-ingestion/pending/:id/resolve', + { + preHandler: [fastify.authenticate], + handler: controller.resolveAssociation.bind(controller), + } + ); + + // DELETE /api/email-ingestion/pending/:id - Dismiss/discard a pending association + fastify.delete<{ Params: { id: string } }>( + '/email-ingestion/pending/:id', + { + preHandler: [fastify.authenticate], + handler: controller.dismissAssociation.bind(controller), + } + ); +}; diff --git a/backend/src/features/email-ingestion/data/email-ingestion.repository.ts b/backend/src/features/email-ingestion/data/email-ingestion.repository.ts index 4ea1bb0..b91b58c 100644 --- a/backend/src/features/email-ingestion/data/email-ingestion.repository.ts +++ b/backend/src/features/email-ingestion/data/email-ingestion.repository.ts @@ -194,6 +194,33 @@ export class EmailIngestionRepository { } } + async getPendingAssociationById(associationId: string): Promise { + try { + const res = await this.db.query( + `SELECT * FROM pending_vehicle_associations WHERE id = $1`, + [associationId] + ); + return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null; + } catch (error) { + logger.error('Error fetching pending association by id', { error, associationId }); + throw error; + } + } + + async getPendingAssociationCount(userId: string): Promise { + try { + const res = await this.db.query( + `SELECT COUNT(*)::int AS count FROM pending_vehicle_associations + WHERE user_id = $1 AND status = 'pending'`, + [userId] + ); + return res.rows[0]?.count ?? 0; + } catch (error) { + logger.error('Error counting pending associations', { error, userId }); + throw error; + } + } + async getPendingAssociations(userId: string): Promise { try { const res = await this.db.query( diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts index f2d66a9..4cae7ba 100644 --- a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts +++ b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts @@ -542,6 +542,67 @@ export class EmailIngestionService { }; } + // ======================== + // Public Resolution API + // ======================== + + /** + * Resolve a pending vehicle association by creating the record with the selected vehicle. + * Called from the user-facing API when a multi-vehicle user picks a vehicle. + */ + async resolveAssociation( + associationId: string, + vehicleId: string, + userId: string + ): Promise<{ recordId: string; recordType: EmailRecordType }> { + const association = await this.repository.getPendingAssociationById(associationId); + if (!association) { + throw new Error('Pending association not found'); + } + if (association.userId !== userId) { + throw new Error('Unauthorized'); + } + if (association.status !== 'pending') { + throw new Error('Association already resolved'); + } + + // Verify vehicle belongs to user + const vehicles = await this.vehiclesRepository.findByUserId(userId); + const vehicle = vehicles.find(v => v.id === vehicleId); + if (!vehicle) { + throw new Error('Vehicle not found'); + } + + // Create the record + const recordId = await this.createRecord(userId, vehicleId, association.recordType, association.extractedData); + + // Mark as resolved + await this.repository.resolvePendingAssociation(associationId, 'resolved'); + + logger.info('Pending association resolved', { associationId, vehicleId, userId, recordType: association.recordType, recordId }); + + return { recordId, recordType: association.recordType }; + } + + /** + * Dismiss a pending vehicle association without creating a record. + */ + async dismissAssociation(associationId: string, userId: string): Promise { + const association = await this.repository.getPendingAssociationById(associationId); + if (!association) { + throw new Error('Pending association not found'); + } + if (association.userId !== userId) { + throw new Error('Unauthorized'); + } + if (association.status !== 'pending') { + throw new Error('Association already resolved'); + } + + await this.repository.resolvePendingAssociation(associationId, 'expired'); + logger.info('Pending association dismissed', { associationId, userId }); + } + // ======================== // Record Creation // ======================== diff --git a/backend/src/features/email-ingestion/index.ts b/backend/src/features/email-ingestion/index.ts index d7781d2..305ce0b 100644 --- a/backend/src/features/email-ingestion/index.ts +++ b/backend/src/features/email-ingestion/index.ts @@ -3,7 +3,7 @@ * @ai-context Exports webhook routes, services, and types for Resend inbound email processing */ -export { emailIngestionWebhookRoutes } from './api/email-ingestion.routes'; +export { emailIngestionWebhookRoutes, emailIngestionRoutes } from './api/email-ingestion.routes'; export { EmailIngestionService } from './domain/email-ingestion.service'; export { EmailIngestionRepository } from './data/email-ingestion.repository'; export { ReceiptClassifier } from './domain/receipt-classifier'; diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index ae7d827..e9444c4 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -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 = ({ 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 = ({ // Main dashboard view return (
+ {/* Pending Receipts Banner */} + setShowPendingReceipts(true)} /> + {/* Summary Cards */} @@ -132,6 +141,35 @@ export const DashboardScreen: React.FC = ({ Dashboard updates every 2 minutes

+ + {/* Pending Receipts Dialog */} + setShowPendingReceipts(false)} + fullScreen={isSmall} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + maxHeight: isSmall ? '100%' : '90vh', + m: isSmall ? 0 : 2, + }, + }} + > + + Pending Receipts + setShowPendingReceipts(false)} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + + + + + ); }; diff --git a/frontend/src/features/email-ingestion/api/email-ingestion.api.ts b/frontend/src/features/email-ingestion/api/email-ingestion.api.ts new file mode 100644 index 0000000..fac197f --- /dev/null +++ b/frontend/src/features/email-ingestion/api/email-ingestion.api.ts @@ -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 => { + const response = await apiClient.get('/email-ingestion/pending'); + return response.data; + }, + + getPendingCount: async (): Promise => { + const response = await apiClient.get('/email-ingestion/pending/count'); + return response.data; + }, + + resolve: async (associationId: string, vehicleId: string): Promise => { + const response = await apiClient.post(`/email-ingestion/pending/${associationId}/resolve`, { + vehicleId, + }); + return response.data; + }, + + dismiss: async (associationId: string): Promise => { + await apiClient.delete(`/email-ingestion/pending/${associationId}`); + }, +}; diff --git a/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx b/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx new file mode 100644 index 0000000..38aaa34 --- /dev/null +++ b/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx @@ -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 = ({ 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 ( + + + + + + +
+ + Pending Receipts + +

+ {label} need a vehicle assigned +

+
+ + + + +
+
+ ); +}; diff --git a/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx b/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx new file mode 100644 index 0000000..68c9659 --- /dev/null +++ b/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx @@ -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(null); + + if (isLoading) { + return ; + } + + if (error) { + return ( + +
+

Failed to load pending receipts

+
+
+ ); + } + + if (!associations || associations.length === 0) { + return ( + +
+ + + +

+ No Pending Receipts +

+

+ All emailed receipts have been assigned to vehicles +

+
+
+ ); + } + + 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 ( + <> + +
+

+ Pending Receipts +

+

+ Assign a vehicle to each emailed receipt +

+
+ +
+ {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 ( + +
+ + + + +
+ + {merchant} + + +
+ {typeLabel} + {formatDate(extractedData.date)} + {extractedData.total != null && ( + {formatCurrency(extractedData.total)} + )} +
+ + {isFuel && extractedData.gallons != null && ( +

+ {extractedData.gallons} gal + {extractedData.pricePerGallon != null && ` @ $${extractedData.pricePerGallon.toFixed(3)}/gal`} +

+ )} + {!isFuel && extractedData.category && ( +

+ {extractedData.category} + {extractedData.description && ` - ${extractedData.description}`} +

+ )} + +

+ Received {formatDate(association.createdAt)} +

+ +
+ + +
+
+
+
+ ); + })} +
+
+ + {resolving && ( + setResolving(null)} + /> + )} + + ); +}; + +const PendingAssociationListSkeleton: React.FC = () => ( + +
+
+
+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ +); diff --git a/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx new file mode 100644 index 0000000..0c6425a --- /dev/null +++ b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx @@ -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 = ({ + 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(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 ( + + {isSmall && ( + + + + )} + + + {isFuel ? ( + + ) : ( + + )} + Assign Vehicle + + + + + {/* Receipt summary */} + + + {isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'} + + + {merchant} + + + {extractedData.date && ( + + {formatDate(extractedData.date)} + + )} + {extractedData.total != null && ( + + ${extractedData.total.toFixed(2)} + + )} + {isFuel && extractedData.gallons != null && ( + + {extractedData.gallons} gal + + )} + {!isFuel && extractedData.category && ( + + {extractedData.category} + + )} + + + + {/* Vehicle selection */} + + + Select a vehicle + + + {vehiclesLoading ? ( + + + + ) : !vehicles || vehicles.length === 0 ? ( + + No vehicles found. Add a vehicle first. + + ) : ( + + {vehicles.map((vehicle) => { + const isSelected = selectedVehicleId === vehicle.id; + const vehicleName = vehicle.nickname + || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ') + || 'Unnamed Vehicle'; + + return ( + 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 && ( + + )} + + + {vehicleName} + + {vehicle.licensePlate && ( + + {vehicle.licensePlate} + + )} + + + ); + })} + + )} + + + + + + + + + + ); +}; diff --git a/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts b/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts new file mode 100644 index 0000000..4451e18 --- /dev/null +++ b/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts @@ -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'); + }, + }); +}; diff --git a/frontend/src/features/email-ingestion/index.ts b/frontend/src/features/email-ingestion/index.ts new file mode 100644 index 0000000..0d0f2f5 --- /dev/null +++ b/frontend/src/features/email-ingestion/index.ts @@ -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'; diff --git a/frontend/src/features/email-ingestion/types/email-ingestion.types.ts b/frontend/src/features/email-ingestion/types/email-ingestion.types.ts new file mode 100644 index 0000000..bfb59e3 --- /dev/null +++ b/frontend/src/features/email-ingestion/types/email-ingestion.types.ts @@ -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; +}