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
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:
@@ -35,7 +35,7 @@ import { userImportRoutes } from './features/user-import';
|
|||||||
import { ownershipCostsRoutes } from './features/ownership-costs';
|
import { ownershipCostsRoutes } from './features/ownership-costs';
|
||||||
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
|
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
|
||||||
import { ocrRoutes } from './features/ocr';
|
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 { pool } from './core/config/database';
|
||||||
import { configRoutes } from './core/config/config.routes';
|
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(donationsRoutes, { prefix: '/api' });
|
||||||
await app.register(webhooksRoutes, { prefix: '/api' });
|
await app.register(webhooksRoutes, { prefix: '/api' });
|
||||||
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
|
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
|
||||||
|
await app.register(emailIngestionRoutes, { prefix: '/api' });
|
||||||
await app.register(ocrRoutes, { prefix: '/api' });
|
await app.register(ocrRoutes, { prefix: '/api' });
|
||||||
await app.register(configRoutes, { prefix: '/api' });
|
await app.register(configRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Controller for Resend inbound email webhook
|
* @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints
|
||||||
* @ai-context Verifies signatures, checks idempotency, queues emails for async processing
|
* @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
@@ -21,6 +21,105 @@ export class EmailIngestionController {
|
|||||||
this.service = new EmailIngestionService();
|
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> {
|
async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rawBody = (request as any).rawBody;
|
const rawBody = (request as any).rawBody;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Resend inbound webhook route registration
|
* @ai-summary Resend inbound webhook + user-facing pending association routes
|
||||||
* @ai-context Public endpoint (no JWT auth) with rawBody for signature verification
|
* @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import { EmailIngestionController } from './email-ingestion.controller';
|
import { EmailIngestionController } from './email-ingestion.controller';
|
||||||
|
|
||||||
|
/** Public webhook route - no JWT auth, uses Svix signature verification */
|
||||||
export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => {
|
export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
const controller = new EmailIngestionController();
|
const controller = new EmailIngestionController();
|
||||||
|
|
||||||
@@ -22,3 +23,38 @@ export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) =
|
|||||||
controller.handleInboundWebhook.bind(controller)
|
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[]> {
|
async getPendingAssociations(userId: string): Promise<PendingVehicleAssociation[]> {
|
||||||
try {
|
try {
|
||||||
const res = await this.db.query(
|
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
|
// Record Creation
|
||||||
// ========================
|
// ========================
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @ai-context Exports webhook routes, services, and types for Resend inbound email processing
|
* @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 { EmailIngestionService } from './domain/email-ingestion.service';
|
||||||
export { EmailIngestionRepository } from './data/email-ingestion.repository';
|
export { EmailIngestionRepository } from './data/email-ingestion.repository';
|
||||||
export { ReceiptClassifier } from './domain/receipt-classifier';
|
export { ReceiptClassifier } from './domain/receipt-classifier';
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
* @ai-summary Main dashboard screen component showing fleet overview
|
* @ai-summary Main dashboard screen component showing fleet overview
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box } from '@mui/material';
|
import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material';
|
||||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
|
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
|
||||||
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
|
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
|
||||||
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
|
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
|
||||||
import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData';
|
import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData';
|
||||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
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 { MobileScreen } from '../../../core/store';
|
||||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
@@ -29,6 +32,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
onViewMaintenance,
|
onViewMaintenance,
|
||||||
onAddVehicle
|
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: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
|
||||||
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
|
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
|
||||||
|
|
||||||
@@ -102,6 +108,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
// Main dashboard view
|
// Main dashboard view
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Pending Receipts Banner */}
|
||||||
|
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<SummaryCards summary={summary} />
|
<SummaryCards summary={summary} />
|
||||||
|
|
||||||
@@ -132,6 +141,35 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
Dashboard updates every 2 minutes
|
Dashboard updates every 2 minutes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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