Merge branch 'main' of 172.30.1.72:egullickson/motovaultpro
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m24s
Deploy to Staging / Deploy to Staging (push) Successful in 22s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 3m24s
Deploy to Staging / Deploy to Staging (push) Successful in 22s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
This commit is contained in:
@@ -17,6 +17,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'(.*/core/api/client)$': '<rootDir>/src/core/api/__mocks__/client.ts',
|
||||||
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
||||||
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
// Jest setup for React Testing Library
|
// Jest setup for React Testing Library
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Polyfill TextEncoder/TextDecoder for jsdom (required by Auth0 SDK)
|
||||||
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
|
Object.assign(global, { TextEncoder, TextDecoder });
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react';
|
import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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 { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { useIsAuthInitialized } from './core/auth/auth-gate';
|
import { useIsAuthInitialized } from './core/auth/auth-gate';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
@@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC<EditVehicleScreenProps> = ({ vehicle, onBack,
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isLoading, isAuthenticated, user } = useAuth0();
|
const { isLoading, isAuthenticated, user, error: authError } = useAuth0();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const isAuthGateReady = useIsAuthInitialized();
|
const isAuthGateReady = useIsAuthInitialized();
|
||||||
const [_isPending, startTransition] = useTransition();
|
const [_isPending, startTransition] = useTransition();
|
||||||
console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady);
|
|
||||||
|
|
||||||
// Initialize data synchronization
|
// Initialize data synchronization
|
||||||
const { prefetchForNavigation } = useDataSync();
|
const { prefetchForNavigation } = useDataSync();
|
||||||
@@ -365,17 +365,24 @@ function App() {
|
|||||||
const [showAddVehicle, setShowAddVehicle] = useState(false);
|
const [showAddVehicle, setShowAddVehicle] = useState(false);
|
||||||
|
|
||||||
// Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile)
|
// 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(() => {
|
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) {
|
if (screen && screen !== activeScreen) {
|
||||||
navigateToScreen(screen, { source: 'url-sync' });
|
navigateToScreen(screen, { source: 'url-sync' });
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount
|
}, []); // 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)
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path === '/callback' || path === '/signup' || path === '/verify-email') return;
|
||||||
const targetPath = screenToRoute[activeScreen];
|
const targetPath = screenToRoute[activeScreen];
|
||||||
if (targetPath && window.location.pathname !== targetPath) {
|
if (targetPath && path !== targetPath) {
|
||||||
window.history.replaceState(null, '', targetPath);
|
window.history.replaceState(null, '', targetPath);
|
||||||
}
|
}
|
||||||
}, [activeScreen]);
|
}, [activeScreen]);
|
||||||
@@ -486,7 +493,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [navigateToScreen, navigateToVehicleSubScreen]);
|
}, [navigateToScreen, navigateToVehicleSubScreen]);
|
||||||
|
|
||||||
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
|
|
||||||
|
|
||||||
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
|
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
|
||||||
const isCallbackRoute = location.pathname === '/callback';
|
const isCallbackRoute = location.pathname === '/callback';
|
||||||
@@ -496,6 +502,16 @@ function App() {
|
|||||||
const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute;
|
const isAuthRoute = isSignupRoute || isVerifyEmailRoute || isOnboardingRoute;
|
||||||
const shouldShowHomePage = !isGarageRoute && !isCallbackRoute && !isAuthRoute;
|
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
|
// Enhanced navigation handlers for mobile
|
||||||
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
|
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
|
||||||
setSelectedVehicle(vehicle);
|
setSelectedVehicle(vehicle);
|
||||||
@@ -557,18 +573,55 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback route requires authentication - handled by CallbackPage component
|
if (isCallbackRoute) {
|
||||||
if (isCallbackRoute && isAuthenticated) {
|
if (authError) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-4">
|
||||||
|
<div className="text-lg text-red-600 text-center">Login failed: {authError.message}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Return to Homepage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<React.Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-lg">Processing login...</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{mobileMode ? <CallbackMobileScreen /> : <CallbackPage />}
|
||||||
|
</React.Suspense>
|
||||||
|
<DebugInfo />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (callbackTimedOut) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
if (mobileMode) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<Layout mobileMode={true}>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-500">Processing login...</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<React.Suspense fallback={
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="text-lg">Processing login...</div>
|
||||||
<div className="text-lg">Processing login...</div>
|
</div>
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
{mobileMode ? <CallbackMobileScreen /> : <CallbackPage />}
|
|
||||||
</React.Suspense>
|
|
||||||
<DebugInfo />
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -637,7 +690,6 @@ function App() {
|
|||||||
// Wait for auth gate to be ready before rendering protected routes
|
// 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
|
// This prevents a race condition where the page renders before the auth token is ready
|
||||||
if (!isAuthGateReady) {
|
if (!isAuthGateReady) {
|
||||||
console.log('[DEBUG App] Auth gate not ready yet, showing loading state');
|
|
||||||
if (mobileMode) {
|
if (mobileMode) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
15
frontend/src/core/api/__mocks__/client.ts
Normal file
15
frontend/src/core/api/__mocks__/client.ts
Normal file
@@ -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() },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { apiClient, setAuthReady } from '../api/client';
|
import { apiClient, setAuthReady } from '../api/client';
|
||||||
import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
|
import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
|
||||||
import { setAuthInitialized } from './auth-gate';
|
import { setAuthInitialized } from './auth-gate';
|
||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
interface Auth0ProviderProps {
|
interface Auth0ProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -20,12 +21,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
|||||||
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
||||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
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 }) => {
|
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
|
// Route to callback page which will check user status and redirect appropriately
|
||||||
// Pass the intended destination as state for after status check
|
// Pass the intended destination as state for after status check
|
||||||
navigate('/callback', {
|
navigate('/callback', {
|
||||||
@@ -57,17 +54,9 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Component to inject token into API client with mobile support
|
// Component to inject token into API client with mobile support
|
||||||
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
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);
|
const [retryCount, setRetryCount] = React.useState(0);
|
||||||
|
const validatingRef = React.useRef(false);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Helper function to get token with enhanced retry logic for mobile devices
|
// Helper function to get token with enhanced retry logic for mobile devices
|
||||||
const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => {
|
const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => {
|
||||||
@@ -93,77 +82,49 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await getAccessTokenSilently(tokenOptions);
|
const token = await getAccessTokenSilently(tokenOptions);
|
||||||
console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, {
|
logger.debug(`Token acquired on attempt ${attempt + 1}`);
|
||||||
cacheMode: tokenOptions.cacheMode,
|
|
||||||
timeout: tokenOptions.timeoutInSeconds
|
|
||||||
});
|
|
||||||
return token;
|
return token;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, {
|
logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, {
|
||||||
error: error.message || error,
|
error: error.message || String(error),
|
||||||
cacheMode: attempt <= 2 ? 'on' : 'off'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile-specific: longer delays and more attempts
|
// Mobile-specific: longer delays and more attempts
|
||||||
if (attempt < maxRetries - 1) {
|
if (attempt < maxRetries - 1) {
|
||||||
const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff
|
const delay = delayMs * Math.pow(1.5, attempt);
|
||||||
console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
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;
|
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(() => {
|
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
|
const validateToken = async () => {
|
||||||
console.log('[Auth Debug] State check:', {
|
validatingRef.current = true;
|
||||||
isMobile,
|
try {
|
||||||
isLoading,
|
await getAccessTokenSilently({ cacheMode: 'off', timeoutInSeconds: 10 });
|
||||||
isAuthenticated,
|
} catch (error: any) {
|
||||||
pathname: window.location.pathname,
|
const errorType = error?.error || error?.message || '';
|
||||||
userAgent: navigator.userAgent.substring(0, 50) + '...'
|
if (errorType.includes('login_required') || errorType.includes('consent_required') ||
|
||||||
});
|
errorType.includes('invalid_grant')) {
|
||||||
|
logger.warn('Stale token detected, clearing auth state');
|
||||||
// Trigger for mobile devices OR any device on protected route without authentication
|
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
|
||||||
if (!isLoading && !isAuthenticated && window.location.pathname !== '/') {
|
await indexedDBStorage.clearAll();
|
||||||
console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...');
|
logout({ openUrl: false });
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
} finally {
|
||||||
|
validatingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
forceAuthCheck();
|
validateToken();
|
||||||
}
|
}, [isAuthenticated, isLoading, getAccessTokenSilently, logout]);
|
||||||
}, [isLoading, isAuthenticated, getAccessTokenSilently]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let interceptorId: number | undefined;
|
let interceptorId: number | undefined;
|
||||||
@@ -175,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
try {
|
try {
|
||||||
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
|
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
|
||||||
await indexedDBStorage.waitForReady();
|
await indexedDBStorage.waitForReady();
|
||||||
console.log('[Auth] IndexedDB storage is ready');
|
logger.debug('IndexedDB storage is ready');
|
||||||
} catch (error) {
|
} 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)
|
// 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);
|
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
if (isMobile) {
|
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));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
} else {
|
|
||||||
console.log('[Auth] Initializing token cache (desktop, no delay)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await getTokenWithRetry();
|
const token = await getTokenWithRetry();
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log('[Mobile Auth] Token pre-warming successful');
|
logger.debug('Token pre-warming successful');
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
setAuthInitialized(true); // Signal that auth is fully ready
|
setAuthInitialized(true); // Signal that auth is fully ready
|
||||||
} else {
|
} 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);
|
setRetryCount(prev => prev + 1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Mobile Auth] Token initialization failed:', error);
|
logger.error('Token initialization failed', { error: String(error) });
|
||||||
setRetryCount(prev => prev + 1);
|
setRetryCount(prev => prev + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -221,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
} else {
|
} 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
|
// Allow request to proceed - backend will return 401 if needed
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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
|
// Allow request to proceed - backend will return 401 if needed
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
|
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
interface StorageAdapter {
|
interface StorageAdapter {
|
||||||
getItem(key: string): string | null;
|
getItem(key: string): string | null;
|
||||||
setItem(key: string, value: string): void;
|
setItem(key: string, value: string): void;
|
||||||
@@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
this.db = await this.openDatabase();
|
this.db = await this.openDatabase();
|
||||||
await this.loadCacheFromDB();
|
await this.loadCacheFromDB();
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
console.log('[IndexedDB] Storage initialized successfully');
|
logger.debug('IndexedDB storage initialized successfully');
|
||||||
} catch (error) {
|
} 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;
|
this.isReady = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||||
|
|
||||||
request.onerror = () => {
|
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
|
resolve(null as any); // Fallback to memory-only mode
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,25 +73,27 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.getAll();
|
const request = store.openCursor();
|
||||||
|
this.memoryCache.clear();
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const results = request.result;
|
const cursor = request.result;
|
||||||
this.memoryCache.clear();
|
if (cursor) {
|
||||||
|
const key = cursor.key as string;
|
||||||
for (const item of results) {
|
const value = cursor.value;
|
||||||
if (item.key && typeof item.value === 'string') {
|
if (typeof key === 'string' && typeof value === 'string') {
|
||||||
this.memoryCache.set(item.key, item.value);
|
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 = () => {
|
request.onerror = () => {
|
||||||
console.warn('[IndexedDB] Failed to load cache from DB:', request.error);
|
logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) });
|
||||||
resolve(); // Don't fail initialization
|
resolve();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -105,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
const request = store.delete(key);
|
const request = store.delete(key);
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => resolve();
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error);
|
logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) });
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const request = store.put(value, key);
|
const request = store.put(value, key);
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => resolve();
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error);
|
logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) });
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
// Async persist to IndexedDB (non-blocking)
|
// Async persist to IndexedDB (non-blocking)
|
||||||
if (this.isReady) {
|
if (this.isReady) {
|
||||||
this.persistToDB(key, value).catch(error => {
|
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)
|
// Async remove from IndexedDB (non-blocking)
|
||||||
if (this.isReady) {
|
if (this.isReady) {
|
||||||
this.persistToDB(key, null).catch(error => {
|
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<void> {
|
||||||
|
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<void>((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 {
|
key(index: number): string | null {
|
||||||
const keys = Array.from(this.memoryCache.keys());
|
const keys = Array.from(this.memoryCache.keys());
|
||||||
return keys[index] || null;
|
return keys[index] || null;
|
||||||
@@ -167,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth0 Cache interface implementation
|
// 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<any> {
|
async get(key: string): Promise<any> {
|
||||||
await this.initPromise;
|
await this.initPromise;
|
||||||
const value = this.getItem(key);
|
const value = this.getItem(key);
|
||||||
@@ -175,12 +208,21 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
|||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: any): Promise<void> {
|
||||||
await this.initPromise;
|
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<void> {
|
async remove(key: string): Promise<void> {
|
||||||
await this.initPromise;
|
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
|
// Additional methods for enhanced functionality
|
||||||
|
|||||||
38
frontend/src/features/dashboard/components/ActionBar.tsx
Normal file
38
frontend/src/features/dashboard/components/ActionBar.tsx
Normal file
@@ -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<ActionBarProps> = ({ onAddVehicle, onLogFuel }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={onAddVehicle}
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Add Vehicle
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<LocalGasStation />}
|
||||||
|
onClick={onLogFuel}
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
Log Fuel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 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 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 CloseIcon from '@mui/icons-material/Close';
|
||||||
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
|
import { VehicleRosterCard } from './VehicleRosterCard';
|
||||||
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
|
import { ActionBar } from './ActionBar';
|
||||||
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
|
import { useVehicleRoster } from '../hooks/useDashboardData';
|
||||||
import { RecentActivity, RecentActivitySkeleton } from './RecentActivity';
|
|
||||||
import { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } 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 { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
|
||||||
import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList';
|
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';
|
||||||
|
|
||||||
interface DashboardScreenProps {
|
interface DashboardScreenProps {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches navigation store type signature
|
||||||
onNavigate?: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
onNavigate?: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
||||||
onVehicleClick?: (vehicle: Vehicle) => void;
|
onVehicleClick?: (vehicle: Vehicle) => void;
|
||||||
onViewMaintenance?: () => void;
|
onViewMaintenance?: () => void;
|
||||||
onAddVehicle?: () => void;
|
onAddVehicle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RosterSkeleton: React.FC = () => (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<GlassCard key={i}>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton variant="text" width="60%" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="circular" width={12} height={12} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
<Skeleton variant="text" width="80%" />
|
||||||
|
<Skeleton variant="text" width="80%" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="text" width="30%" />
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onVehicleClick,
|
onVehicleClick,
|
||||||
onViewMaintenance,
|
onAddVehicle,
|
||||||
onAddVehicle
|
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const [showPendingReceipts, setShowPendingReceipts] = useState(false);
|
const [showPendingReceipts, setShowPendingReceipts] = useState(false);
|
||||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
|
const { data: roster, vehicles, isLoading, error } = useVehicleRoster();
|
||||||
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
|
|
||||||
const { data: recentActivity } = useRecentActivity();
|
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
|
// Error state
|
||||||
if (summaryError || attentionError) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
@@ -69,19 +94,21 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) {
|
if (isLoading || !roster) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SummaryCardsSkeleton />
|
<div className="flex items-center justify-between">
|
||||||
<VehicleAttentionSkeleton />
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
<RecentActivitySkeleton />
|
Your Fleet
|
||||||
<QuickActionsSkeleton />
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<RosterSkeleton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state - no vehicles
|
// Empty state - no vehicles
|
||||||
if (summary.totalVehicles === 0) {
|
if (roster.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
@@ -98,7 +125,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
|
onClick={handleAddVehicle}
|
||||||
>
|
>
|
||||||
Add Your First Vehicle
|
Add Your First Vehicle
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,32 +141,24 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
{/* Pending Receipts Banner */}
|
{/* Pending Receipts Banner */}
|
||||||
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
|
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Heading + Action Bar */}
|
||||||
<SummaryCards summary={summary} onNavigate={onNavigate} />
|
<div className="flex items-center justify-between">
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
|
Your Fleet
|
||||||
|
</Typography>
|
||||||
|
<ActionBar onAddVehicle={handleAddVehicle} onLogFuel={handleLogFuel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Vehicles Needing Attention */}
|
{/* Vehicle Roster Grid */}
|
||||||
{vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && (
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<VehicleAttention
|
{roster.map(rosterData => (
|
||||||
vehicles={vehiclesNeedingAttention}
|
<VehicleRosterCard
|
||||||
onVehicleClick={(vehicleId) => {
|
key={rosterData.vehicle.id}
|
||||||
const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId);
|
data={rosterData}
|
||||||
if (vehicle && onVehicleClick) {
|
onClick={handleVehicleClick}
|
||||||
onVehicleClick(vehicle);
|
/>
|
||||||
}
|
))}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
{recentActivity && <RecentActivity items={recentActivity} />}
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<QuickActions
|
|
||||||
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
|
|
||||||
onLogFuel={() => onNavigate?.('Log Fuel')}
|
|
||||||
onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))}
|
|
||||||
onViewVehicles={() => onNavigate?.('Vehicles')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pending Receipts Dialog */}
|
{/* Pending Receipts Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* @ai-summary Quick action buttons for common tasks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Box, SvgIconProps } from '@mui/material';
|
|
||||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
|
||||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
|
||||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
|
||||||
import FormatListBulletedRoundedIcon from '@mui/icons-material/FormatListBulletedRounded';
|
|
||||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
|
||||||
|
|
||||||
interface QuickAction {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: React.ComponentType<SvgIconProps>;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuickActionsProps {
|
|
||||||
onAddVehicle: () => void;
|
|
||||||
onLogFuel: () => void;
|
|
||||||
onViewMaintenance: () => void;
|
|
||||||
onViewVehicles: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QuickActions: React.FC<QuickActionsProps> = ({
|
|
||||||
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 (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
|
||||||
Quick Actions
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
|
||||||
Common tasks and navigation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{actions.map((action) => {
|
|
||||||
const IconComponent = action.icon;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={action.id}
|
|
||||||
component="button"
|
|
||||||
onClick={action.onClick}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
textAlign: 'left',
|
|
||||||
minHeight: { xs: 100, sm: 120 },
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.selected',
|
|
||||||
borderColor: 'divider',
|
|
||||||
},
|
|
||||||
'&:focus': {
|
|
||||||
outline: 'none',
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
color: 'primary.main',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconComponent sx={{ fontSize: 28 }} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: 'text.primary',
|
|
||||||
mb: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.title}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'none', sm: 'block' },
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'text.secondary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.description}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const QuickActionsSkeleton: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
|
|
||||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800 min-h-[100px] sm:min-h-[120px]"
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 bg-slate-100 dark:bg-slate-700 rounded animate-pulse mb-3" />
|
|
||||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20 mb-2" />
|
|
||||||
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-full hidden sm:block" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<RecentActivityProps> = ({ items }) => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
|
|
||||||
Recent Activity
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-400 dark:text-canna text-center py-4">
|
|
||||||
No recent activity. Start by logging fuel or scheduling maintenance.
|
|
||||||
</p>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
|
|
||||||
Recent Activity
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={`${item.type}-${item.timestamp}-${index}`}
|
|
||||||
className="flex items-start gap-3 py-2"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 2,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.type === 'fuel' ? (
|
|
||||||
<LocalGasStationRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
|
|
||||||
) : (
|
|
||||||
<BuildRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-avus truncate">
|
|
||||||
{item.vehicleName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-titanio truncate">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-400 dark:text-canna whitespace-nowrap flex-shrink-0">
|
|
||||||
{formatRelativeTime(item.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecentActivitySkeleton: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="h-5 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-3" />
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 animate-pulse" />
|
|
||||||
<div className="flex-1 space-y-1.5">
|
|
||||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
|
|
||||||
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<SummaryCardsProps> = ({ 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 (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{cards.map((card) => {
|
|
||||||
const IconComponent = card.icon;
|
|
||||||
return (
|
|
||||||
<GlassCard key={card.title} padding="md">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 3,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconComponent sx={{ fontSize: 24, color: card.color }} />
|
|
||||||
</Box>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm text-slate-500 dark:text-titanio font-medium mb-1">
|
|
||||||
{card.title}
|
|
||||||
</p>
|
|
||||||
<Box
|
|
||||||
component="p"
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.875rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'text.primary',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.value}
|
|
||||||
</Box>
|
|
||||||
{card.value === 0 && card.ctaText ? (
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
onClick={() => 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}
|
|
||||||
</Box>
|
|
||||||
) : card.subtitle ? (
|
|
||||||
<p className="text-xs text-slate-400 dark:text-canna mt-1">
|
|
||||||
{card.subtitle}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SummaryCardsSkeleton: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<GlassCard key={i} padding="md">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 animate-pulse" />
|
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
|
||||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
|
|
||||||
<div className="h-8 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-16" />
|
|
||||||
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<VehicleAttentionProps> = ({ vehicles, onVehicleClick }) => {
|
|
||||||
if (vehicles.length === 0) {
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Box sx={{ color: 'success.main', mb: 1.5 }}>
|
|
||||||
<CheckCircleRoundedIcon sx={{ fontSize: 48 }} />
|
|
||||||
</Box>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
|
|
||||||
All Caught Up!
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
|
||||||
No vehicles need immediate attention
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const priorityConfig: Record<string, { color: string; icon: React.ComponentType<SvgIconProps> }> = {
|
|
||||||
high: {
|
|
||||||
color: 'error.main',
|
|
||||||
icon: ErrorRoundedIcon,
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
color: 'warning.main',
|
|
||||||
icon: WarningAmberRoundedIcon,
|
|
||||||
},
|
|
||||||
low: {
|
|
||||||
color: 'info.main',
|
|
||||||
icon: ScheduleRoundedIcon,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
|
||||||
Needs Attention
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
|
||||||
Vehicles with overdue maintenance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{vehicles.map((vehicle) => {
|
|
||||||
const config = priorityConfig[vehicle.priority];
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={vehicle.id}
|
|
||||||
onClick={() => 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',
|
|
||||||
} : {},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Box sx={{ flexShrink: 0, color: config.color }}>
|
|
||||||
<IconComponent sx={{ fontSize: 24 }} />
|
|
||||||
</Box>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Box
|
|
||||||
component="h4"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'text.primary',
|
|
||||||
fontSize: '1rem',
|
|
||||||
mb: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getVehicleLabel(vehicle)}
|
|
||||||
</Box>
|
|
||||||
<p className="text-sm text-slate-600 dark:text-titanio">
|
|
||||||
{vehicle.reason}
|
|
||||||
</p>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-block',
|
|
||||||
mt: 1,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
bgcolor: 'action.selected',
|
|
||||||
color: config.color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vehicle.priority.toUpperCase()} PRIORITY
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VehicleAttentionSkeleton: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<GlassCard padding="md">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
|
|
||||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
|
|
||||||
</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-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-24 mt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
103
frontend/src/features/dashboard/components/VehicleRosterCard.tsx
Normal file
103
frontend/src/features/dashboard/components/VehicleRosterCard.tsx
Normal file
@@ -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<VehicleRosterCardProps> = ({
|
||||||
|
data,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const { vehicle, health, attentionItems } = data;
|
||||||
|
const displayedItems = attentionItems.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard onClick={() => onClick(vehicle.id)}>
|
||||||
|
{/* Top row: Image, Label, Health Badge */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{/* Vehicle image container - clips the built-in mb-2 margin */}
|
||||||
|
<div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
<VehicleImage vehicle={vehicle} height={48} borderRadius={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle label */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-base font-semibold text-slate-800 dark:text-avus truncate">
|
||||||
|
{getVehicleLabel(vehicle)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health badge */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'w-3 h-3 rounded-full flex-shrink-0',
|
||||||
|
getHealthBadgeClass(health)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attention items */}
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
{displayedItems.length === 0 ? (
|
||||||
|
<div className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
All clear
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayedItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={clsx('text-sm', getAttentionItemClass(item.urgency))}
|
||||||
|
>
|
||||||
|
{item.label} - {formatAttentionStatus(item)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Odometer */}
|
||||||
|
<div className="text-sm text-slate-500 dark:text-titanio">
|
||||||
|
{vehicle.odometerReading.toLocaleString()} mi
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
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(<ActionBar onAddVehicle={onAddVehicle} onLogFuel={onLogFuel} />);
|
||||||
|
|
||||||
|
const logFuelButton = screen.getByText('Log Fuel');
|
||||||
|
fireEvent.click(logFuelButton);
|
||||||
|
|
||||||
|
expect(onLogFuel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <div data-testid="vehicle-image" />,
|
||||||
|
}));
|
||||||
|
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<typeof useVehicleRoster>;
|
||||||
|
|
||||||
|
const makeVehicle = (overrides: Partial<Vehicle> = {}): 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(
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
{ui}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
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(<DashboardScreen />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Your Fleet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <div data-testid="vehicle-image" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeVehicle = (overrides: Partial<Vehicle> = {}): 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> = {}): 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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Oil Change - OVERDUE')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders odometer with formatting', () => {
|
||||||
|
const data = makeRosterData();
|
||||||
|
const onClick = jest.fn();
|
||||||
|
|
||||||
|
render(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('87,412 mi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick with vehicle ID when clicked', () => {
|
||||||
|
const data = makeRosterData();
|
||||||
|
const onClick = jest.fn();
|
||||||
|
|
||||||
|
render(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
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(<VehicleRosterCard data={data} onClick={onClick} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('All clear')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary React Query hooks for dashboard data
|
* @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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
|
||||||
import { maintenanceApi } from '../../maintenance/api/maintenance.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 { 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 {
|
interface DashboardData {
|
||||||
summary: DashboardSummary;
|
vehicles: Vehicle[];
|
||||||
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
schedulesByVehicle: Map<string, MaintenanceSchedule[]>;
|
||||||
recentActivity: RecentActivityItem[];
|
documentsByVehicle: Map<string, DocumentRecord[]>;
|
||||||
|
roster: VehicleRosterData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified hook that fetches all dashboard data in a single query
|
* Unified hook that fetches all dashboard data in a single query.
|
||||||
* Prevents duplicate API calls for vehicles and maintenance schedules
|
* Fetches vehicles, maintenance schedules, and document expiry data.
|
||||||
*/
|
*/
|
||||||
export const useDashboardData = () => {
|
export const useDashboardData = () => {
|
||||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
||||||
@@ -31,123 +34,58 @@ export const useDashboardData = () => {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'all'],
|
queryKey: ['dashboard', 'all'],
|
||||||
queryFn: async (): Promise<DashboardData> => {
|
queryFn: async (): Promise<DashboardData> => {
|
||||||
// Fetch vehicles and fuel logs in parallel
|
// Fetch vehicles first (need IDs for schedule queries)
|
||||||
const [vehicles, fuelLogs] = await Promise.all([
|
const vehicles = await vehiclesApi.getAll();
|
||||||
vehiclesApi.getAll(),
|
|
||||||
fuelLogsApi.getUserFuelLogs(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fetch all maintenance schedules in parallel (not sequential!)
|
// Fetch maintenance schedules per vehicle in parallel
|
||||||
const allSchedulesArrays = await Promise.all(
|
const allSchedulesArrays = await Promise.all(
|
||||||
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
|
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a map of vehicle ID to schedules for efficient lookup
|
|
||||||
const schedulesByVehicle = new Map<string, MaintenanceSchedule[]>();
|
const schedulesByVehicle = new Map<string, MaintenanceSchedule[]>();
|
||||||
vehicles.forEach((vehicle, index) => {
|
vehicles.forEach((vehicle, index) => {
|
||||||
schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]);
|
schedulesByVehicle.set(vehicle.id, allSchedulesArrays[index]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const flatSchedules = allSchedulesArrays.flat();
|
// Fetch document expiry data (insurance + registration) with graceful degradation
|
||||||
const now = new Date();
|
let expiryDocs: DocumentRecord[] = [];
|
||||||
|
try {
|
||||||
// Calculate summary stats
|
const [insuranceDocs, registrationDocs] = await Promise.all([
|
||||||
const thirtyDaysFromNow = new Date();
|
documentsApi.list({ type: 'insurance' }),
|
||||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
documentsApi.list({ type: 'registration' }),
|
||||||
|
]);
|
||||||
const upcomingMaintenance = flatSchedules.filter(schedule => {
|
expiryDocs = [...insuranceDocs, ...registrationDocs]
|
||||||
if (!schedule.nextDueDate) return false;
|
.filter(d => d.expirationDate != null);
|
||||||
const dueDate = new Date(schedule.nextDueDate);
|
} catch {
|
||||||
return dueDate >= now && dueDate <= thirtyDaysFromNow;
|
// Gracefully degrade: dashboard still works with maintenance-only health data
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority (high -> medium -> low)
|
// Group documents by vehicleId
|
||||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
const documentsByVehicle = new Map<string, DocumentRecord[]>();
|
||||||
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
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
|
// Compute roster data per vehicle
|
||||||
const vehicleMap = new Map(vehicles.map(v => [v.id, v]));
|
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 => ({
|
return { vehicles, schedulesByVehicle, documentsByVehicle, roster };
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated && !authLoading,
|
enabled: isAuthenticated && !authLoading,
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
staleTime: 2 * 60 * 1000,
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
gcTime: 5 * 60 * 1000,
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: unknown) => {
|
||||||
if (error?.response?.status === 401 && failureCount < 3) {
|
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||||
console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`);
|
if (status === 401 && failureCount < 3) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -157,44 +95,14 @@ export const useDashboardData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch dashboard summary stats
|
* Derived hook returning vehicle roster data for the dashboard grid.
|
||||||
* Derives from unified dashboard data query
|
|
||||||
*/
|
*/
|
||||||
export const useDashboardSummary = () => {
|
export const useVehicleRoster = () => {
|
||||||
const { data, isLoading, error, refetch } = useDashboardData();
|
const { data, isLoading, error, refetch } = useDashboardData();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data?.summary,
|
data: data?.roster,
|
||||||
isLoading,
|
vehicles: data?.vehicles,
|
||||||
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,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
|
|
||||||
export { DashboardScreen } from './components/DashboardScreen';
|
export { DashboardScreen } from './components/DashboardScreen';
|
||||||
export { DashboardPage } from './pages/DashboardPage';
|
export { DashboardPage } from './pages/DashboardPage';
|
||||||
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards';
|
export { VehicleRosterCard } from './components/VehicleRosterCard';
|
||||||
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention';
|
export { ActionBar } from './components/ActionBar';
|
||||||
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions';
|
export { useVehicleRoster } from './hooks/useDashboardData';
|
||||||
export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity';
|
export type { VehicleHealth, AttentionItem, VehicleRosterData } from './types';
|
||||||
export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData';
|
|
||||||
export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types';
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Box, Typography } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { DashboardScreen } from '../components/DashboardScreen';
|
import { DashboardScreen } from '../components/DashboardScreen';
|
||||||
import { MobileScreen } from '../../../core/store';
|
import { MobileScreen } from '../../../core/store';
|
||||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
@@ -49,9 +49,6 @@ export const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ py: 2 }}>
|
<Box sx={{ py: 2 }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
|
||||||
Dashboard
|
|
||||||
</Typography>
|
|
||||||
<DashboardScreen
|
<DashboardScreen
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
onVehicleClick={handleVehicleClick}
|
onVehicleClick={handleVehicleClick}
|
||||||
|
|||||||
@@ -4,27 +4,17 @@
|
|||||||
|
|
||||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export type VehicleHealth = 'green' | 'yellow' | 'red';
|
||||||
totalVehicles: number;
|
|
||||||
upcomingMaintenanceCount: number;
|
export interface AttentionItem {
|
||||||
recentFuelLogsCount: number;
|
label: string;
|
||||||
|
urgency: 'overdue' | 'due-soon' | 'upcoming';
|
||||||
|
daysUntilDue: number;
|
||||||
|
source: 'maintenance' | 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleNeedingAttention extends Vehicle {
|
export interface VehicleRosterData {
|
||||||
reason: string;
|
vehicle: Vehicle;
|
||||||
priority: 'high' | 'medium' | 'low';
|
health: VehicleHealth;
|
||||||
}
|
attentionItems: AttentionItem[];
|
||||||
|
|
||||||
export interface RecentActivityItem {
|
|
||||||
type: 'fuel' | 'maintenance';
|
|
||||||
vehicleId: string;
|
|
||||||
vehicleName: string;
|
|
||||||
description: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardData {
|
|
||||||
summary: DashboardSummary;
|
|
||||||
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
|
||||||
recentActivity: RecentActivityItem[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Pure function to compute per-vehicle health status from maintenance and document data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
|
||||||
|
import { DocumentRecord } from '../../documents/types/documents.types';
|
||||||
|
import { VehicleHealth, AttentionItem } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute health status and attention items for a single vehicle.
|
||||||
|
* Pure function -- no React dependencies, easily unit-testable.
|
||||||
|
*/
|
||||||
|
export function computeVehicleHealth(
|
||||||
|
schedules: MaintenanceSchedule[],
|
||||||
|
documents: DocumentRecord[],
|
||||||
|
): { health: VehicleHealth; attentionItems: AttentionItem[] } {
|
||||||
|
const now = new Date();
|
||||||
|
const items: AttentionItem[] = [];
|
||||||
|
|
||||||
|
// Maintenance schedule attention items
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
if (!schedule.nextDueDate || !schedule.isActive) continue;
|
||||||
|
const dueDate = new Date(schedule.nextDueDate);
|
||||||
|
const daysUntil = Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const label = schedule.subtypes.length > 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) };
|
||||||
|
}
|
||||||
@@ -65,9 +65,10 @@ type FormData = z.infer<typeof schema>;
|
|||||||
|
|
||||||
interface MaintenanceRecordFormProps {
|
interface MaintenanceRecordFormProps {
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ vehicleId }) => {
|
export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ vehicleId, onSuccess }) => {
|
||||||
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
|
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
|
||||||
const { createRecord, isRecordMutating } = useMaintenanceRecords();
|
const { createRecord, isRecordMutating } = useMaintenanceRecords();
|
||||||
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
|
||||||
@@ -211,6 +212,7 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
|
|||||||
|
|
||||||
await createRecord(payload);
|
await createRecord(payload);
|
||||||
toast.success('Maintenance record added successfully');
|
toast.success('Maintenance record added successfully');
|
||||||
|
onSuccess?.();
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
reset({
|
reset({
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
|
import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm';
|
||||||
import { VehicleImage } from '../components/VehicleImage';
|
import { VehicleImage } from '../components/VehicleImage';
|
||||||
import { OwnershipCostsList } from '../../ownership-costs';
|
import { OwnershipCostsList } from '../../ownership-costs';
|
||||||
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
|
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
|
||||||
@@ -46,6 +47,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
|
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
|
const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false);
|
||||||
// Unit conversions are now handled by the backend
|
// Unit conversions are now handled by the backend
|
||||||
|
|
||||||
type VehicleRecord = {
|
type VehicleRecord = {
|
||||||
@@ -184,8 +186,11 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
>
|
>
|
||||||
Add Fuel
|
Add Fuel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined">
|
<Button
|
||||||
Maintenance
|
variant="outlined"
|
||||||
|
onClick={() => setShowMaintenanceDialog(true)}
|
||||||
|
>
|
||||||
|
Add Maintenance
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -300,6 +305,32 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
onClose={handleCloseEdit}
|
onClose={handleCloseEdit}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Maintenance Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={showMaintenanceDialog}
|
||||||
|
onClose={() => setShowMaintenanceDialog(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen
|
||||||
|
PaperProps={{
|
||||||
|
sx: { maxHeight: '90vh' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>Add Maintenance</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<MaintenanceRecordForm
|
||||||
|
vehicleId={vehicle.id}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowMaintenanceDialog(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
|||||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
||||||
|
import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm';
|
||||||
// Unit conversions now handled by backend
|
// Unit conversions now handled by backend
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
import { OwnershipCostsList } from '../../ownership-costs';
|
import { OwnershipCostsList } from '../../ownership-costs';
|
||||||
@@ -63,6 +64,7 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
|
||||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||||
@@ -355,11 +357,12 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
Add Fuel Log
|
Add Fuel Log
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
<MuiButton
|
<MuiButton
|
||||||
variant="outlined"
|
variant="contained"
|
||||||
startIcon={<BuildIcon />}
|
startIcon={<BuildIcon />}
|
||||||
sx={{ borderRadius: '999px' }}
|
sx={{ borderRadius: '999px' }}
|
||||||
|
onClick={() => setShowMaintenanceDialog(true)}
|
||||||
>
|
>
|
||||||
Schedule Maintenance
|
Add Maintenance
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -545,6 +548,32 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Maintenance Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={showMaintenanceDialog}
|
||||||
|
onClose={() => setShowMaintenanceDialog(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isSmallScreen}
|
||||||
|
PaperProps={{
|
||||||
|
sx: { maxHeight: '90vh' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>Add Maintenance</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<MaintenanceRecordForm
|
||||||
|
vehicleId={vehicle?.id}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowMaintenanceDialog(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle?.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Document Confirmation Dialog */}
|
{/* Delete Document Confirmation Dialog */}
|
||||||
<DeleteDocumentConfirmDialog
|
<DeleteDocumentConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { FeaturesGrid } from './HomePage/FeaturesGrid';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
export const HomePage = () => {
|
export const HomePage = () => {
|
||||||
const { loginWithRedirect, isAuthenticated } = useAuth0();
|
const { loginWithRedirect, isAuthenticated, logout } = useAuth0();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [sessionCleared, setSessionCleared] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +42,22 @@ export const HomePage = () => {
|
|||||||
navigate('/signup');
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-nero text-avus">
|
<div className="min-h-screen bg-nero text-avus">
|
||||||
{/* Navigation Bar */}
|
{/* Navigation Bar */}
|
||||||
@@ -84,6 +101,12 @@ export const HomePage = () => {
|
|||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearSession}
|
||||||
|
className="text-white/40 hover:text-white/70 text-xs transition-colors min-h-[44px] min-w-[44px] flex items-center"
|
||||||
|
>
|
||||||
|
{sessionCleared ? 'Session cleared' : 'Trouble logging in?'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
@@ -149,6 +172,12 @@ export const HomePage = () => {
|
|||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearSession}
|
||||||
|
className="w-full text-white/40 hover:text-white/70 text-xs py-2 min-h-[44px] transition-colors"
|
||||||
|
>
|
||||||
|
{sessionCleared ? 'Session cleared' : 'Trouble logging in?'}
|
||||||
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user