diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 6ef7f47..4946922 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -17,6 +17,7 @@ const config: Config = { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '(.*/core/api/client)$': '/src/core/api/__mocks__/client.ts', '\\.(css|less|scss|sass)$': '/test/__mocks__/styleMock.js', '\\.(svg|png|jpg|jpeg|gif)$': '/test/__mocks__/fileMock.js', }, diff --git a/frontend/setupTests.ts b/frontend/setupTests.ts index d1e03bc..f277bf3 100644 --- a/frontend/setupTests.ts +++ b/frontend/setupTests.ts @@ -1,3 +1,7 @@ // Jest setup for React Testing Library import '@testing-library/jest-dom'; +// Polyfill TextEncoder/TextDecoder for jsdom (required by Auth0 SDK) +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextEncoder, TextDecoder }); + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0aa66e0..fbbd914 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; import { useIsAuthInitialized } from './core/auth/auth-gate'; import { motion, AnimatePresence } from 'framer-motion'; @@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC = ({ vehicle, onBack, }; function App() { - const { isLoading, isAuthenticated, user } = useAuth0(); + const { isLoading, isAuthenticated, user, error: authError } = useAuth0(); const location = useLocation(); + const navigate = useNavigate(); const isAuthGateReady = useIsAuthInitialized(); const [_isPending, startTransition] = useTransition(); - console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady); // Initialize data synchronization const { prefetchForNavigation } = useDataSync(); @@ -365,17 +365,24 @@ function App() { const [showAddVehicle, setShowAddVehicle] = useState(false); // Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile) + // Skip on auth routes -- their query params must survive until Auth0 SDK processes them useEffect(() => { - const screen = routeToScreen[window.location.pathname]; + const path = window.location.pathname; + if (path === '/callback' || path === '/signup' || path === '/verify-email') return; + const screen = routeToScreen[path]; if (screen && screen !== activeScreen) { navigateToScreen(screen, { source: 'url-sync' }); } }, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount // Sync Zustand screen changes back to browser URL (enables bookmarks and URL sharing) + // Skip on auth routes -- replaceState would strip ?code= and &state= params that + // Auth0 SDK needs for handleRedirectCallback (child effects fire before parent effects) useEffect(() => { + const path = window.location.pathname; + if (path === '/callback' || path === '/signup' || path === '/verify-email') return; const targetPath = screenToRoute[activeScreen]; - if (targetPath && window.location.pathname !== targetPath) { + if (targetPath && path !== targetPath) { window.history.replaceState(null, '', targetPath); } }, [activeScreen]); @@ -486,7 +493,6 @@ function App() { } }, [navigateToScreen, navigateToVehicleSubScreen]); - console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent }); const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/'); const isCallbackRoute = location.pathname === '/callback'; @@ -496,6 +502,16 @@ function App() { const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute; const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute; + const [callbackTimedOut, setCallbackTimedOut] = useState(false); + useEffect(() => { + if (isCallbackRoute && !isAuthenticated && !isLoading) { + const timer = setTimeout(() => setCallbackTimedOut(true), 10000); + return () => clearTimeout(timer); + } + setCallbackTimedOut(false); + return undefined; + }, [isCallbackRoute, isAuthenticated, isLoading]); + // Enhanced navigation handlers for mobile const handleVehicleSelect = useCallback((vehicle: Vehicle) => { setSelectedVehicle(vehicle); @@ -557,18 +573,55 @@ function App() { ); } - // Callback route requires authentication - handled by CallbackPage component - if (isCallbackRoute && isAuthenticated) { + if (isCallbackRoute) { + if (authError) { + return ( + +
+
Login failed: {authError.message}
+ +
+
+ ); + } + if (isAuthenticated) { + return ( + + +
Processing login...
+ + }> + {mobileMode ? : } +
+ +
+ ); + } + if (callbackTimedOut) { + return ; + } + if (mobileMode) { + return ( + + +
+
Processing login...
+
+
+
+ ); + } return ( - -
Processing login...
- - }> - {mobileMode ? : } -
- +
+
Processing login...
+
); } @@ -637,7 +690,6 @@ function App() { // Wait for auth gate to be ready before rendering protected routes // This prevents a race condition where the page renders before the auth token is ready if (!isAuthGateReady) { - console.log('[DEBUG App] Auth gate not ready yet, showing loading state'); if (mobileMode) { return ( diff --git a/frontend/src/core/api/__mocks__/client.ts b/frontend/src/core/api/__mocks__/client.ts new file mode 100644 index 0000000..c4a20ed --- /dev/null +++ b/frontend/src/core/api/__mocks__/client.ts @@ -0,0 +1,15 @@ +/** + * @ai-summary Manual mock for API client used in Jest tests + * Prevents import.meta.env errors in jsdom environment + */ + +export const apiClient = { + get: jest.fn().mockResolvedValue({ data: [] }), + post: jest.fn().mockResolvedValue({ data: {} }), + put: jest.fn().mockResolvedValue({ data: {} }), + delete: jest.fn().mockResolvedValue({}), + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, +}; diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 7678046..746f5ad 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { apiClient, setAuthReady } from '../api/client'; import { createIndexedDBAdapter } from '../utils/indexeddb-storage'; import { setAuthInitialized } from './auth-gate'; +import logger from '../../utils/logger'; interface Auth0ProviderProps { children: React.ReactNode; @@ -20,12 +21,8 @@ export const Auth0Provider: React.FC = ({ children }) => { const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; const audience = import.meta.env.VITE_AUTH0_AUDIENCE; - // Basic component loading debug - console.log('[Auth0Provider] Component loaded', { domain, clientId, audience }); - - const onRedirectCallback = (appState?: { returnTo?: string }) => { - console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); + logger.debug('Auth0 redirect callback triggered', { returnTo: appState?.returnTo }); // Route to callback page which will check user status and redirect appropriately // Pass the intended destination as state for after status check navigate('/callback', { @@ -57,17 +54,9 @@ export const Auth0Provider: React.FC = ({ children }) => { // Component to inject token into API client with mobile support const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { getAccessTokenSilently, isAuthenticated, isLoading, user } = useAuth0(); + const { getAccessTokenSilently, isAuthenticated, isLoading, logout } = useAuth0(); const [retryCount, setRetryCount] = React.useState(0); - - // Basic component loading debug - console.log('[TokenInjector] Component loaded'); - - // Debug mobile authentication state - React.useEffect(() => { - const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - console.log(`[Auth Debug] Mobile: ${isMobile}, Loading: ${isLoading}, Authenticated: ${isAuthenticated}, User: ${user ? 'present' : 'null'}`); - }, [isAuthenticated, isLoading, user]); + const validatingRef = React.useRef(false); // Helper function to get token with enhanced retry logic for mobile devices const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise => { @@ -93,77 +82,49 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => } const token = await getAccessTokenSilently(tokenOptions); - console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, { - cacheMode: tokenOptions.cacheMode, - timeout: tokenOptions.timeoutInSeconds - }); + logger.debug(`Token acquired on attempt ${attempt + 1}`); return token; } catch (error: any) { - console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, { - error: error.message || error, - cacheMode: attempt <= 2 ? 'on' : 'off' + logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, { + error: error.message || String(error), }); // Mobile-specific: longer delays and more attempts if (attempt < maxRetries - 1) { - const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff - console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`); + const delay = delayMs * Math.pow(1.5, attempt); await new Promise(resolve => setTimeout(resolve, delay)); } } } - console.error('[Mobile Auth] All token acquisition attempts failed - authentication may be broken'); + logger.error('All token acquisition attempts failed'); return null; }; - // Force authentication check for devices when user seems logged in but isAuthenticated is false + // Prevent stale session state when cached token is no longer valid React.useEffect(() => { - const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + if (!isAuthenticated || isLoading || validatingRef.current) return; + if (window.location.pathname === '/callback') return; - // Debug current state - console.log('[Auth Debug] State check:', { - isMobile, - isLoading, - isAuthenticated, - pathname: window.location.pathname, - userAgent: navigator.userAgent.substring(0, 50) + '...' - }); - - // Trigger for mobile devices OR any device on protected route without authentication - if (!isLoading && !isAuthenticated && window.location.pathname !== '/') { - console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...'); - - // Aggressive token check - const forceAuthCheck = async () => { - try { - // Try multiple approaches to get token - const token = await getAccessTokenSilently({ - cacheMode: 'off' as const, - timeoutInSeconds: 10 - }); - console.log('[Auth Debug] Force auth successful, token acquired'); - - // Manually add to API client since isAuthenticated might still be false - if (token) { - console.log('[Auth Debug] Manually adding token to API client'); - // Force add the token to subsequent requests - apiClient.interceptors.request.use((config) => { - if (!config.headers.Authorization) { - config.headers.Authorization = `Bearer ${token}`; - console.log('[Auth Debug] Token manually added to request'); - } - return config; - }); - setAuthReady(true); - } - } catch (error: any) { - console.log('[Auth Debug] Force auth failed:', error.message); + const validateToken = async () => { + validatingRef.current = true; + try { + await getAccessTokenSilently({ cacheMode: 'off', timeoutInSeconds: 10 }); + } catch (error: any) { + const errorType = error?.error || error?.message || ''; + if (errorType.includes('login_required') || errorType.includes('consent_required') || + errorType.includes('invalid_grant')) { + logger.warn('Stale token detected, clearing auth state'); + const { indexedDBStorage } = await import('../utils/indexeddb-storage'); + await indexedDBStorage.clearAll(); + logout({ openUrl: false }); } - }; + } finally { + validatingRef.current = false; + } + }; - forceAuthCheck(); - } - }, [isLoading, isAuthenticated, getAccessTokenSilently]); + validateToken(); + }, [isAuthenticated, isLoading, getAccessTokenSilently, logout]); React.useEffect(() => { let interceptorId: number | undefined; @@ -175,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => try { const { indexedDBStorage } = await import('../utils/indexeddb-storage'); await indexedDBStorage.waitForReady(); - console.log('[Auth] IndexedDB storage is ready'); + logger.debug('IndexedDB storage is ready'); } catch (error) { - console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error); + logger.warn('IndexedDB not ready, proceeding anyway', { error: String(error) }); } // Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready) const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); if (isMobile) { - // Small delay for mobile browsers to settle after IndexedDB init - console.log('[Mobile Auth] Initializing token cache (mobile: true, delay: 50ms)'); await new Promise(resolve => setTimeout(resolve, 50)); - } else { - console.log('[Auth] Initializing token cache (desktop, no delay)'); } try { const token = await getTokenWithRetry(); if (token) { - console.log('[Mobile Auth] Token pre-warming successful'); + logger.debug('Token pre-warming successful'); setRetryCount(0); setAuthReady(true); setAuthInitialized(true); // Signal that auth is fully ready } else { - console.error('[Mobile Auth] Failed to acquire token after retries - will retry on API calls'); + logger.error('Failed to acquire token after retries'); setRetryCount(prev => prev + 1); } } catch (error) { - console.error('[Mobile Auth] Token initialization failed:', error); + logger.error('Token initialization failed', { error: String(error) }); setRetryCount(prev => prev + 1); } }; @@ -221,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => config.headers.Authorization = `Bearer ${token}`; setAuthReady(true); } else { - console.error('No token available for request to:', config.url); + logger.error('No token available for request', { url: config.url }); // Allow request to proceed - backend will return 401 if needed } } catch (error: any) { - console.error('Failed to get access token for request:', error.message || error); + logger.error('Failed to get access token for request', { error: error.message || String(error) }); // Allow request to proceed - backend will return 401 if needed } return config; diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts index 6ce8dd6..417a67e 100644 --- a/frontend/src/core/utils/indexeddb-storage.ts +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -3,6 +3,8 @@ * @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility */ +import logger from '../../utils/logger'; + interface StorageAdapter { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { this.db = await this.openDatabase(); await this.loadCacheFromDB(); this.isReady = true; - console.log('[IndexedDB] Storage initialized successfully'); + logger.debug('IndexedDB storage initialized successfully'); } catch (error) { - console.error('[IndexedDB] Initialization failed, using memory only:', error); + logger.error('IndexedDB initialization failed, using memory only', { error: String(error) }); this.isReady = false; } } @@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => { - console.error(`IndexedDB open failed: ${request.error?.message}`); + logger.error(`IndexedDB open failed: ${request.error?.message}`); resolve(null as any); // Fallback to memory-only mode }; @@ -71,25 +73,27 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { return new Promise((resolve) => { const transaction = this.db!.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); - const request = store.getAll(); + const request = store.openCursor(); + this.memoryCache.clear(); request.onsuccess = () => { - const results = request.result; - this.memoryCache.clear(); - - for (const item of results) { - if (item.key && typeof item.value === 'string') { - this.memoryCache.set(item.key, item.value); + const cursor = request.result; + if (cursor) { + const key = cursor.key as string; + const value = cursor.value; + if (typeof key === 'string' && typeof value === 'string') { + this.memoryCache.set(key, value); } + cursor.continue(); + } else { + logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`); + resolve(); } - - console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`); - resolve(); }; request.onerror = () => { - console.warn('[IndexedDB] Failed to load cache from DB:', request.error); - resolve(); // Don't fail initialization + logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) }); + resolve(); }; }); } @@ -105,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { const request = store.delete(key); request.onsuccess = () => resolve(); request.onerror = () => { - console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error); + logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) }); resolve(); }; } else { const request = store.put(value, key); request.onsuccess = () => resolve(); request.onerror = () => { - console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error); + logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) }); resolve(); }; } @@ -130,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { // Async persist to IndexedDB (non-blocking) if (this.isReady) { this.persistToDB(key, value).catch(error => { - console.warn(`[IndexedDB] Background persist failed for ${key}:`, error); + logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) }); }); } } @@ -141,7 +145,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { // Async remove from IndexedDB (non-blocking) if (this.isReady) { this.persistToDB(key, null).catch(error => { - console.warn(`[IndexedDB] Background removal failed for ${key}:`, error); + logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) }); }); } } @@ -157,6 +161,30 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { } } + async clearAll(): Promise { + await this.initPromise; + if (!this.db) { + this.memoryCache.clear(); + return; + } + const tx = this.db.transaction(this.storeName, 'readwrite'); + tx.objectStore(this.storeName).clear(); + await new Promise((resolve, reject) => { + tx.oncomplete = () => { + this.memoryCache.clear(); + resolve(); + }; + tx.onerror = () => { + this.memoryCache.clear(); + reject(tx.error); + }; + tx.onabort = () => { + this.memoryCache.clear(); + reject(new Error('Transaction aborted')); + }; + }); + } + key(index: number): string | null { const keys = Array.from(this.memoryCache.keys()); return keys[index] || null; @@ -167,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { } // Auth0 Cache interface implementation + // allKeys() eliminates Auth0 SDK's CacheKeyManifest fallback (auth0-spa-js line 2319) + allKeys(): string[] { + return Array.from(this.memoryCache.keys()); + } + async get(key: string): Promise { await this.initPromise; const value = this.getItem(key); @@ -175,12 +208,21 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache { async set(key: string, value: any): Promise { await this.initPromise; - this.setItem(key, JSON.stringify(value)); + const stringValue = JSON.stringify(value); + this.memoryCache.set(key, stringValue); + // Fire-and-forget: persist to IndexedDB for page reload survival + this.persistToDB(key, stringValue).catch(error => { + logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) }); + }); } async remove(key: string): Promise { await this.initPromise; - this.removeItem(key); + this.memoryCache.delete(key); + // Fire-and-forget: remove from IndexedDB + this.persistToDB(key, null).catch(error => { + logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) }); + }); } // Additional methods for enhanced functionality diff --git a/frontend/src/features/dashboard/components/ActionBar.tsx b/frontend/src/features/dashboard/components/ActionBar.tsx new file mode 100644 index 0000000..3464d8b --- /dev/null +++ b/frontend/src/features/dashboard/components/ActionBar.tsx @@ -0,0 +1,38 @@ +/** + * @ai-summary Compact action bar for dashboard with Add Vehicle and Log Fuel buttons + */ + +import React from 'react'; +import Button from '@mui/material/Button'; +import Add from '@mui/icons-material/Add'; +import LocalGasStation from '@mui/icons-material/LocalGasStation'; + +interface ActionBarProps { + onAddVehicle: () => void; + onLogFuel: () => void; +} + +export const ActionBar: React.FC = ({ onAddVehicle, onLogFuel }) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index 01b76aa..ee0fc7c 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -1,47 +1,72 @@ /** - * @ai-summary Main dashboard screen component showing fleet overview + * @ai-summary Main dashboard screen showing vehicle fleet roster with health indicators */ import React, { useState } from 'react'; -import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material'; +import { Box, Dialog, DialogTitle, DialogContent, IconButton, Skeleton, Typography, 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 { RecentActivity, RecentActivitySkeleton } from './RecentActivity'; -import { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from '../hooks/useDashboardData'; +import { VehicleRosterCard } from './VehicleRosterCard'; +import { ActionBar } from './ActionBar'; +import { useVehicleRoster } 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'; interface DashboardScreenProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches navigation store type signature onNavigate?: (screen: MobileScreen, metadata?: Record) => void; onVehicleClick?: (vehicle: Vehicle) => void; onViewMaintenance?: () => void; onAddVehicle?: () => void; } +const RosterSkeleton: React.FC = () => ( +
+ {[0, 1, 2, 3].map(i => ( + +
+ +
+ +
+ +
+
+ + +
+ +
+ ))} +
+); + export const DashboardScreen: React.FC = ({ onNavigate, onVehicleClick, - 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: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); - const { data: recentActivity } = useRecentActivity(); + const { data: roster, vehicles, isLoading, error } = useVehicleRoster(); + + const handleAddVehicle = onAddVehicle ?? (() => onNavigate?.('Vehicles')); + const handleLogFuel = () => onNavigate?.('Log Fuel'); + const handleVehicleClick = (vehicleId: string) => { + const vehicle = vehicles?.find(v => v.id === vehicleId); + if (vehicle && onVehicleClick) { + onVehicleClick(vehicle); + } + }; // Error state - if (summaryError || attentionError) { + if (error) { return (
@@ -69,19 +94,21 @@ export const DashboardScreen: React.FC = ({ } // Loading state - if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) { + if (isLoading || !roster) { return (
- - - - +
+ + Your Fleet + +
+
); } // Empty state - no vehicles - if (summary.totalVehicles === 0) { + if (roster.length === 0) { return (
@@ -98,7 +125,7 @@ export const DashboardScreen: React.FC = ({ @@ -114,32 +141,24 @@ export const DashboardScreen: React.FC = ({ {/* Pending Receipts Banner */} setShowPendingReceipts(true)} /> - {/* Summary Cards */} - + {/* Heading + Action Bar */} +
+ + Your Fleet + + +
- {/* Vehicles Needing Attention */} - {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( - { - const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId); - if (vehicle && onVehicleClick) { - onVehicleClick(vehicle); - } - }} - /> - )} - - {/* Recent Activity */} - {recentActivity && } - - {/* Quick Actions */} - onNavigate?.('Vehicles'))} - onLogFuel={() => onNavigate?.('Log Fuel')} - onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))} - onViewVehicles={() => onNavigate?.('Vehicles')} - /> + {/* Vehicle Roster Grid */} +
+ {roster.map(rosterData => ( + + ))} +
{/* Pending Receipts Dialog */} ; - onClick: () => void; -} - -interface QuickActionsProps { - onAddVehicle: () => void; - onLogFuel: () => void; - onViewMaintenance: () => void; - onViewVehicles: () => void; -} - -export const QuickActions: React.FC = ({ - onAddVehicle, - onLogFuel, - onViewMaintenance, - onViewVehicles, -}) => { - const actions: QuickAction[] = [ - { - id: 'add-vehicle', - title: 'Add Vehicle', - description: 'Register a new vehicle', - icon: DirectionsCarRoundedIcon, - onClick: onAddVehicle, - }, - { - id: 'log-fuel', - title: 'Log Fuel', - description: 'Record a fuel purchase', - icon: LocalGasStationRoundedIcon, - onClick: onLogFuel, - }, - { - id: 'view-maintenance', - title: 'Maintenance', - description: 'View maintenance records', - icon: BuildRoundedIcon, - onClick: onViewMaintenance, - }, - { - id: 'view-vehicles', - title: 'My Vehicles', - description: 'View all vehicles', - icon: FormatListBulletedRoundedIcon, - onClick: onViewVehicles, - }, - ]; - - return ( - -
-

- Quick Actions -

-

- Common tasks and navigation -

-
- -
- {actions.map((action) => { - const IconComponent = action.icon; - return ( - - - - - - - {action.title} - - - {action.description} - - - - ); - })} -
-
- ); -}; - -export const QuickActionsSkeleton: React.FC = () => { - return ( - -
-
-
-
-
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/components/RecentActivity.tsx b/frontend/src/features/dashboard/components/RecentActivity.tsx deleted file mode 100644 index 51e6b9f..0000000 --- a/frontend/src/features/dashboard/components/RecentActivity.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * @ai-summary Recent activity feed showing latest fuel logs and maintenance events - */ - -import React from 'react'; -import { Box } from '@mui/material'; -import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; -import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { RecentActivityItem } from '../types'; - -interface RecentActivityProps { - items: RecentActivityItem[]; -} - -const formatRelativeTime = (timestamp: string): string => { - const now = new Date(); - const date = new Date(timestamp); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffDays < 0) { - // Future date (upcoming maintenance) - const absDays = Math.abs(diffDays); - if (absDays === 0) return 'Today'; - if (absDays === 1) return 'Tomorrow'; - return `In ${absDays} days`; - } - if (diffDays === 0) { - if (diffHours === 0) return diffMins <= 1 ? 'Just now' : `${diffMins}m ago`; - return `${diffHours}h ago`; - } - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); -}; - -export const RecentActivity: React.FC = ({ items }) => { - if (items.length === 0) { - return ( - -

- Recent Activity -

-

- No recent activity. Start by logging fuel or scheduling maintenance. -

-
- ); - } - - return ( - -

- Recent Activity -

-
- {items.map((item, index) => ( -
- - {item.type === 'fuel' ? ( - - ) : ( - - )} - -
-

- {item.vehicleName} -

-

- {item.description} -

-
- - {formatRelativeTime(item.timestamp)} - -
- ))} -
-
- ); -}; - -export const RecentActivitySkeleton: React.FC = () => { - return ( - -
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/components/SummaryCards.tsx b/frontend/src/features/dashboard/components/SummaryCards.tsx deleted file mode 100644 index 9b14463..0000000 --- a/frontend/src/features/dashboard/components/SummaryCards.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @ai-summary Summary cards showing key dashboard metrics - */ - -import React from 'react'; -import { Box } from '@mui/material'; -import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; -import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; -import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { DashboardSummary } from '../types'; -import { MobileScreen } from '../../../core/store'; - -interface SummaryCardsProps { - summary: DashboardSummary; - onNavigate?: (screen: MobileScreen) => void; -} - -export const SummaryCards: React.FC = ({ summary, onNavigate }) => { - const cards = [ - { - title: 'Total Vehicles', - value: summary.totalVehicles, - icon: DirectionsCarRoundedIcon, - color: 'primary.main', - ctaText: 'Add a vehicle', - ctaScreen: 'Vehicles' as MobileScreen, - }, - { - title: 'Upcoming Maintenance', - value: summary.upcomingMaintenanceCount, - subtitle: 'Next 30 days', - icon: BuildRoundedIcon, - color: 'primary.main', - ctaText: 'Schedule maintenance', - ctaScreen: 'Maintenance' as MobileScreen, - }, - { - title: 'Recent Fuel Logs', - value: summary.recentFuelLogsCount, - subtitle: 'Last 7 days', - icon: LocalGasStationRoundedIcon, - color: 'primary.main', - ctaText: 'Log your first fill-up', - ctaScreen: 'Log Fuel' as MobileScreen, - }, - ]; - - return ( -
- {cards.map((card) => { - const IconComponent = card.icon; - return ( - -
- - - -
-

- {card.title} -

- - {card.value} - - {card.value === 0 && card.ctaText ? ( - onNavigate?.(card.ctaScreen)} - sx={{ - background: 'none', - border: 'none', - padding: 0, - cursor: 'pointer', - color: 'primary.main', - fontSize: '0.75rem', - fontWeight: 500, - mt: 0.5, - '&:hover': { textDecoration: 'underline' }, - }} - > - {card.ctaText} - - ) : card.subtitle ? ( -

- {card.subtitle} -

- ) : null} -
-
-
- ); - })} -
- ); -}; - -export const SummaryCardsSkeleton: React.FC = () => { - return ( -
- {[1, 2, 3].map((i) => ( - -
-
-
-
-
-
-
-
- - ))} -
- ); -}; diff --git a/frontend/src/features/dashboard/components/VehicleAttention.tsx b/frontend/src/features/dashboard/components/VehicleAttention.tsx deleted file mode 100644 index 89fbabc..0000000 --- a/frontend/src/features/dashboard/components/VehicleAttention.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @ai-summary List of vehicles needing attention (overdue maintenance) - */ - -import React from 'react'; -import { Box, SvgIconProps } from '@mui/material'; -import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; -import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded'; -import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; -import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded'; -import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; -import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; -import { VehicleNeedingAttention } from '../types'; - -interface VehicleAttentionProps { - vehicles: VehicleNeedingAttention[]; - onVehicleClick?: (vehicleId: string) => void; -} - -export const VehicleAttention: React.FC = ({ vehicles, onVehicleClick }) => { - if (vehicles.length === 0) { - return ( - -
- - - -

- All Caught Up! -

-

- No vehicles need immediate attention -

-
-
- ); - } - - const priorityConfig: Record }> = { - high: { - color: 'error.main', - icon: ErrorRoundedIcon, - }, - medium: { - color: 'warning.main', - icon: WarningAmberRoundedIcon, - }, - low: { - color: 'info.main', - icon: ScheduleRoundedIcon, - }, - }; - - return ( - -
-

- Needs Attention -

-

- Vehicles with overdue maintenance -

-
- -
- {vehicles.map((vehicle) => { - const config = priorityConfig[vehicle.priority]; - const IconComponent = config.icon; - return ( - onVehicleClick?.(vehicle.id)} - role={onVehicleClick ? 'button' : undefined} - tabIndex={onVehicleClick ? 0 : undefined} - onKeyDown={(e: React.KeyboardEvent) => { - if (onVehicleClick && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - onVehicleClick(vehicle.id); - } - }} - sx={{ - p: 2, - borderRadius: 3, - bgcolor: 'action.hover', - border: '1px solid', - borderColor: 'divider', - cursor: onVehicleClick ? 'pointer' : 'default', - transition: 'all 0.2s', - '&:hover': onVehicleClick ? { - bgcolor: 'action.selected', - } : {}, - }} - > -
- - - -
- - {getVehicleLabel(vehicle)} - -

- {vehicle.reason} -

- - {vehicle.priority.toUpperCase()} PRIORITY - -
-
-
- ); - })} -
-
- ); -}; - -export const VehicleAttentionSkeleton: React.FC = () => { - return ( - -
-
-
-
-
- {[1, 2].map((i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- - ); -}; diff --git a/frontend/src/features/dashboard/components/VehicleRosterCard.tsx b/frontend/src/features/dashboard/components/VehicleRosterCard.tsx new file mode 100644 index 0000000..41bf831 --- /dev/null +++ b/frontend/src/features/dashboard/components/VehicleRosterCard.tsx @@ -0,0 +1,103 @@ +/** + * @ai-summary Vehicle roster card component for dashboard fleet grid + * Displays vehicle image, health status, attention items, and odometer + */ + +import React from 'react'; +import { clsx } from 'clsx'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { VehicleImage } from '../../vehicles/components/VehicleImage'; +import { getVehicleLabel } from '../../../core/utils/vehicleDisplay'; +import { VehicleRosterData, AttentionItem } from '../types'; + +interface VehicleRosterCardProps { + data: VehicleRosterData; + onClick: (vehicleId: string) => void; +} + +const getHealthBadgeClass = (health: VehicleRosterData['health']): string => { + switch (health) { + case 'green': + return 'bg-emerald-500'; + case 'yellow': + return 'bg-amber-500'; + case 'red': + return 'bg-red-500'; + } +}; + +const getAttentionItemClass = (urgency: AttentionItem['urgency']): string => { + switch (urgency) { + case 'overdue': + return 'text-red-600 dark:text-red-400'; + case 'due-soon': + return 'text-amber-600 dark:text-amber-400'; + case 'upcoming': + return 'text-slate-500 dark:text-titanio'; + } +}; + +const formatAttentionStatus = (item: AttentionItem): string => { + if (item.urgency === 'overdue') { + return 'OVERDUE'; + } + return `${item.daysUntilDue} days`; +}; + +export const VehicleRosterCard: React.FC = ({ + data, + onClick, +}) => { + const { vehicle, health, attentionItems } = data; + const displayedItems = attentionItems.slice(0, 3); + + return ( + onClick(vehicle.id)}> + {/* Top row: Image, Label, Health Badge */} +
+ {/* Vehicle image container - clips the built-in mb-2 margin */} +
+ +
+ + {/* Vehicle label */} +
+
+ {getVehicleLabel(vehicle)} +
+
+ + {/* Health badge */} +
+
+ + {/* Attention items */} +
+ {displayedItems.length === 0 ? ( +
+ All clear +
+ ) : ( + displayedItems.map((item, index) => ( +
+ {item.label} - {formatAttentionStatus(item)} +
+ )) + )} +
+ + {/* Odometer */} +
+ {vehicle.odometerReading.toLocaleString()} mi +
+ + ); +}; diff --git a/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx b/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx new file mode 100644 index 0000000..a9f11fd --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ActionBar } from '../ActionBar'; + +describe('ActionBar', () => { + it('renders both buttons with correct text', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + expect(screen.getByText('Add Vehicle')).toBeInTheDocument(); + expect(screen.getByText('Log Fuel')).toBeInTheDocument(); + }); + + it('calls onAddVehicle when Add Vehicle button clicked', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + const addVehicleButton = screen.getByText('Add Vehicle'); + fireEvent.click(addVehicleButton); + + expect(onAddVehicle).toHaveBeenCalledTimes(1); + }); + + it('calls onLogFuel when Log Fuel button clicked', () => { + const onAddVehicle = jest.fn(); + const onLogFuel = jest.fn(); + + render(); + + const logFuelButton = screen.getByText('Log Fuel'); + fireEvent.click(logFuelButton); + + expect(onLogFuel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx b/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx new file mode 100644 index 0000000..09b8ee8 --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/DashboardScreen.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material'; +import { DashboardScreen } from '../DashboardScreen'; +import { Vehicle } from '../../../vehicles/types/vehicles.types'; +import { VehicleRosterData } from '../../types'; +import { useVehicleRoster } from '../../hooks/useDashboardData'; + +jest.mock('@auth0/auth0-react'); +jest.mock('../../../../core/api/client'); +jest.mock('../../../vehicles/api/vehicles.api'); +jest.mock('../../../maintenance/api/maintenance.api'); +jest.mock('../../../documents/api/documents.api'); +jest.mock('../../../vehicles/components/VehicleImage', () => ({ + VehicleImage: () =>
, +})); +jest.mock('../../../email-ingestion/components/PendingAssociationBanner', () => ({ + PendingAssociationBanner: () => null, +})); +jest.mock('../../../email-ingestion/components/PendingAssociationList', () => ({ + PendingAssociationList: () => null, +})); +jest.mock('../../hooks/useDashboardData'); + +const mockUseVehicleRoster = useVehicleRoster as jest.MockedFunction; + +const makeVehicle = (overrides: Partial = {}): Vehicle => ({ + id: 'vehicle-1', + userId: 'user-1', + vin: '1HGBH41JXMN109186', + year: 2019, + make: 'Ford', + model: 'F-150', + odometerReading: 87412, + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeRosterData = (vehicle?: Vehicle): VehicleRosterData => ({ + vehicle: vehicle ?? makeVehicle(), + health: 'green' as const, + attentionItems: [], +}); + +const theme = createTheme(); + +const renderWithProviders = (ui: React.ReactElement) => { + return render( + + {ui} + + ); +}; + +describe('DashboardScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders vehicle roster cards', () => { + const vehicle1 = makeVehicle({ id: 'v1', make: 'Ford', model: 'F-150', year: 2019 }); + const vehicle2 = makeVehicle({ id: 'v2', make: 'Honda', model: 'Civic', year: 2020 }); + const roster = [makeRosterData(vehicle1), makeRosterData(vehicle2)]; + + mockUseVehicleRoster.mockReturnValue({ + data: roster, + vehicles: [vehicle1, vehicle2], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('2019 Ford F-150')).toBeInTheDocument(); + expect(screen.getByText('2020 Honda Civic')).toBeInTheDocument(); + }); + + it('renders empty state when 0 vehicles', () => { + mockUseVehicleRoster.mockReturnValue({ + data: [], + vehicles: [], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Welcome to MotoVaultPro')).toBeInTheDocument(); + }); + + it('renders loading skeletons when loading', () => { + mockUseVehicleRoster.mockReturnValue({ + data: undefined, + vehicles: undefined, + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Your Fleet')).toBeInTheDocument(); + }); + + it('renders "Your Fleet" heading', () => { + const vehicle = makeVehicle(); + const roster = [makeRosterData(vehicle)]; + + mockUseVehicleRoster.mockReturnValue({ + data: roster, + vehicles: [vehicle], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + renderWithProviders(); + + expect(screen.getByText('Your Fleet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx b/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx new file mode 100644 index 0000000..5020df4 --- /dev/null +++ b/frontend/src/features/dashboard/components/__tests__/VehicleRosterCard.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { VehicleRosterCard } from '../VehicleRosterCard'; +import { Vehicle } from '../../../vehicles/types/vehicles.types'; +import { VehicleRosterData, AttentionItem } from '../../types'; + +jest.mock('@auth0/auth0-react'); +jest.mock('../../../vehicles/components/VehicleImage', () => ({ + VehicleImage: () =>
, +})); + +const makeVehicle = (overrides: Partial = {}): Vehicle => ({ + id: 'vehicle-1', + userId: 'user-1', + vin: '1HGBH41JXMN109186', + year: 2019, + make: 'Ford', + model: 'F-150', + odometerReading: 87412, + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeRosterData = (overrides: Partial = {}): VehicleRosterData => ({ + vehicle: makeVehicle(), + health: 'green', + attentionItems: [], + ...overrides, +}); + +describe('VehicleRosterCard', () => { + it('renders vehicle label with year make model', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('2019 Ford F-150')).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for green health', () => { + const data = makeRosterData({ health: 'green' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-emerald-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for yellow health', () => { + const data = makeRosterData({ health: 'yellow' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-amber-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders health badge with correct color class for red health', () => { + const data = makeRosterData({ health: 'red' }); + const onClick = jest.fn(); + + const { container } = render(); + + const badge = container.querySelector('.bg-red-500'); + expect(badge).toBeInTheDocument(); + }); + + it('renders attention items text', () => { + const attentionItems: AttentionItem[] = [ + { + label: 'Oil Change', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }, + ]; + const data = makeRosterData({ attentionItems }); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('Oil Change - OVERDUE')).toBeInTheDocument(); + }); + + it('renders odometer with formatting', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('87,412 mi')).toBeInTheDocument(); + }); + + it('calls onClick with vehicle ID when clicked', () => { + const data = makeRosterData(); + const onClick = jest.fn(); + + render(); + + fireEvent.click(screen.getByText('2019 Ford F-150')); + + expect(onClick).toHaveBeenCalledWith('vehicle-1'); + }); + + it('renders All clear when no attention items', () => { + const data = makeRosterData({ attentionItems: [] }); + const onClick = jest.fn(); + + render(); + + expect(screen.getByText('All clear')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts b/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts new file mode 100644 index 0000000..c3b14d9 --- /dev/null +++ b/frontend/src/features/dashboard/hooks/__tests__/useDashboardData.test.ts @@ -0,0 +1,373 @@ +/** + * @ai-summary Unit tests for computeVehicleHealth pure function + * @ai-context Tests health calculation logic from maintenance schedules and document expiry + */ + +import { computeVehicleHealth } from '../../utils/computeVehicleHealth'; +import { MaintenanceSchedule } from '../../../maintenance/types/maintenance.types'; +import { DocumentRecord } from '../../../documents/types/documents.types'; + +// Helper factory functions for test data +const makeSchedule = (overrides: Partial = {}): MaintenanceSchedule => ({ + id: 'sched-1', + userId: 'user-1', + vehicleId: 'vehicle-1', + category: 'routine_maintenance', + subtypes: ['Engine Oil'], + scheduleType: 'interval', + isActive: true, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +const makeDocument = (overrides: Partial = {}): DocumentRecord => ({ + id: 'doc-1', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', + title: 'Insurance Policy', + sharedVehicleIds: [], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + ...overrides, +}); + +describe('computeVehicleHealth', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-02-15T00:00:00Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('Green health', () => { + it('should return green health with no schedules and no documents', () => { + const { health, attentionItems } = computeVehicleHealth([], []); + + expect(health).toBe('green'); + expect(attentionItems).toEqual([]); + }); + + it('should return green health with schedule due in 20 days and 1 upcoming attention item', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-03-07T00:00:00Z', // 20 days from now + subtypes: ['Engine Oil'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('green'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Engine Oil', + urgency: 'upcoming', + daysUntilDue: 20, + source: 'maintenance', + }); + }); + }); + + describe('Yellow health', () => { + it('should return yellow health with schedule due in 10 days, no overdue', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-25T00:00:00Z', // 10 days from now + subtypes: ['Air Filter Element'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Air Filter Element', + urgency: 'due-soon', + daysUntilDue: 10, + source: 'maintenance', + }); + }); + + it('should return yellow health with registration expiring in 7 days', () => { + const documents = [ + makeDocument({ + documentType: 'registration', + expirationDate: '2026-02-22T00:00:00Z', // 7 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Registration', + urgency: 'due-soon', + daysUntilDue: 7, + source: 'document', + }); + }); + }); + + describe('Red health', () => { + it('should return red health with maintenance overdue by 5 days', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Brakes and Traction Control'], + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Brakes and Traction Control', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }); + }); + + it('should return red health with insurance expired 3 days ago', () => { + const documents = [ + makeDocument({ + documentType: 'insurance', + expirationDate: '2026-02-12T00:00:00Z', // 3 days ago + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0]).toEqual({ + label: 'Insurance', + urgency: 'overdue', + daysUntilDue: -3, + source: 'document', + }); + }); + + it('should return red health with one overdue maintenance and one due-soon document', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Coolant'], + }), + ]; + + const documents = [ + makeDocument({ + documentType: 'registration', + expirationDate: '2026-02-20T00:00:00Z', // 5 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, documents); + + expect(health).toBe('red'); + expect(attentionItems).toHaveLength(2); + expect(attentionItems[0]).toEqual({ + label: 'Coolant', + urgency: 'overdue', + daysUntilDue: -5, + source: 'maintenance', + }); + expect(attentionItems[1]).toEqual({ + label: 'Registration', + urgency: 'due-soon', + daysUntilDue: 5, + source: 'document', + }); + }); + }); + + describe('Attention items sorting', () => { + it('should sort attention items with overdue first by most overdue, then due-soon by proximity', () => { + const schedules = [ + makeSchedule({ + id: 'sched-1', + nextDueDate: '2026-02-13T00:00:00Z', // 2 days ago (overdue, less urgent) + subtypes: ['Cabin Air Filter / Purifier'], + }), + makeSchedule({ + id: 'sched-2', + nextDueDate: '2026-02-05T00:00:00Z', // 10 days ago (overdue, more urgent) + subtypes: ['Engine Oil'], + }), + makeSchedule({ + id: 'sched-3', + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now (due-soon) + subtypes: ['Wiper Blade'], + }), + makeSchedule({ + id: 'sched-4', + nextDueDate: '2026-02-17T00:00:00Z', // 2 days from now (due-soon, more urgent) + subtypes: ['Brakes and Traction Control'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems).toHaveLength(3); // Max 3 items + expect(attentionItems[0]).toEqual({ + label: 'Engine Oil', + urgency: 'overdue', + daysUntilDue: -10, + source: 'maintenance', + }); + expect(attentionItems[1]).toEqual({ + label: 'Cabin Air Filter / Purifier', + urgency: 'overdue', + daysUntilDue: -2, + source: 'maintenance', + }); + expect(attentionItems[2]).toEqual({ + label: 'Brakes and Traction Control', + urgency: 'due-soon', + daysUntilDue: 2, + source: 'maintenance', + }); + }); + }); + + describe('Max 3 attention items enforcement', () => { + it('should enforce max 3 attention items when 5 items are present', () => { + const schedules = [ + makeSchedule({ + id: 'sched-1', + nextDueDate: '2026-02-05T00:00:00Z', // 10 days ago + subtypes: ['Item 1'], + }), + makeSchedule({ + id: 'sched-2', + nextDueDate: '2026-02-08T00:00:00Z', // 7 days ago + subtypes: ['Item 2'], + }), + makeSchedule({ + id: 'sched-3', + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago + subtypes: ['Item 3'], + }), + makeSchedule({ + id: 'sched-4', + nextDueDate: '2026-02-12T00:00:00Z', // 3 days ago + subtypes: ['Item 4'], + }), + makeSchedule({ + id: 'sched-5', + nextDueDate: '2026-02-14T00:00:00Z', // 1 day ago + subtypes: ['Item 5'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems).toHaveLength(3); + expect(attentionItems[0].label).toBe('Item 1'); // Most overdue + expect(attentionItems[1].label).toBe('Item 2'); + expect(attentionItems[2].label).toBe('Item 3'); + }); + }); + + describe('Inactive schedule handling', () => { + it('should ignore inactive schedules (isActive: false)', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-10T00:00:00Z', // 5 days ago (overdue) + subtypes: ['Ignored Item'], + isActive: false, + }), + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now + subtypes: ['Active Item'], + isActive: true, + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('Active Item'); + }); + }); + + describe('Missing date handling', () => { + it('should ignore schedules without nextDueDate', () => { + const schedules = [ + makeSchedule({ + nextDueDate: undefined, + subtypes: ['No Due Date'], + isActive: true, + }), + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', // 5 days from now + subtypes: ['With Due Date'], + isActive: true, + }), + ]; + + const { health, attentionItems } = computeVehicleHealth(schedules, []); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('With Due Date'); + }); + + it('should ignore documents without expirationDate', () => { + const documents = [ + makeDocument({ + documentType: 'manual', + expirationDate: null, + }), + makeDocument({ + documentType: 'insurance', + expirationDate: '2026-02-20T00:00:00Z', // 5 days from now + }), + ]; + + const { health, attentionItems } = computeVehicleHealth([], documents); + + expect(health).toBe('yellow'); + expect(attentionItems).toHaveLength(1); + expect(attentionItems[0].label).toBe('Insurance'); + }); + }); + + describe('Label extraction', () => { + it('should use first subtype as label when subtypes array is not empty', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', + subtypes: ['Air Filter Element', 'Engine Oil'], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems[0].label).toBe('Air Filter Element'); + }); + + it('should use formatted category as label when subtypes array is empty', () => { + const schedules = [ + makeSchedule({ + nextDueDate: '2026-02-20T00:00:00Z', + category: 'routine_maintenance', + subtypes: [], + }), + ]; + + const { attentionItems } = computeVehicleHealth(schedules, []); + + expect(attentionItems[0].label).toBe('routine maintenance'); + }); + }); +}); diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts index 28dc63a..9983861 100644 --- a/frontend/src/features/dashboard/hooks/useDashboardData.ts +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -1,29 +1,32 @@ /** * @ai-summary React Query hooks for dashboard data - * @ai-context Unified data fetching to prevent duplicate API calls + * @ai-context Fetches vehicles, maintenance schedules, and document expiry data + * to compute per-vehicle health indicators for the fleet roster. */ import { useQuery } from '@tanstack/react-query'; import { useAuth0 } from '@auth0/auth0-react'; import { vehiclesApi } from '../../vehicles/api/vehicles.api'; -import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { maintenanceApi } from '../../maintenance/api/maintenance.api'; -import { DashboardSummary, VehicleNeedingAttention, RecentActivityItem } from '../types'; +import { documentsApi } from '../../documents/api/documents.api'; import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; -import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; +import { DocumentRecord } from '../../documents/types/documents.types'; +import { Vehicle } from '../../vehicles/types/vehicles.types'; +import { VehicleRosterData } from '../types'; +import { computeVehicleHealth } from '../utils/computeVehicleHealth'; + +export { computeVehicleHealth }; -/** - * Combined dashboard data structure - */ interface DashboardData { - summary: DashboardSummary; - vehiclesNeedingAttention: VehicleNeedingAttention[]; - recentActivity: RecentActivityItem[]; + vehicles: Vehicle[]; + schedulesByVehicle: Map; + documentsByVehicle: Map; + roster: VehicleRosterData[]; } /** - * Unified hook that fetches all dashboard data in a single query - * Prevents duplicate API calls for vehicles and maintenance schedules + * Unified hook that fetches all dashboard data in a single query. + * Fetches vehicles, maintenance schedules, and document expiry data. */ export const useDashboardData = () => { const { isAuthenticated, isLoading: authLoading } = useAuth0(); @@ -31,123 +34,58 @@ export const useDashboardData = () => { return useQuery({ queryKey: ['dashboard', 'all'], queryFn: async (): Promise => { - // Fetch vehicles and fuel logs in parallel - const [vehicles, fuelLogs] = await Promise.all([ - vehiclesApi.getAll(), - fuelLogsApi.getUserFuelLogs(), - ]); + // Fetch vehicles first (need IDs for schedule queries) + const vehicles = await vehiclesApi.getAll(); - // Fetch all maintenance schedules in parallel (not sequential!) + // Fetch maintenance schedules per vehicle in parallel const allSchedulesArrays = await Promise.all( vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id)) ); - // Create a map of vehicle ID to schedules for efficient lookup const schedulesByVehicle = new Map(); vehicles.forEach((vehicle, index) => { schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]); }); - const flatSchedules = allSchedulesArrays.flat(); - const now = new Date(); - - // Calculate summary stats - const thirtyDaysFromNow = new Date(); - thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); - - const upcomingMaintenance = flatSchedules.filter(schedule => { - if (!schedule.nextDueDate) return false; - const dueDate = new Date(schedule.nextDueDate); - return dueDate >= now && dueDate <= thirtyDaysFromNow; - }); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const recentFuelLogs = fuelLogs.filter(log => { - const logDate = new Date(log.dateTime); - return logDate >= sevenDaysAgo; - }); - - const summary: DashboardSummary = { - totalVehicles: vehicles.length, - upcomingMaintenanceCount: upcomingMaintenance.length, - recentFuelLogsCount: recentFuelLogs.length, - }; - - // Calculate vehicles needing attention (using already-fetched schedules) - const vehiclesNeedingAttention: VehicleNeedingAttention[] = []; - - for (const vehicle of vehicles) { - const schedules = schedulesByVehicle.get(vehicle.id) || []; - - const overdueSchedules = schedules.filter(schedule => { - if (!schedule.nextDueDate) return false; - const dueDate = new Date(schedule.nextDueDate); - return dueDate < now; - }); - - if (overdueSchedules.length > 0) { - const mostOverdue = overdueSchedules.reduce((oldest, current) => { - const oldestDate = new Date(oldest.nextDueDate!); - const currentDate = new Date(current.nextDueDate!); - return currentDate < oldestDate ? current : oldest; - }); - - const daysOverdue = Math.floor( - (now.getTime() - new Date(mostOverdue.nextDueDate!).getTime()) / (1000 * 60 * 60 * 24) - ); - - let priority: 'high' | 'medium' | 'low' = 'low'; - if (daysOverdue > 30) { - priority = 'high'; - } else if (daysOverdue > 14) { - priority = 'medium'; - } - - vehiclesNeedingAttention.push({ - ...vehicle, - reason: `${overdueSchedules.length} overdue maintenance ${overdueSchedules.length === 1 ? 'item' : 'items'}`, - priority, - }); - } + // Fetch document expiry data (insurance + registration) with graceful degradation + let expiryDocs: DocumentRecord[] = []; + try { + const [insuranceDocs, registrationDocs] = await Promise.all([ + documentsApi.list({ type: 'insurance' }), + documentsApi.list({ type: 'registration' }), + ]); + expiryDocs = [...insuranceDocs, ...registrationDocs] + .filter(d => d.expirationDate != null); + } catch { + // Gracefully degrade: dashboard still works with maintenance-only health data } - // Sort by priority (high -> medium -> low) - const priorityOrder = { high: 0, medium: 1, low: 2 }; - vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + // Group documents by vehicleId + const documentsByVehicle = new Map(); + for (const doc of expiryDocs) { + const vehicleId = doc.vehicleId; + if (!documentsByVehicle.has(vehicleId)) { + documentsByVehicle.set(vehicleId, []); + } + documentsByVehicle.get(vehicleId)!.push(doc); + } - // Build recent activity feed - const vehicleMap = new Map(vehicles.map(v => [v.id, v])); + // Compute roster data per vehicle + const roster: VehicleRosterData[] = vehicles.map(vehicle => { + const schedules = schedulesByVehicle.get(vehicle.id) || []; + const documents = documentsByVehicle.get(vehicle.id) || []; + const { health, attentionItems } = computeVehicleHealth(schedules, documents); + return { vehicle, health, attentionItems }; + }); - const fuelActivity: RecentActivityItem[] = recentFuelLogs.map(log => ({ - type: 'fuel' as const, - vehicleId: log.vehicleId, - vehicleName: getVehicleLabel(vehicleMap.get(log.vehicleId)), - description: `Filled ${log.fuelUnits.toFixed(1)} gal at $${log.costPerUnit.toFixed(2)}/gal`, - timestamp: log.dateTime, - })); - - const maintenanceActivity: RecentActivityItem[] = upcomingMaintenance.map(schedule => ({ - type: 'maintenance' as const, - vehicleId: schedule.vehicleId, - vehicleName: getVehicleLabel(vehicleMap.get(schedule.vehicleId)), - description: `${schedule.category.replace(/_/g, ' ')} due${schedule.nextDueDate ? ` ${new Date(schedule.nextDueDate).toLocaleDateString()}` : ''}`, - timestamp: schedule.nextDueDate || now.toISOString(), - })); - - const recentActivity = [...fuelActivity, ...maintenanceActivity] - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 7); - - return { summary, vehiclesNeedingAttention, recentActivity }; + return { vehicles, schedulesByVehicle, documentsByVehicle, roster }; }, enabled: isAuthenticated && !authLoading, - staleTime: 2 * 60 * 1000, // 2 minutes - gcTime: 5 * 60 * 1000, // 5 minutes cache time - retry: (failureCount, error: any) => { - if (error?.response?.status === 401 && failureCount < 3) { - console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`); + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: (failureCount, error: unknown) => { + const status = (error as { response?: { status?: number } })?.response?.status; + if (status === 401 && failureCount < 3) { return true; } return false; @@ -157,44 +95,14 @@ export const useDashboardData = () => { }; /** - * Hook to fetch dashboard summary stats - * Derives from unified dashboard data query + * Derived hook returning vehicle roster data for the dashboard grid. */ -export const useDashboardSummary = () => { +export const useVehicleRoster = () => { const { data, isLoading, error, refetch } = useDashboardData(); return { - data: data?.summary, - isLoading, - error, - refetch, - }; -}; - -/** - * Hook to fetch recent activity feed - * Derives from unified dashboard data query - */ -export const useRecentActivity = () => { - const { data, isLoading, error, refetch } = useDashboardData(); - - return { - data: data?.recentActivity, - isLoading, - error, - refetch, - }; -}; - -/** - * Hook to fetch vehicles needing attention (overdue maintenance) - * Derives from unified dashboard data query - */ -export const useVehiclesNeedingAttention = () => { - const { data, isLoading, error, refetch } = useDashboardData(); - - return { - data: data?.vehiclesNeedingAttention, + data: data?.roster, + vehicles: data?.vehicles, isLoading, error, refetch, diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts index cac9ef7..cc7963a 100644 --- a/frontend/src/features/dashboard/index.ts +++ b/frontend/src/features/dashboard/index.ts @@ -4,9 +4,7 @@ export { DashboardScreen } from './components/DashboardScreen'; export { DashboardPage } from './pages/DashboardPage'; -export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards'; -export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention'; -export { QuickActions, QuickActionsSkeleton } from './components/QuickActions'; -export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity'; -export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData'; -export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types'; +export { VehicleRosterCard } from './components/VehicleRosterCard'; +export { ActionBar } from './components/ActionBar'; +export { useVehicleRoster } from './hooks/useDashboardData'; +export type { VehicleHealth, AttentionItem, VehicleRosterData } from './types'; diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx index 39bd4d7..68f7084 100644 --- a/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { DashboardScreen } from '../components/DashboardScreen'; import { MobileScreen } from '../../../core/store'; import { Vehicle } from '../../vehicles/types/vehicles.types'; @@ -49,9 +49,6 @@ export const DashboardPage: React.FC = () => { return ( - - Dashboard - 0 + ? schedule.subtypes[0] + : schedule.category.replace(/_/g, ' '); + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'maintenance' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'maintenance' }); + } + } + + // Document expiry attention items (insurance, registration) + for (const doc of documents) { + if (!doc.expirationDate) continue; + const expiryDate = new Date(doc.expirationDate); + const daysUntil = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const label = doc.documentType === 'insurance' ? 'Insurance' : 'Registration'; + + if (daysUntil < 0) { + items.push({ label, urgency: 'overdue', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 14) { + items.push({ label, urgency: 'due-soon', daysUntilDue: daysUntil, source: 'document' }); + } else if (daysUntil <= 30) { + items.push({ label, urgency: 'upcoming', daysUntilDue: daysUntil, source: 'document' }); + } + } + + // Sort: overdue first (most overdue at top), then due-soon by proximity, then upcoming + const urgencyOrder = { overdue: 0, 'due-soon': 1, upcoming: 2 }; + items.sort((a, b) => { + const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; + if (urgencyDiff !== 0) return urgencyDiff; + return a.daysUntilDue - b.daysUntilDue; + }); + + // Determine health color + const hasOverdue = items.some(i => i.urgency === 'overdue'); + const hasDueSoon = items.some(i => i.urgency === 'due-soon'); + + let health: VehicleHealth = 'green'; + if (hasOverdue) health = 'red'; + else if (hasDueSoon) health = 'yellow'; + + return { health, attentionItems: items.slice(0, 3) }; +} diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index f20aae7..0aff345 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -65,9 +65,10 @@ type FormData = z.infer; interface MaintenanceRecordFormProps { vehicleId?: string; + onSuccess?: () => void; } -export const MaintenanceRecordForm: React.FC = ({ vehicleId }) => { +export const MaintenanceRecordForm: React.FC = ({ vehicleId, onSuccess }) => { const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { createRecord, isRecordMutating } = useMaintenanceRecords(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -211,6 +212,7 @@ export const MaintenanceRecordForm: React.FC = ({ ve await createRecord(payload); toast.success('Maintenance record added successfully'); + onSuccess?.(); // Reset form reset({ diff --git a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx index b1504f8..8dbc6c3 100644 --- a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx +++ b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx @@ -3,13 +3,14 @@ */ import React, { useMemo, useState } from 'react'; -import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton } from '@mui/material'; +import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton, Dialog, DialogTitle, DialogContent } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { Vehicle } from '../types/vehicles.types'; import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs'; import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; +import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; import { VehicleImage } from '../components/VehicleImage'; import { OwnershipCostsList } from '../../ownership-costs'; import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; @@ -46,6 +47,7 @@ export const VehicleDetailMobile: React.FC = ({ const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id); const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); + const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false); // Unit conversions are now handled by the backend type VehicleRecord = { @@ -184,8 +186,11 @@ export const VehicleDetailMobile: React.FC = ({ > Add Fuel - @@ -300,6 +305,32 @@ export const VehicleDetailMobile: React.FC = ({ onClose={handleCloseEdit} onSave={handleSaveEdit} /> + + {/* Add Maintenance Dialog */} + setShowMaintenanceDialog(false)} + maxWidth="md" + fullWidth + fullScreen + PaperProps={{ + sx: { maxHeight: '90vh' } + }} + > + Add Maintenance + + + { + setShowMaintenanceDialog(false); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle.id] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] }); + }} + /> + + + ); }; diff --git a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx index da384cc..a50f37d 100644 --- a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx +++ b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx @@ -21,6 +21,7 @@ import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs'; import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types'; import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'; import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm'; +import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm'; // Unit conversions now handled by backend import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { OwnershipCostsList } from '../../ownership-costs'; @@ -63,6 +64,7 @@ export const VehicleDetailPage: React.FC = () => { const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); + const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [documentToDelete, setDocumentToDelete] = useState(null); const isSmallScreen = useMediaQuery('(max-width:600px)'); @@ -354,12 +356,13 @@ export const VehicleDetailPage: React.FC = () => { > Add Fuel Log - } sx={{ borderRadius: '999px' }} + onClick={() => setShowMaintenanceDialog(true)} > - Schedule Maintenance + Add Maintenance @@ -545,6 +548,32 @@ export const VehicleDetailPage: React.FC = () => {
+ {/* Add Maintenance Dialog */} + setShowMaintenanceDialog(false)} + maxWidth="md" + fullWidth + fullScreen={isSmallScreen} + PaperProps={{ + sx: { maxHeight: '90vh' } + }} + > + Add Maintenance + + + { + setShowMaintenanceDialog(false); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle?.id] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] }); + }} + /> + + + + {/* Delete Document Confirmation Dialog */} { - const { loginWithRedirect, isAuthenticated } = useAuth0(); + const { loginWithRedirect, isAuthenticated, logout } = useAuth0(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + const [sessionCleared, setSessionCleared] = useState(false); const navigate = useNavigate(); useEffect(() => { @@ -41,6 +42,22 @@ export const HomePage = () => { navigate('/signup'); }; + const handleClearSession = async () => { + try { + const { indexedDBStorage } = await import('../core/utils/indexeddb-storage'); + await indexedDBStorage.clearAll(); + Object.keys(localStorage).forEach(key => { + if (key.startsWith('@@auth0')) localStorage.removeItem(key); + }); + logout({ openUrl: false }); + setSessionCleared(true); + setTimeout(() => setSessionCleared(false), 3000); + } catch (error) { + console.error('[HomePage] Failed to clear session:', error); + window.location.reload(); + } + }; + return (
{/* Navigation Bar */} @@ -84,6 +101,12 @@ export const HomePage = () => { > Login +
{/* Mobile Menu Button */} @@ -149,6 +172,12 @@ export const HomePage = () => { > Login + )}