feat: Maintenance Receipt Upload with OCR Auto-populate (#16) #161
@@ -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<FastifyInstance> {
|
||||
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' });
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const rawBody = (request as any).rawBody;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -194,6 +194,33 @@ export class EmailIngestionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingAssociationById(associationId: string): Promise<PendingVehicleAssociation | null> {
|
||||
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<number> {
|
||||
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<PendingVehicleAssociation[]> {
|
||||
try {
|
||||
const res = await this.db.query(
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
// ========================
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user