Initial Commit
This commit is contained in:
41
frontend/jest.config.ts
Normal file
41
frontend/jest.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Jest configuration for MotoVaultPro frontend (React + TS, ESM)
|
||||
*/
|
||||
import type { Config } from 'jest';
|
||||
const { createDefaultPreset } = require('ts-jest/presets');
|
||||
|
||||
const tsJestTransformCfg = {
|
||||
tsconfig: 'tsconfig.json',
|
||||
useESM: true,
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
testEnvironment: 'jsdom',
|
||||
roots: ['<rootDir>/src'],
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
transform: {
|
||||
...createDefaultPreset().transform,
|
||||
'^.+\\.(ts|tsx)$': ['ts-jest', tsJestTransformCfg],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'\\.(css|less|scss|sass)$': '<rootDir>/test/__mocks__/styleMock.js',
|
||||
'\\.(svg|png|jpg|jpeg|gif)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/setupTests.ts'],
|
||||
testMatch: ['**/?(*.)+(test).[tj]sx?'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'tdd-guard-jest',
|
||||
{
|
||||
projectRoot: '/home/egullickson/motovaultpro',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -9,36 +9,11 @@ http {
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
# HTTP server - for internal proxy use only
|
||||
server {
|
||||
listen 3000;
|
||||
server_name motovaultpro.com *.motovaultpro.com localhost;
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
return 301 https://$host:3443$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 3443 ssl http2;
|
||||
server_name motovaultpro.com *.motovaultpro.com localhost;
|
||||
|
||||
# SSL certificate configuration
|
||||
ssl_certificate /etc/nginx/certs/motovaultpro.com.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/motovaultpro.com.key;
|
||||
|
||||
# Modern SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -47,20 +22,10 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --project tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
@@ -48,9 +48,12 @@
|
||||
"@emotion/babel-plugin": "^11.11.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.0.6",
|
||||
"vitest": "^1.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.10",
|
||||
"ts-jest": "^29.1.1",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/user-event": "^14.5.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
frontend/setupTests.ts
Normal file
3
frontend/setupTests.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Jest setup for React Testing Library
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @ai-summary Main app component with routing and mobile navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useTransition, lazy } from 'react';
|
||||
import { useState, useEffect, useTransition, useCallback, lazy } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -14,9 +14,13 @@ import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRound
|
||||
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
||||
import { md3Theme } from './shared-minimal/theme/md3Theme';
|
||||
import { Layout } from './components/Layout';
|
||||
import { UnitsProvider } from './core/units/UnitsContext';
|
||||
|
||||
// Lazy load route components for better initial bundle size
|
||||
const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage').then(m => ({ default: m.VehiclesPage })));
|
||||
const VehicleDetailPage = lazy(() => import('./features/vehicles/pages/VehicleDetailPage').then(m => ({ default: m.VehicleDetailPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage })));
|
||||
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
|
||||
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
|
||||
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
|
||||
@@ -24,26 +28,53 @@ import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
|
||||
import { Button } from './shared-minimal/components/Button';
|
||||
import { RouteSuspense } from './components/SuspenseWrappers';
|
||||
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
||||
import { FuelLogForm } from './features/fuel-logs/components/FuelLogForm';
|
||||
import { FuelLogsList } from './features/fuel-logs/components/FuelLogsList';
|
||||
import { useFuelLogs } from './features/fuel-logs/hooks/useFuelLogs';
|
||||
import { VehicleForm } from './features/vehicles/components/VehicleForm';
|
||||
import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVehicles';
|
||||
import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
|
||||
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
|
||||
import { useNavigationStore, useUserStore } from './core/store';
|
||||
import { useDataSync } from './core/hooks/useDataSync';
|
||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||
|
||||
|
||||
function App() {
|
||||
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
|
||||
const { isLoading, isAuthenticated, loginWithRedirect, user } = useAuth0();
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
|
||||
// Mobile navigation state - detect mobile screen size with responsive updates
|
||||
|
||||
// Initialize data synchronization
|
||||
const { prefetchForNavigation } = useDataSync();
|
||||
|
||||
// Enhanced navigation and user state management
|
||||
const {
|
||||
activeScreen,
|
||||
vehicleSubScreen,
|
||||
navigateToScreen,
|
||||
navigateToVehicleSubScreen,
|
||||
goBack,
|
||||
canGoBack,
|
||||
} = useNavigationStore();
|
||||
|
||||
const { setUserProfile } = useUserStore();
|
||||
|
||||
// Mobile mode detection - detect mobile screen size with responsive updates
|
||||
const [mobileMode, setMobileMode] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [activeScreen, setActiveScreen] = useState("Vehicles");
|
||||
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||||
const [showAddVehicle, setShowAddVehicle] = useState(false);
|
||||
|
||||
// Update mobile mode on window resize
|
||||
useEffect(() => {
|
||||
const checkMobileMode = () => {
|
||||
const isMobile = window.innerWidth <= 768 ||
|
||||
const isMobile = window.innerWidth <= 768 ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
console.log('Window width:', window.innerWidth, 'User agent mobile:', /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent), 'Mobile mode:', isMobile);
|
||||
setMobileMode(isMobile);
|
||||
@@ -51,11 +82,35 @@ function App() {
|
||||
|
||||
// Check on mount
|
||||
checkMobileMode();
|
||||
|
||||
|
||||
window.addEventListener('resize', checkMobileMode);
|
||||
return () => window.removeEventListener('resize', checkMobileMode);
|
||||
}, []);
|
||||
|
||||
// Update user profile when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user);
|
||||
}
|
||||
}, [isAuthenticated, user, setUserProfile]);
|
||||
|
||||
// Handle mobile back button and navigation errors
|
||||
useEffect(() => {
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
event.preventDefault();
|
||||
if (canGoBack() && mobileMode) {
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
|
||||
if (mobileMode) {
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [goBack, canGoBack, mobileMode]);
|
||||
|
||||
// Mobile navigation items
|
||||
const mobileNavItems: NavigationItem[] = [
|
||||
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
|
||||
@@ -64,13 +119,33 @@ function App() {
|
||||
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
|
||||
];
|
||||
|
||||
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, userAgent: navigator.userAgent });
|
||||
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
|
||||
|
||||
// Debug component for testing
|
||||
// Enhanced navigation handlers for mobile
|
||||
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
|
||||
setSelectedVehicle(vehicle);
|
||||
navigateToVehicleSubScreen('detail', vehicle.id, { source: 'vehicle-list' });
|
||||
}, [navigateToVehicleSubScreen]);
|
||||
|
||||
const handleAddVehicle = useCallback(() => {
|
||||
setShowAddVehicle(true);
|
||||
navigateToVehicleSubScreen('add', undefined, { source: 'vehicle-list' });
|
||||
}, [navigateToVehicleSubScreen]);
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
setSelectedVehicle(null);
|
||||
setShowAddVehicle(false);
|
||||
navigateToVehicleSubScreen('list', undefined, { source: 'back-navigation' });
|
||||
}, [navigateToVehicleSubScreen]);
|
||||
|
||||
const handleVehicleAdded = useCallback(() => {
|
||||
setShowAddVehicle(false);
|
||||
navigateToVehicleSubScreen('list', undefined, { source: 'vehicle-added' });
|
||||
}, [navigateToVehicleSubScreen]);
|
||||
|
||||
// Enhanced debug component
|
||||
const DebugInfo = () => (
|
||||
<div className="fixed bottom-0 right-0 bg-black/80 text-white p-2 text-xs z-50 rounded-tl-lg">
|
||||
Mode: {mobileMode ? 'Mobile' : 'Desktop'} | Auth: {isAuthenticated ? 'Yes' : 'No'} | Screen: {typeof window !== 'undefined' ? window.innerWidth : 'N/A'}px
|
||||
</div>
|
||||
<MobileDebugPanel visible={import.meta.env.MODE === 'development'} />
|
||||
);
|
||||
|
||||
// Placeholder screens for mobile
|
||||
@@ -85,27 +160,85 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const LogFuelScreen = () => (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">Log Fuel</h2>
|
||||
<p className="text-slate-500">Coming soon - Fuel logging functionality</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
const LogFuelScreen = () => {
|
||||
const { fuelLogs, isLoading, error } = useFuelLogs();
|
||||
|
||||
const SettingsScreen = () => (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">Settings</h2>
|
||||
<p className="text-slate-500">Coming soon - App settings and preferences</p>
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 mb-4">Failed to load fuel logs</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FuelLogForm />
|
||||
<GlassCard>
|
||||
<div className="py-2">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Loading fuel logs...
|
||||
</div>
|
||||
) : (
|
||||
<FuelLogsList logs={fuelLogs || []} />
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mobile settings now uses the dedicated MobileSettingsScreen component
|
||||
const SettingsScreen = MobileSettingsScreen;
|
||||
|
||||
const AddVehicleScreen = () => {
|
||||
// Vehicle creation logic
|
||||
const { optimisticCreateVehicle } = useOptimisticVehicles([]);
|
||||
|
||||
const handleCreateVehicle = async (data: CreateVehicleRequest) => {
|
||||
try {
|
||||
await optimisticCreateVehicle(data);
|
||||
// Success - navigate back to list
|
||||
handleVehicleAdded();
|
||||
} catch (error) {
|
||||
console.error('Failed to create vehicle:', error);
|
||||
// Error handling is done by the useOptimisticVehicles hook via toast
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Add Vehicle</h2>
|
||||
<button
|
||||
onClick={handleBackToList}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<VehicleForm
|
||||
onSubmit={handleCreateVehicle}
|
||||
onCancel={handleBackToList}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (mobileMode) {
|
||||
@@ -175,50 +308,99 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<Layout mobileMode={true}>
|
||||
<AnimatePresence mode="wait">
|
||||
<UnitsProvider>
|
||||
<Layout mobileMode={true}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeScreen === "Dashboard" && (
|
||||
<motion.div key="dashboard" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<DashboardScreen />
|
||||
<motion.div
|
||||
key="dashboard"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Dashboard">
|
||||
<DashboardScreen />
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Vehicles" && (
|
||||
<motion.div key="vehicles" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}} className="space-y-6">
|
||||
{selectedVehicle ? (
|
||||
<VehicleDetailMobile
|
||||
vehicle={selectedVehicle}
|
||||
onBack={() => setSelectedVehicle(null)}
|
||||
onLogFuel={() => setActiveScreen("Log Fuel")}
|
||||
/>
|
||||
) : (
|
||||
<VehiclesMobileScreen
|
||||
onVehicleSelect={(vehicle) => setSelectedVehicle(vehicle)}
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
key="vehicles"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
className="space-y-6"
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Vehicles">
|
||||
{vehicleSubScreen === 'add' || showAddVehicle ? (
|
||||
<AddVehicleScreen />
|
||||
) : selectedVehicle && (vehicleSubScreen === 'detail') ? (
|
||||
<VehicleDetailMobile
|
||||
vehicle={selectedVehicle}
|
||||
onBack={handleBackToList}
|
||||
onLogFuel={() => navigateToScreen("Log Fuel")}
|
||||
/>
|
||||
) : (
|
||||
<VehiclesMobileScreen
|
||||
onVehicleSelect={handleVehicleSelect}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
/>
|
||||
)}
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Log Fuel" && (
|
||||
<motion.div key="logfuel" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<LogFuelScreen />
|
||||
<motion.div
|
||||
key="logfuel"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Log Fuel">
|
||||
<LogFuelScreen />
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Settings" && (
|
||||
<motion.div key="settings" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||
<SettingsScreen />
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Settings">
|
||||
<SettingsScreen />
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
|
||||
<BottomNavigation
|
||||
<BottomNavigation
|
||||
items={mobileNavItems}
|
||||
activeItem={activeScreen}
|
||||
onItemSelect={(screen) => startTransition(() => {
|
||||
setActiveScreen(screen);
|
||||
setSelectedVehicle(null); // Reset selected vehicle on navigation
|
||||
// Prefetch data for the target screen
|
||||
prefetchForNavigation(screen);
|
||||
|
||||
// Reset states first, then navigate to prevent race conditions
|
||||
if (screen !== 'Vehicles') {
|
||||
setSelectedVehicle(null); // Reset selected vehicle when leaving Vehicles
|
||||
}
|
||||
if (screen !== 'Vehicles' || vehicleSubScreen !== 'add') {
|
||||
setShowAddVehicle(false); // Reset add vehicle form when appropriate
|
||||
}
|
||||
|
||||
// Navigate after state cleanup
|
||||
navigateToScreen(screen as any, { source: 'bottom-navigation' });
|
||||
})}
|
||||
/>
|
||||
</UnitsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -227,22 +409,26 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider theme={md3Theme}>
|
||||
<CssBaseline />
|
||||
<Layout mobileMode={false}>
|
||||
<UnitsProvider>
|
||||
<Layout mobileMode={false}>
|
||||
<RouteSuspense>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/vehicles" replace />} />
|
||||
<Route path="/callback" element={<div>Processing login...</div>} />
|
||||
<Route path="/vehicles" element={<VehiclesPage />} />
|
||||
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
|
||||
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
|
||||
<Route path="/vehicles/:id" element={<VehicleDetailPage />} />
|
||||
<Route path="/fuel-logs" element={<FuelLogsPage />} />
|
||||
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
|
||||
<Route path="/stations" element={<div>Stations (TODO)</div>} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/vehicles" replace />} />
|
||||
</Routes>
|
||||
</RouteSuspense>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
</UnitsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -11,6 +11,7 @@ import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
|
||||
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useAppStore } from '../core/store';
|
||||
@@ -32,6 +33,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
{ name: 'Fuel Logs', href: '/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Maintenance', href: '/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Gas Stations', href: '/stations', icon: <PlaceRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Settings', href: '/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
];
|
||||
|
||||
// Mobile layout
|
||||
@@ -83,21 +85,29 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
height: '100vh',
|
||||
width: 256,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
borderRight: 1,
|
||||
borderColor: 'divider',
|
||||
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 64,
|
||||
px: 3,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
square
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 64,
|
||||
px: 3,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 0
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'primary.main' }}>
|
||||
MotoVaultPro
|
||||
</Typography>
|
||||
@@ -108,7 +118,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ mt: 3, px: 2, flex: 1 }}>
|
||||
{navigation.map((item) => {
|
||||
@@ -241,4 +251,4 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,10 +16,20 @@ export const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for auth token
|
||||
// Request interceptor for auth token with mobile debugging
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
// Token will be added by Auth0 wrapper
|
||||
// Log mobile requests for debugging
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile && config.url?.includes('/vehicles')) {
|
||||
console.log('Mobile API request:', config.method?.toUpperCase(), config.url, {
|
||||
hasAuth: !!config.headers.Authorization,
|
||||
authPreview: config.headers.Authorization?.toString().substring(0, 20) + '...'
|
||||
});
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -27,19 +37,37 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
// Response interceptor for error handling with mobile-specific logic
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized - Auth0 will redirect to login
|
||||
toast.error('Session expired. Please login again.');
|
||||
// Enhanced 401 handling for mobile token issues
|
||||
const errorMessage = error.response?.data?.message || '';
|
||||
const isTokenIssue = errorMessage.includes('token') || errorMessage.includes('JWT') || errorMessage.includes('Unauthorized');
|
||||
|
||||
if (isMobile && isTokenIssue) {
|
||||
// Mobile devices sometimes have token timing issues
|
||||
// Show a more helpful message that doesn't sound like a permanent error
|
||||
toast.error('Refreshing your session...', {
|
||||
duration: 3000,
|
||||
id: 'mobile-auth-refresh' // Prevent duplicate toasts
|
||||
});
|
||||
} else {
|
||||
// Standard session expiry message
|
||||
toast.error('Session expired. Please login again.');
|
||||
}
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error('You do not have permission to perform this action.');
|
||||
} else if (error.response?.status >= 500) {
|
||||
toast.error('Server error. Please try again later.');
|
||||
} else if (error.code === 'NETWORK_ERROR' && isMobile) {
|
||||
// Mobile-specific network error handling
|
||||
toast.error('Network error. Please check your connection and try again.');
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -28,35 +28,102 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
redirect_uri: window.location.hostname === "admin.motovaultpro.com" ? "https://admin.motovaultpro.com/callback" : window.location.origin + "/callback",
|
||||
audience: audience,
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<TokenInjector>{children}</TokenInjector>
|
||||
</BaseAuth0Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to inject token into API client
|
||||
// Component to inject token into API client with mobile support
|
||||
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
// Helper function to get token with retry logic for mobile devices
|
||||
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Progressive fallback strategy for mobile compatibility
|
||||
let tokenOptions;
|
||||
if (attempt === 0) {
|
||||
// First attempt: try cache first
|
||||
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
|
||||
} else if (attempt === 1) {
|
||||
// Second attempt: force refresh
|
||||
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
|
||||
} else {
|
||||
// Final attempt: default behavior with longer timeout
|
||||
tokenOptions = { timeoutInSeconds: 30 };
|
||||
}
|
||||
|
||||
const token = await getAccessTokenSilently(tokenOptions);
|
||||
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
|
||||
return token;
|
||||
} catch (error: any) {
|
||||
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
|
||||
|
||||
// On mobile, Auth0 might need more time - wait and retry
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
|
||||
console.log(`Waiting ${delay}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error('All token acquisition attempts failed');
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let interceptorId: number | undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Add token to all API requests
|
||||
// Pre-warm token cache for mobile devices with delay
|
||||
const initializeToken = async () => {
|
||||
// Give Auth0 a moment to fully initialize on mobile
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
console.log('Token pre-warming successful');
|
||||
setRetryCount(0);
|
||||
} else {
|
||||
console.error('Failed to acquire token after retries - will retry on API calls');
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token initialization failed:', error);
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
initializeToken();
|
||||
|
||||
// Add token to all API requests with enhanced error handling
|
||||
interceptorId = apiClient.interceptors.request.use(async (config) => {
|
||||
try {
|
||||
const token = await getAccessTokenSilently();
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to get access token:', error);
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.error('No token available for request to:', config.url);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get access token for request:', error.message || error);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
});
|
||||
} else {
|
||||
setRetryCount(0);
|
||||
}
|
||||
|
||||
// Cleanup function to remove interceptor
|
||||
@@ -65,7 +132,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
apiClient.interceptors.request.eject(interceptorId);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, getAccessTokenSilently]);
|
||||
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
138
frontend/src/core/auth/Auth0Provider.tsx.backup
Normal file
138
frontend/src/core/auth/Auth0Provider.tsx.backup
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @ai-summary Auth0 provider wrapper with API token injection
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
interface Auth0ProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
|
||||
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
|
||||
const onRedirectCallback = (appState?: { returnTo?: string }) => {
|
||||
navigate(appState?.returnTo || '/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAuth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: window.location.origin,
|
||||
audience: audience,
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<TokenInjector>{children}</TokenInjector>
|
||||
</BaseAuth0Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to inject token into API client with mobile support
|
||||
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
// Helper function to get token with retry logic for mobile devices
|
||||
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Progressive fallback strategy for mobile compatibility
|
||||
let tokenOptions;
|
||||
if (attempt === 0) {
|
||||
// First attempt: try cache first
|
||||
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
|
||||
} else if (attempt === 1) {
|
||||
// Second attempt: force refresh
|
||||
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
|
||||
} else {
|
||||
// Final attempt: default behavior with longer timeout
|
||||
tokenOptions = { timeoutInSeconds: 30 };
|
||||
}
|
||||
|
||||
const token = await getAccessTokenSilently(tokenOptions);
|
||||
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
|
||||
return token;
|
||||
} catch (error: any) {
|
||||
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
|
||||
|
||||
// On mobile, Auth0 might need more time - wait and retry
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
|
||||
console.log(`Waiting ${delay}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error('All token acquisition attempts failed');
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let interceptorId: number | undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Pre-warm token cache for mobile devices with delay
|
||||
const initializeToken = async () => {
|
||||
// Give Auth0 a moment to fully initialize on mobile
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
console.log('Token pre-warming successful');
|
||||
setRetryCount(0);
|
||||
} else {
|
||||
console.error('Failed to acquire token after retries - will retry on API calls');
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token initialization failed:', error);
|
||||
setRetryCount(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
initializeToken();
|
||||
|
||||
// Add token to all API requests with enhanced error handling
|
||||
interceptorId = apiClient.interceptors.request.use(async (config) => {
|
||||
try {
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.error('No token available for request to:', config.url);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get access token for request:', error.message || error);
|
||||
// Allow request to proceed - backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
});
|
||||
} else {
|
||||
setRetryCount(0);
|
||||
}
|
||||
|
||||
// Cleanup function to remove interceptor
|
||||
return () => {
|
||||
if (interceptorId !== undefined) {
|
||||
apiClient.interceptors.request.eject(interceptorId);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
258
frontend/src/core/debug/MobileDebugPanel.tsx
Normal file
258
frontend/src/core/debug/MobileDebugPanel.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @ai-summary Enhanced debugging panel for mobile token flow and performance monitoring
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigationStore, useUserStore } from '../store';
|
||||
|
||||
interface DebugInfo {
|
||||
timestamp: string;
|
||||
type: 'auth' | 'query' | 'navigation' | 'network';
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const MobileDebugPanel: React.FC<{ visible: boolean }> = ({ visible }) => {
|
||||
const { isAuthenticated, getAccessTokenSilently } = useAuth0();
|
||||
const queryClient = useQueryClient();
|
||||
const navigationStore = useNavigationStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const [debugLogs, setDebugLogs] = useState<DebugInfo[]>([]);
|
||||
const [tokenInfo, setTokenInfo] = useState<{
|
||||
hasToken: boolean;
|
||||
tokenPreview?: string;
|
||||
lastRefresh?: string;
|
||||
cacheMode?: string;
|
||||
}>({
|
||||
hasToken: false,
|
||||
});
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Monitor token status
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const token = await getAccessTokenSilently({ cacheMode: 'cache-only' });
|
||||
setTokenInfo({
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
|
||||
lastRefresh: new Date().toLocaleTimeString(),
|
||||
cacheMode: 'cache-only',
|
||||
});
|
||||
|
||||
addDebugLog('auth', 'Token check successful', {
|
||||
hasToken: !!token,
|
||||
cacheMode: 'cache-only',
|
||||
});
|
||||
} catch (error) {
|
||||
addDebugLog('auth', 'Token check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
setTokenInfo({ hasToken: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkToken();
|
||||
const interval = setInterval(checkToken, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, getAccessTokenSilently]);
|
||||
|
||||
const addDebugLog = (type: DebugInfo['type'], message: string, data?: any) => {
|
||||
const logEntry: DebugInfo = {
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
type,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
|
||||
setDebugLogs(prev => [...prev.slice(-19), logEntry]); // Keep last 20 entries
|
||||
};
|
||||
|
||||
// Monitor navigation changes
|
||||
useEffect(() => {
|
||||
addDebugLog('navigation', `Navigated to ${navigationStore.activeScreen}`, {
|
||||
screen: navigationStore.activeScreen,
|
||||
subScreen: navigationStore.vehicleSubScreen,
|
||||
selectedVehicleId: navigationStore.selectedVehicleId,
|
||||
isNavigating: navigationStore.isNavigating,
|
||||
historyLength: navigationStore.navigationHistory.length,
|
||||
});
|
||||
}, [navigationStore.activeScreen, navigationStore.vehicleSubScreen, navigationStore.selectedVehicleId, navigationStore.isNavigating]);
|
||||
|
||||
// Monitor navigation errors
|
||||
useEffect(() => {
|
||||
if (navigationStore.navigationError) {
|
||||
addDebugLog('navigation', `Navigation Error: ${navigationStore.navigationError}`, {
|
||||
error: navigationStore.navigationError,
|
||||
screen: navigationStore.activeScreen,
|
||||
});
|
||||
}
|
||||
}, [navigationStore.navigationError]);
|
||||
|
||||
// Monitor network status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => addDebugLog('network', 'Network: Online');
|
||||
const handleOffline = () => addDebugLog('network', 'Network: Offline');
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getQueryCacheStats = () => {
|
||||
const cache = queryClient.getQueryCache();
|
||||
const queries = cache.getAll();
|
||||
|
||||
return {
|
||||
total: queries.length,
|
||||
stale: queries.filter(q => q.isStale()).length,
|
||||
loading: queries.filter(q => q.state.status === 'pending').length,
|
||||
error: queries.filter(q => q.state.status === 'error').length,
|
||||
};
|
||||
};
|
||||
|
||||
const testTokenRefresh = async () => {
|
||||
try {
|
||||
addDebugLog('auth', 'Testing token refresh...');
|
||||
const token = await getAccessTokenSilently({ cacheMode: 'off' });
|
||||
addDebugLog('auth', 'Token refresh successful', {
|
||||
hasToken: !!token,
|
||||
length: token?.length,
|
||||
});
|
||||
setTokenInfo(prev => ({
|
||||
...prev,
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
|
||||
lastRefresh: new Date().toLocaleTimeString(),
|
||||
cacheMode: 'off',
|
||||
}));
|
||||
} catch (error) {
|
||||
addDebugLog('auth', 'Token refresh failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const cacheStats = getQueryCacheStats();
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div className={`fixed ${expanded ? 'inset-4' : 'bottom-4 right-4'} bg-black/90 text-white text-xs font-mono rounded-lg transition-all duration-300 z-50`}>
|
||||
<div className="flex items-center justify-between p-2 border-b border-white/20">
|
||||
<span className="font-semibold">Debug Panel</span>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
{expanded ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="p-3 max-h-[80vh] overflow-y-auto">
|
||||
{/* System Info */}
|
||||
<div className="mb-4">
|
||||
<div className="text-yellow-400 font-semibold mb-1">System Status</div>
|
||||
<div>Mode: {isMobile ? 'Mobile' : 'Desktop'} | Auth: {isAuthenticated ? 'Yes' : 'No'}</div>
|
||||
<div>Screen: {navigationStore.activeScreen} | Sub: {navigationStore.vehicleSubScreen}</div>
|
||||
<div>Online: {userStore.isOnline ? 'Yes' : 'No'} | Width: {window.innerWidth}px</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Info */}
|
||||
<div className="mb-4">
|
||||
<div className="text-cyan-400 font-semibold mb-1">Navigation State</div>
|
||||
<div>Current: {navigationStore.activeScreen} → {navigationStore.vehicleSubScreen}</div>
|
||||
<div>Navigating: {navigationStore.isNavigating ? 'Yes' : 'No'}</div>
|
||||
<div>History: {navigationStore.navigationHistory.length} entries</div>
|
||||
<div>Selected Vehicle: {navigationStore.selectedVehicleId || 'None'}</div>
|
||||
{navigationStore.navigationError && (
|
||||
<div className="text-red-300">Error: {navigationStore.navigationError}</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-1 flex-wrap">
|
||||
{['Dashboard', 'Vehicles', 'Log Fuel', 'Settings'].map((screen) => (
|
||||
<button
|
||||
key={screen}
|
||||
onClick={() => {
|
||||
addDebugLog('navigation', `Debug navigation to ${screen}`);
|
||||
navigationStore.navigateToScreen(screen as any);
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
navigationStore.activeScreen === screen
|
||||
? 'bg-cyan-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{screen.slice(0, 3)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Info */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-green-400 font-semibold">Token Status</span>
|
||||
<button
|
||||
onClick={testTokenRefresh}
|
||||
className="text-xs bg-blue-600 px-2 py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
Test Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div>Has Token: {tokenInfo.hasToken ? 'Yes' : 'No'}</div>
|
||||
{tokenInfo.tokenPreview && <div>Preview: {tokenInfo.tokenPreview}</div>}
|
||||
{tokenInfo.lastRefresh && <div>Last Refresh: {tokenInfo.lastRefresh}</div>}
|
||||
{tokenInfo.cacheMode && <div>Cache Mode: {tokenInfo.cacheMode}</div>}
|
||||
</div>
|
||||
|
||||
{/* Query Cache Stats */}
|
||||
<div className="mb-4">
|
||||
<div className="text-blue-400 font-semibold mb-1">Query Cache</div>
|
||||
<div>Total: {cacheStats.total} | Stale: {cacheStats.stale}</div>
|
||||
<div>Loading: {cacheStats.loading} | Error: {cacheStats.error}</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Logs */}
|
||||
<div>
|
||||
<div className="text-purple-400 font-semibold mb-1">Recent Events</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{debugLogs.slice(-10).reverse().map((log, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<span className="text-white/50">[{log.timestamp}]</span>{' '}
|
||||
<span className={
|
||||
log.type === 'auth' ? 'text-green-300' :
|
||||
log.type === 'query' ? 'text-blue-300' :
|
||||
log.type === 'navigation' ? 'text-yellow-300' :
|
||||
'text-purple-300'
|
||||
}>
|
||||
{log.type.toUpperCase()}
|
||||
</span>:{' '}
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
<div>
|
||||
{isMobile ? 'M' : 'D'} | {isAuthenticated ? '🔐' : '🔓'} |
|
||||
{navigationStore.activeScreen.substring(0, 3)} |
|
||||
T:{tokenInfo.hasToken ? '✅' : '❌'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
frontend/src/core/error-boundaries/MobileErrorBoundary.tsx
Normal file
124
frontend/src/core/error-boundaries/MobileErrorBoundary.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @ai-summary Error boundary component specifically designed for mobile screens
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { GlassCard } from '../../shared-minimal/components/mobile/GlassCard';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
interface MobileErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export class MobileErrorBoundary extends React.Component<MobileErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: MobileErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// Log error for debugging
|
||||
console.error(`Mobile screen error in ${this.props.screenName}:`, error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
this.props.onRetry?.();
|
||||
};
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-8">
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2">
|
||||
Oops! Something went wrong
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
There was an error loading the {this.props.screenName.toLowerCase()} screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Refresh App
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="text-sm text-slate-500 cursor-pointer">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-red-50 rounded text-xs text-red-800 overflow-auto">
|
||||
<div className="font-semibold mb-1">Error:</div>
|
||||
<div className="mb-2">{this.state.error.message}</div>
|
||||
|
||||
<div className="font-semibold mb-1">Stack Trace:</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
|
||||
{this.state.errorInfo && (
|
||||
<>
|
||||
<div className="font-semibold mb-1 mt-2">Component Stack:</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
44
frontend/src/core/hooks/useDataSync.ts
Normal file
44
frontend/src/core/hooks/useDataSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @ai-summary React hook for data synchronization management
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { DataSyncManager } from '../sync/data-sync';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
|
||||
export const useDataSync = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const syncManagerRef = useRef<DataSyncManager | null>(null);
|
||||
const navigationStore = useNavigationStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize data sync manager
|
||||
syncManagerRef.current = new DataSyncManager(queryClient, {
|
||||
enableCrossTabs: true,
|
||||
enableOptimisticUpdates: true,
|
||||
enableBackgroundSync: true,
|
||||
syncInterval: 30000,
|
||||
});
|
||||
|
||||
return () => {
|
||||
syncManagerRef.current?.cleanup();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Listen for navigation changes and trigger prefetching
|
||||
useEffect(() => {
|
||||
if (syncManagerRef.current) {
|
||||
syncManagerRef.current.prefetchForNavigation(navigationStore.activeScreen);
|
||||
}
|
||||
}, [navigationStore.activeScreen]);
|
||||
|
||||
return {
|
||||
optimisticVehicleUpdate: (vehicleId: string, updates: any) => {
|
||||
syncManagerRef.current?.optimisticVehicleUpdate(vehicleId, updates);
|
||||
},
|
||||
prefetchForNavigation: (screen: string) => {
|
||||
syncManagerRef.current?.prefetchForNavigation(screen);
|
||||
},
|
||||
};
|
||||
};
|
||||
175
frontend/src/core/hooks/useFormState.ts
Normal file
175
frontend/src/core/hooks/useFormState.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
|
||||
export interface UseFormStateOptions<T> {
|
||||
formId: string;
|
||||
defaultValues: T;
|
||||
autoSave?: boolean;
|
||||
saveDelay?: number;
|
||||
onRestore?: (data: T) => void;
|
||||
onSave?: (data: T) => void;
|
||||
validate?: (data: T) => Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface FormStateReturn<T> {
|
||||
formData: T;
|
||||
updateFormData: (updates: Partial<T>) => void;
|
||||
setFormData: (data: T) => void;
|
||||
resetForm: () => void;
|
||||
submitForm: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
isRestored: boolean;
|
||||
isSaving: boolean;
|
||||
errors: Record<string, string>;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export const useFormState = <T extends Record<string, any>>({
|
||||
formId,
|
||||
defaultValues,
|
||||
autoSave = true,
|
||||
saveDelay = 1000,
|
||||
onRestore,
|
||||
onSave,
|
||||
validate,
|
||||
}: UseFormStateOptions<T>): FormStateReturn<T> => {
|
||||
const { saveFormState, restoreFormState, clearFormState } = useNavigationStore();
|
||||
const [formData, setFormDataState] = useState<T>(defaultValues);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const initialDataRef = useRef<T>(defaultValues);
|
||||
const formDataRef = useRef<T>(formData);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update ref when formData changes
|
||||
useEffect(() => {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
// Validation
|
||||
const validateForm = useCallback((data: T) => {
|
||||
if (!validate) return {};
|
||||
|
||||
const validationErrors = validate(data);
|
||||
return validationErrors || {};
|
||||
}, [validate]);
|
||||
|
||||
// Restore form state on mount
|
||||
useEffect(() => {
|
||||
const restoredState = restoreFormState(formId);
|
||||
if (restoredState && !isRestored) {
|
||||
const restoredData = { ...defaultValues, ...restoredState.data };
|
||||
setFormDataState(restoredData);
|
||||
setHasChanges(restoredState.isDirty);
|
||||
setIsRestored(true);
|
||||
|
||||
if (onRestore) {
|
||||
onRestore(restoredData);
|
||||
}
|
||||
}
|
||||
}, [formId, restoreFormState, defaultValues, isRestored, onRestore]);
|
||||
|
||||
// Auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (!autoSave || !hasChanges) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
saveFormState(formId, formDataRef.current, hasChanges);
|
||||
|
||||
if (onSave) {
|
||||
await onSave(formDataRef.current);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Form auto-save failed:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, saveDelay);
|
||||
|
||||
// Cleanup timeout
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [formData, hasChanges, autoSave, saveDelay, formId, saveFormState, onSave]);
|
||||
|
||||
// Validate when form data changes
|
||||
useEffect(() => {
|
||||
if (hasChanges) {
|
||||
const validationErrors = validateForm(formData);
|
||||
setErrors(validationErrors);
|
||||
}
|
||||
}, [formData, hasChanges, validateForm]);
|
||||
|
||||
const updateFormData = useCallback((updates: Partial<T>) => {
|
||||
setFormDataState((current) => {
|
||||
const updated = { ...current, ...updates };
|
||||
const hasActualChanges = JSON.stringify(updated) !== JSON.stringify(initialDataRef.current);
|
||||
setHasChanges(hasActualChanges);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setFormData = useCallback((data: T) => {
|
||||
setFormDataState(data);
|
||||
const hasActualChanges = JSON.stringify(data) !== JSON.stringify(initialDataRef.current);
|
||||
setHasChanges(hasActualChanges);
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormDataState(defaultValues);
|
||||
setHasChanges(false);
|
||||
setErrors({});
|
||||
clearFormState(formId);
|
||||
initialDataRef.current = { ...defaultValues };
|
||||
}, [defaultValues, formId, clearFormState]);
|
||||
|
||||
const submitForm = useCallback(async () => {
|
||||
const validationErrors = validateForm(formDataRef.current);
|
||||
setErrors(validationErrors);
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
throw new Error('Form validation failed');
|
||||
}
|
||||
|
||||
try {
|
||||
setHasChanges(false);
|
||||
clearFormState(formId);
|
||||
initialDataRef.current = { ...formDataRef.current };
|
||||
|
||||
if (onSave) {
|
||||
await onSave(formDataRef.current);
|
||||
}
|
||||
} catch (error) {
|
||||
setHasChanges(true); // Restore changes state on error
|
||||
throw error;
|
||||
}
|
||||
}, [validateForm, formId, clearFormState, onSave]);
|
||||
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateFormData,
|
||||
setFormData,
|
||||
resetForm,
|
||||
submitForm,
|
||||
hasChanges,
|
||||
isRestored,
|
||||
isSaving,
|
||||
errors,
|
||||
isValid,
|
||||
};
|
||||
};
|
||||
148
frontend/src/core/query/query-config.ts
Normal file
148
frontend/src/core/query/query-config.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @ai-summary Enhanced Query Client configuration with mobile optimization
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Mobile detection utility
|
||||
const isMobileDevice = (): boolean => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.innerWidth <= 768;
|
||||
};
|
||||
|
||||
// Enhanced error handler for mobile devices
|
||||
const handleQueryError = (error: any) => {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (error?.response?.status === 401) {
|
||||
// Token refresh handled by Auth0Provider
|
||||
if (isMobile) {
|
||||
toast.error('Refreshing session...', {
|
||||
duration: 2000,
|
||||
id: 'mobile-auth-refresh'
|
||||
});
|
||||
}
|
||||
} else if (error?.response?.status >= 500) {
|
||||
toast.error(isMobile ? 'Server issue, retrying...' : 'Server error occurred', {
|
||||
duration: isMobile ? 3000 : 4000,
|
||||
});
|
||||
} else if (error?.code === 'NETWORK_ERROR') {
|
||||
if (isMobile) {
|
||||
toast.error('Check connection and try again', {
|
||||
duration: 4000,
|
||||
id: 'mobile-network-error'
|
||||
});
|
||||
} else {
|
||||
toast.error('Network error occurred');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create enhanced query client with mobile-optimized settings
|
||||
export const createEnhancedQueryClient = (): QueryClient => {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
return new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: handleQueryError,
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: handleQueryError,
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Mobile-optimized retry strategy
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry 4xx errors except 401 (auth issues)
|
||||
if (error?.response?.status >= 400 && error?.response?.status < 500) {
|
||||
return error?.response?.status === 401 && failureCount < 2;
|
||||
}
|
||||
|
||||
// Mobile devices get more aggressive retry for network issues
|
||||
if (isMobile) {
|
||||
return failureCount < 3;
|
||||
}
|
||||
|
||||
return failureCount < 2;
|
||||
},
|
||||
|
||||
// Mobile-optimized timing
|
||||
retryDelay: (attemptIndex) => {
|
||||
const baseDelay = isMobile ? 1000 : 500;
|
||||
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
|
||||
},
|
||||
|
||||
// Stale time optimization for mobile
|
||||
staleTime: isMobile ? 1000 * 60 * 2 : 1000 * 60 * 5, // 2 min mobile, 5 min desktop
|
||||
|
||||
// GC time optimization
|
||||
gcTime: isMobile ? 1000 * 60 * 10 : 1000 * 60 * 30, // 10 min mobile, 30 min desktop
|
||||
|
||||
// Refetch behavior
|
||||
refetchOnWindowFocus: !isMobile, // Disable on mobile to save data
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMount: true,
|
||||
|
||||
// Network mode for offline capability
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
mutations: {
|
||||
// Mutation retry strategy
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.response?.status >= 400 && error?.response?.status < 500) {
|
||||
return false; // Don't retry 4xx errors for mutations
|
||||
}
|
||||
|
||||
return failureCount < (isMobile ? 2 : 1);
|
||||
},
|
||||
|
||||
retryDelay: (attemptIndex) => {
|
||||
const baseDelay = isMobile ? 2000 : 1000;
|
||||
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
|
||||
},
|
||||
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Query key factories for consistent cache management
|
||||
export const queryKeys = {
|
||||
all: ['motovaultpro'] as const,
|
||||
users: () => [...queryKeys.all, 'users'] as const,
|
||||
user: (id: string) => [...queryKeys.users(), id] as const,
|
||||
vehicles: () => [...queryKeys.all, 'vehicles'] as const,
|
||||
vehicle: (id: string) => [...queryKeys.vehicles(), id] as const,
|
||||
vehiclesByUser: (userId: string) => [...queryKeys.vehicles(), 'user', userId] as const,
|
||||
fuelLogs: () => [...queryKeys.all, 'fuel-logs'] as const,
|
||||
fuelLog: (id: string) => [...queryKeys.fuelLogs(), id] as const,
|
||||
fuelLogsByVehicle: (vehicleId: string) => [...queryKeys.fuelLogs(), 'vehicle', vehicleId] as const,
|
||||
settings: () => [...queryKeys.all, 'settings'] as const,
|
||||
userSettings: (userId: string) => [...queryKeys.settings(), 'user', userId] as const,
|
||||
} as const;
|
||||
|
||||
// Performance monitoring utilities
|
||||
export const queryPerformanceMonitor = {
|
||||
logSlowQuery: (queryKey: readonly unknown[], duration: number) => {
|
||||
if (duration > 5000) { // Log queries taking more than 5 seconds
|
||||
console.warn('Slow query detected:', {
|
||||
queryKey,
|
||||
duration: `${duration}ms`,
|
||||
isMobile: isMobileDevice(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
logCacheHit: (queryKey: readonly unknown[], fromCache: boolean) => {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.log('Query cache:', {
|
||||
queryKey,
|
||||
fromCache,
|
||||
isMobile: isMobileDevice(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
24
frontend/src/core/store/app.ts
Normal file
24
frontend/src/core/store/app.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
|
||||
|
||||
interface AppState {
|
||||
// UI state
|
||||
sidebarOpen: boolean;
|
||||
selectedVehicle: Vehicle | null;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSelectedVehicle: (vehicle: Vehicle | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
// Initial state
|
||||
sidebarOpen: false,
|
||||
selectedVehicle: null,
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
|
||||
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
|
||||
}));
|
||||
@@ -1,54 +1,12 @@
|
||||
/**
|
||||
* @ai-summary Global state management with Zustand
|
||||
* @ai-context Minimal global state, features manage their own state
|
||||
*/
|
||||
// Export navigation store
|
||||
export { useNavigationStore } from './navigation';
|
||||
export type { MobileScreen, VehicleSubScreen } from './navigation';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
// Export user store
|
||||
export { useUserStore } from './user';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
// Export app store (compatibility)
|
||||
export { useAppStore } from './app';
|
||||
|
||||
interface AppState {
|
||||
// User state
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
|
||||
// UI state
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
// Selected vehicle (for context)
|
||||
selectedVehicleId: string | null;
|
||||
setSelectedVehicle: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
// User state
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
// UI state
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
// Selected vehicle
|
||||
selectedVehicleId: null,
|
||||
setSelectedVehicle: (vehicleId) => set({ selectedVehicleId: vehicleId }),
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-storage',
|
||||
partialize: (state) => ({
|
||||
selectedVehicleId: state.selectedVehicleId,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
// Note: This replaces any existing store exports and provides
|
||||
// centralized access to all Zustand stores in the application
|
||||
205
frontend/src/core/store/navigation.ts
Normal file
205
frontend/src/core/store/navigation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Settings';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
screen: MobileScreen;
|
||||
vehicleSubScreen?: VehicleSubScreen;
|
||||
selectedVehicleId?: string | null;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
data: Record<string, any>;
|
||||
timestamp: number;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
interface NavigationState {
|
||||
// Current navigation state
|
||||
activeScreen: MobileScreen;
|
||||
vehicleSubScreen: VehicleSubScreen;
|
||||
selectedVehicleId: string | null;
|
||||
|
||||
// Navigation history for back button
|
||||
navigationHistory: NavigationHistory[];
|
||||
|
||||
// Form state preservation
|
||||
formStates: Record<string, FormState>;
|
||||
|
||||
// Loading and error states
|
||||
isNavigating: boolean;
|
||||
navigationError: string | null;
|
||||
|
||||
// Actions
|
||||
navigateToScreen: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
||||
navigateToVehicleSubScreen: (subScreen: VehicleSubScreen, vehicleId?: string, metadata?: Record<string, any>) => void;
|
||||
goBack: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
saveFormState: (formId: string, data: any, isDirty?: boolean) => void;
|
||||
restoreFormState: (formId: string) => FormState | null;
|
||||
clearFormState: (formId: string) => void;
|
||||
clearAllFormStates: () => void;
|
||||
setNavigationError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
activeScreen: 'Vehicles',
|
||||
vehicleSubScreen: 'list',
|
||||
selectedVehicleId: null,
|
||||
navigationHistory: [],
|
||||
formStates: {},
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
|
||||
// Navigation actions
|
||||
navigateToScreen: (screen, metadata = {}) => {
|
||||
const currentState = get();
|
||||
|
||||
// Skip navigation if already on the same screen
|
||||
if (currentState.activeScreen === screen && !currentState.isNavigating) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const historyEntry: NavigationHistory = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Update state atomically to prevent blank screens
|
||||
set({
|
||||
activeScreen: screen,
|
||||
vehicleSubScreen: screen === 'Vehicles' ? currentState.vehicleSubScreen : 'list',
|
||||
selectedVehicleId: screen === 'Vehicles' ? currentState.selectedVehicleId : null,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
||||
isNavigating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
navigateToVehicleSubScreen: (subScreen, vehicleId, metadata = {}) => {
|
||||
const currentState = get();
|
||||
|
||||
set({ isNavigating: true, navigationError: null });
|
||||
|
||||
try {
|
||||
const historyEntry: NavigationHistory = {
|
||||
screen: currentState.activeScreen,
|
||||
vehicleSubScreen: currentState.vehicleSubScreen,
|
||||
selectedVehicleId: currentState.selectedVehicleId,
|
||||
timestamp: Date.now(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
set({
|
||||
vehicleSubScreen: subScreen,
|
||||
selectedVehicleId: vehicleId !== null ? vehicleId : currentState.selectedVehicleId,
|
||||
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
||||
isNavigating: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
||||
isNavigating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const currentState = get();
|
||||
const lastEntry = currentState.navigationHistory[currentState.navigationHistory.length - 1];
|
||||
|
||||
if (lastEntry) {
|
||||
set({
|
||||
activeScreen: lastEntry.screen,
|
||||
vehicleSubScreen: lastEntry.vehicleSubScreen || 'list',
|
||||
selectedVehicleId: lastEntry.selectedVehicleId,
|
||||
navigationHistory: currentState.navigationHistory.slice(0, -1),
|
||||
isNavigating: false,
|
||||
navigationError: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
canGoBack: () => {
|
||||
return get().navigationHistory.length > 0;
|
||||
},
|
||||
|
||||
// Form state management
|
||||
saveFormState: (formId, data, isDirty = true) => {
|
||||
const currentState = get();
|
||||
const formState: FormState = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
isDirty,
|
||||
};
|
||||
|
||||
set({
|
||||
formStates: {
|
||||
...currentState.formStates,
|
||||
[formId]: formState,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
restoreFormState: (formId) => {
|
||||
const state = get().formStates[formId];
|
||||
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
if (state && Date.now() - state.timestamp < maxAge) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Clean up old state
|
||||
if (state) {
|
||||
get().clearFormState(formId);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
clearFormState: (formId) => {
|
||||
const currentState = get();
|
||||
const newFormStates = { ...currentState.formStates };
|
||||
delete newFormStates[formId];
|
||||
set({ formStates: newFormStates });
|
||||
},
|
||||
|
||||
clearAllFormStates: () => {
|
||||
set({ formStates: {} });
|
||||
},
|
||||
|
||||
setNavigationError: (error) => {
|
||||
set({ navigationError: error });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-mobile-navigation',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
activeScreen: state.activeScreen,
|
||||
vehicleSubScreen: state.vehicleSubScreen,
|
||||
selectedVehicleId: state.selectedVehicleId,
|
||||
formStates: state.formStates,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
101
frontend/src/core/store/user.ts
Normal file
101
frontend/src/core/store/user.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
interface UserPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
darkMode: boolean;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
maintenance: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
// User data (persisted subset)
|
||||
userProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
picture: string;
|
||||
} | null;
|
||||
|
||||
preferences: UserPreferences;
|
||||
|
||||
// Session data (not persisted)
|
||||
isOnline: boolean;
|
||||
lastSyncTimestamp: number;
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile: any) => void;
|
||||
updatePreferences: (preferences: Partial<UserPreferences>) => void;
|
||||
setOnlineStatus: (isOnline: boolean) => void;
|
||||
updateLastSync: () => void;
|
||||
clearUserData: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
isOnline: true,
|
||||
lastSyncTimestamp: 0,
|
||||
|
||||
// Actions
|
||||
setUserProfile: (profile) => {
|
||||
if (profile) {
|
||||
set({
|
||||
userProfile: {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.picture,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (newPreferences) => {
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...newPreferences },
|
||||
}));
|
||||
},
|
||||
|
||||
setOnlineStatus: (isOnline) => set({ isOnline }),
|
||||
|
||||
updateLastSync: () => set({ lastSyncTimestamp: Date.now() }),
|
||||
|
||||
clearUserData: () => set({
|
||||
userProfile: null,
|
||||
preferences: {
|
||||
unitSystem: 'imperial',
|
||||
darkMode: false,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-user-context',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
userProfile: state.userProfile,
|
||||
preferences: state.preferences,
|
||||
// Don't persist session data
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
254
frontend/src/core/sync/data-sync.ts
Normal file
254
frontend/src/core/sync/data-sync.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @ai-summary Data synchronization layer integrating React Query with Zustand stores
|
||||
*/
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { useNavigationStore } from '../store/navigation';
|
||||
import { useUserStore } from '../store/user';
|
||||
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
|
||||
|
||||
interface SyncConfig {
|
||||
enableCrossTabs: boolean;
|
||||
enableOptimisticUpdates: boolean;
|
||||
enableBackgroundSync: boolean;
|
||||
syncInterval: number;
|
||||
}
|
||||
|
||||
export class DataSyncManager {
|
||||
private queryClient: QueryClient;
|
||||
private config: SyncConfig;
|
||||
private syncInterval?: NodeJS.Timeout;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
|
||||
constructor(queryClient: QueryClient, config: Partial<SyncConfig> = {}) {
|
||||
this.queryClient = queryClient;
|
||||
this.config = {
|
||||
enableCrossTabs: true,
|
||||
enableOptimisticUpdates: true,
|
||||
enableBackgroundSync: true,
|
||||
syncInterval: 30000, // 30 seconds
|
||||
...config,
|
||||
};
|
||||
|
||||
this.initializeSync();
|
||||
}
|
||||
|
||||
private initializeSync() {
|
||||
// Listen to online/offline events
|
||||
window.addEventListener('online', this.handleOnline.bind(this));
|
||||
window.addEventListener('offline', this.handleOffline.bind(this));
|
||||
|
||||
// Cross-tab synchronization
|
||||
if (this.config.enableCrossTabs) {
|
||||
this.initializeCrossTabSync();
|
||||
}
|
||||
|
||||
// Background sync
|
||||
if (this.config.enableBackgroundSync) {
|
||||
this.startBackgroundSync();
|
||||
}
|
||||
}
|
||||
|
||||
private handleOnline() {
|
||||
this.isOnline = true;
|
||||
useUserStore.getState().setOnlineStatus(true);
|
||||
|
||||
// Trigger cache revalidation when coming back online
|
||||
this.queryClient.invalidateQueries();
|
||||
console.log('DataSync: Back online, revalidating cache');
|
||||
}
|
||||
|
||||
private handleOffline() {
|
||||
this.isOnline = false;
|
||||
useUserStore.getState().setOnlineStatus(false);
|
||||
console.log('DataSync: Offline mode enabled');
|
||||
}
|
||||
|
||||
private initializeCrossTabSync() {
|
||||
// Listen for storage changes from other tabs
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key?.startsWith('motovaultpro-')) {
|
||||
// Another tab updated store data
|
||||
if (event.key.includes('user-context')) {
|
||||
// User data changed in another tab - sync React Query cache
|
||||
this.syncUserDataFromStorage();
|
||||
} else if (event.key.includes('mobile-navigation')) {
|
||||
// Navigation state changed - could affect cache keys
|
||||
this.syncNavigationFromStorage();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async syncUserDataFromStorage() {
|
||||
try {
|
||||
const userData = useUserStore.getState().userProfile;
|
||||
if (userData) {
|
||||
// Update query cache with latest user data
|
||||
this.queryClient.setQueryData(['user', userData.id], userData);
|
||||
console.log('DataSync: User data synchronized from another tab');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Failed to sync user data from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNavigationFromStorage() {
|
||||
try {
|
||||
const navigationState = useNavigationStore.getState();
|
||||
|
||||
// If the selected vehicle changed in another tab, preload its data
|
||||
if (navigationState.selectedVehicleId) {
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles', navigationState.selectedVehicleId],
|
||||
queryFn: () => this.fetchVehicleById(navigationState.selectedVehicleId!),
|
||||
});
|
||||
console.log('DataSync: Vehicle data preloaded from navigation sync');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Failed to sync navigation from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private startBackgroundSync() {
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (this.isOnline) {
|
||||
this.performBackgroundSync();
|
||||
}
|
||||
}, this.config.syncInterval);
|
||||
}
|
||||
|
||||
private async performBackgroundSync() {
|
||||
try {
|
||||
// Update last sync timestamp
|
||||
useUserStore.getState().updateLastSync();
|
||||
|
||||
// Strategically refresh critical data
|
||||
const navigationState = useNavigationStore.getState();
|
||||
|
||||
// If on vehicles screen, refresh vehicles data
|
||||
if (navigationState.activeScreen === 'Vehicles') {
|
||||
await this.queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
}
|
||||
|
||||
// If viewing specific vehicle, refresh its data
|
||||
if (navigationState.selectedVehicleId) {
|
||||
await this.queryClient.invalidateQueries({
|
||||
queryKey: ['vehicles', navigationState.selectedVehicleId]
|
||||
});
|
||||
}
|
||||
|
||||
console.log('DataSync: Background sync completed');
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Background sync failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to fetch vehicle by ID (would normally import from vehicles API)
|
||||
private async fetchVehicleById(id: string): Promise<Vehicle | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/vehicles/${id}`, {
|
||||
headers: {
|
||||
'Authorization': this.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch vehicle ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthHeader(): string {
|
||||
// This would integrate with Auth0 token from interceptor
|
||||
// For now, return empty string as token is handled by axios interceptor
|
||||
return '';
|
||||
}
|
||||
|
||||
// Public methods for optimistic updates
|
||||
public async optimisticVehicleUpdate(vehicleId: string, updates: Partial<Vehicle>) {
|
||||
if (!this.config.enableOptimisticUpdates) return;
|
||||
|
||||
try {
|
||||
// Optimistically update query cache
|
||||
this.queryClient.setQueryData(['vehicles', vehicleId], (old: Vehicle | undefined) => {
|
||||
if (!old) return old;
|
||||
return { ...old, ...updates };
|
||||
});
|
||||
|
||||
// Also update the vehicles list cache
|
||||
this.queryClient.setQueryData(['vehicles'], (old: Vehicle[] | undefined) => {
|
||||
if (!old) return old;
|
||||
return old.map(vehicle =>
|
||||
vehicle.id === vehicleId ? { ...vehicle, ...updates } : vehicle
|
||||
);
|
||||
});
|
||||
|
||||
console.log('DataSync: Optimistic vehicle update applied');
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Optimistic update failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async prefetchForNavigation(targetScreen: string) {
|
||||
try {
|
||||
switch (targetScreen) {
|
||||
case 'Vehicles':
|
||||
// Prefetch vehicles list if not already cached
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles'],
|
||||
queryFn: () => this.fetchVehicles(),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Log Fuel':
|
||||
// Prefetch vehicles for fuel logging dropdown
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: ['vehicles'],
|
||||
queryFn: () => this.fetchVehicles(),
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// No specific prefetching needed
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('DataSync: Prefetch failed for', targetScreen, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchVehicles(): Promise<Vehicle[]> {
|
||||
try {
|
||||
const response = await fetch('/api/vehicles', {
|
||||
headers: {
|
||||
'Authorization': this.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch vehicles:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
}
|
||||
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
117
frontend/src/core/units/UnitsContext.tsx
Normal file
117
frontend/src/core/units/UnitsContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @ai-summary React context for unit system preferences
|
||||
* @ai-context Provides unit preferences and conversion functions throughout the app
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { UnitSystem, UnitPreferences } from './units.types';
|
||||
import {
|
||||
formatDistanceBySystem,
|
||||
formatVolumeBySystem,
|
||||
formatFuelEfficiencyBySystem,
|
||||
formatPriceBySystem,
|
||||
convertDistanceBySystem,
|
||||
convertVolumeBySystem,
|
||||
convertFuelEfficiencyBySystem,
|
||||
getDistanceUnit,
|
||||
getVolumeUnit,
|
||||
getFuelEfficiencyUnit
|
||||
} from './units.utils';
|
||||
|
||||
interface UnitsContextType {
|
||||
unitSystem: UnitSystem;
|
||||
setUnitSystem: (system: UnitSystem) => void;
|
||||
preferences: UnitPreferences;
|
||||
|
||||
// Conversion functions
|
||||
convertDistance: (miles: number) => number;
|
||||
convertVolume: (gallons: number) => number;
|
||||
convertFuelEfficiency: (mpg: number) => number;
|
||||
|
||||
// Formatting functions
|
||||
formatDistance: (miles: number, precision?: number) => string;
|
||||
formatVolume: (gallons: number, precision?: number) => string;
|
||||
formatFuelEfficiency: (mpg: number, precision?: number) => string;
|
||||
formatPrice: (pricePerGallon: number, currency?: string, precision?: number) => string;
|
||||
}
|
||||
|
||||
const UnitsContext = createContext<UnitsContextType | undefined>(undefined);
|
||||
|
||||
interface UnitsProviderProps {
|
||||
children: ReactNode;
|
||||
initialSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export const UnitsProvider: React.FC<UnitsProviderProps> = ({
|
||||
children,
|
||||
initialSystem = 'imperial'
|
||||
}) => {
|
||||
const [unitSystem, setUnitSystem] = useState<UnitSystem>(initialSystem);
|
||||
|
||||
// Load unit preference from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('motovaultpro-unit-system');
|
||||
if (stored === 'imperial' || stored === 'metric') {
|
||||
setUnitSystem(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save unit preference to localStorage when changed
|
||||
const handleSetUnitSystem = (system: UnitSystem) => {
|
||||
setUnitSystem(system);
|
||||
localStorage.setItem('motovaultpro-unit-system', system);
|
||||
};
|
||||
|
||||
// Generate preferences object based on current system
|
||||
const preferences: UnitPreferences = {
|
||||
system: unitSystem,
|
||||
distance: getDistanceUnit(unitSystem),
|
||||
volume: getVolumeUnit(unitSystem),
|
||||
fuelEfficiency: getFuelEfficiencyUnit(unitSystem),
|
||||
};
|
||||
|
||||
// Conversion functions using current unit system
|
||||
const convertDistance = (miles: number) => convertDistanceBySystem(miles, unitSystem);
|
||||
const convertVolume = (gallons: number) => convertVolumeBySystem(gallons, unitSystem);
|
||||
const convertFuelEfficiency = (mpg: number) => convertFuelEfficiencyBySystem(mpg, unitSystem);
|
||||
|
||||
// Formatting functions using current unit system
|
||||
const formatDistance = (miles: number, precision?: number) =>
|
||||
formatDistanceBySystem(miles, unitSystem, precision);
|
||||
|
||||
const formatVolume = (gallons: number, precision?: number) =>
|
||||
formatVolumeBySystem(gallons, unitSystem, precision);
|
||||
|
||||
const formatFuelEfficiency = (mpg: number, precision?: number) =>
|
||||
formatFuelEfficiencyBySystem(mpg, unitSystem, precision);
|
||||
|
||||
const formatPrice = (pricePerGallon: number, currency?: string, precision?: number) =>
|
||||
formatPriceBySystem(pricePerGallon, unitSystem, currency, precision);
|
||||
|
||||
const value: UnitsContextType = {
|
||||
unitSystem,
|
||||
setUnitSystem: handleSetUnitSystem,
|
||||
preferences,
|
||||
convertDistance,
|
||||
convertVolume,
|
||||
convertFuelEfficiency,
|
||||
formatDistance,
|
||||
formatVolume,
|
||||
formatFuelEfficiency,
|
||||
formatPrice,
|
||||
};
|
||||
|
||||
return (
|
||||
<UnitsContext.Provider value={value}>
|
||||
{children}
|
||||
</UnitsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUnits = (): UnitsContextType => {
|
||||
const context = useContext(UnitsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useUnits must be used within a UnitsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
24
frontend/src/core/units/units.types.ts
Normal file
24
frontend/src/core/units/units.types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for unit system support
|
||||
* @ai-context Frontend types for Imperial/Metric unit preferences
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
export type DistanceUnit = 'miles' | 'km';
|
||||
export type VolumeUnit = 'gallons' | 'liters';
|
||||
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
|
||||
|
||||
export interface UnitPreferences {
|
||||
system: UnitSystem;
|
||||
distance: DistanceUnit;
|
||||
volume: VolumeUnit;
|
||||
fuelEfficiency: FuelEfficiencyUnit;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
194
frontend/src/core/units/units.utils.ts
Normal file
194
frontend/src/core/units/units.utils.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @ai-summary Frontend unit conversion utilities
|
||||
* @ai-context Mirror of backend unit conversion functions for frontend use
|
||||
*/
|
||||
|
||||
import { UnitSystem, DistanceUnit, VolumeUnit, FuelEfficiencyUnit } from './units.types';
|
||||
|
||||
// Conversion constants
|
||||
const MILES_TO_KM = 1.60934;
|
||||
const KM_TO_MILES = 0.621371;
|
||||
const GALLONS_TO_LITERS = 3.78541;
|
||||
const LITERS_TO_GALLONS = 0.264172;
|
||||
const MPG_TO_L100KM_FACTOR = 235.214;
|
||||
|
||||
// Distance Conversions
|
||||
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'miles' && toUnit === 'km') {
|
||||
return value * MILES_TO_KM;
|
||||
}
|
||||
|
||||
if (fromUnit === 'km' && toUnit === 'miles') {
|
||||
return value * KM_TO_MILES;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertDistanceBySystem(miles: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertDistance(miles, 'miles', 'km');
|
||||
}
|
||||
return miles;
|
||||
}
|
||||
|
||||
// Volume Conversions
|
||||
export function convertVolume(value: number, fromUnit: VolumeUnit, toUnit: VolumeUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'gallons' && toUnit === 'liters') {
|
||||
return value * GALLONS_TO_LITERS;
|
||||
}
|
||||
|
||||
if (fromUnit === 'liters' && toUnit === 'gallons') {
|
||||
return value * LITERS_TO_GALLONS;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertVolume(gallons, 'gallons', 'liters');
|
||||
}
|
||||
return gallons;
|
||||
}
|
||||
|
||||
// Fuel Efficiency Conversions
|
||||
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'mpg' && toUnit === 'l100km') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
if (fromUnit === 'l100km' && toUnit === 'mpg') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
|
||||
}
|
||||
return mpg;
|
||||
}
|
||||
|
||||
// Display Formatting Functions
|
||||
export function formatDistance(value: number, unit: DistanceUnit, precision = 1): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'miles' ? '0 miles' : '0 km';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'miles') {
|
||||
return `${rounded.toLocaleString()} miles`;
|
||||
} else {
|
||||
return `${rounded.toLocaleString()} km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVolume(value: number, unit: VolumeUnit, precision = 2): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'gallons' ? '0 gal' : '0 L';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${rounded} gal`;
|
||||
} else {
|
||||
return `${rounded} L`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return unit === 'mpg' ? '0 MPG' : '0 L/100km';
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'mpg') {
|
||||
return `${rounded} MPG`;
|
||||
} else {
|
||||
return `${rounded} L/100km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(value: number, unit: VolumeUnit, currency = 'USD', precision = 3): string {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
return unit === 'gallons' ? `${formatter.format(0)}/gal` : `${formatter.format(0)}/L`;
|
||||
}
|
||||
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${formatter.format(rounded)}/gal`;
|
||||
} else {
|
||||
return `${formatter.format(rounded)}/L`;
|
||||
}
|
||||
}
|
||||
|
||||
// System-based formatting (convenience functions)
|
||||
export function formatDistanceBySystem(miles: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const km = convertDistanceBySystem(miles, system);
|
||||
return formatDistance(km, 'km', precision);
|
||||
}
|
||||
return formatDistance(miles, 'miles', precision);
|
||||
}
|
||||
|
||||
export function formatVolumeBySystem(gallons: number, system: UnitSystem, precision = 2): string {
|
||||
if (system === 'metric') {
|
||||
const liters = convertVolumeBySystem(gallons, system);
|
||||
return formatVolume(liters, 'liters', precision);
|
||||
}
|
||||
return formatVolume(gallons, 'gallons', precision);
|
||||
}
|
||||
|
||||
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const l100km = convertFuelEfficiencyBySystem(mpg, system);
|
||||
return formatFuelEfficiency(l100km, 'l100km', precision);
|
||||
}
|
||||
return formatFuelEfficiency(mpg, 'mpg', precision);
|
||||
}
|
||||
|
||||
export function formatPriceBySystem(pricePerGallon: number, system: UnitSystem, currency = 'USD', precision = 3): string {
|
||||
if (system === 'metric') {
|
||||
const pricePerLiter = pricePerGallon * LITERS_TO_GALLONS;
|
||||
return formatPrice(pricePerLiter, 'liters', currency, precision);
|
||||
}
|
||||
return formatPrice(pricePerGallon, 'gallons', currency, precision);
|
||||
}
|
||||
|
||||
// Unit system helpers
|
||||
export function getDistanceUnit(system: UnitSystem): DistanceUnit {
|
||||
return system === 'metric' ? 'km' : 'miles';
|
||||
}
|
||||
|
||||
export function getVolumeUnit(system: UnitSystem): VolumeUnit {
|
||||
return system === 'metric' ? 'liters' : 'gallons';
|
||||
}
|
||||
|
||||
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
||||
return system === 'metric' ? 'l100km' : 'mpg';
|
||||
}
|
||||
35
frontend/src/features/fuel-logs/api/fuel-logs.api.ts
Normal file
35
frontend/src/features/fuel-logs/api/fuel-logs.api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats, FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
|
||||
export const fuelLogsApi = {
|
||||
async create(data: CreateFuelLogRequest): Promise<FuelLogResponse> {
|
||||
const res = await apiClient.post('/fuel-logs', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getUserFuelLogs(): Promise<FuelLogResponse[]> {
|
||||
const res = await apiClient.get('/fuel-logs');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string): Promise<FuelLogResponse[]> {
|
||||
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getVehicleStats(vehicleId: string): Promise<EnhancedFuelStats> {
|
||||
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}/stats`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getFuelTypes(): Promise<{ value: FuelType; label: string; grades: FuelGradeOption[] }[]> {
|
||||
const res = await apiClient.get('/fuel-logs/fuel-types');
|
||||
return res.data.fuelTypes;
|
||||
},
|
||||
|
||||
async getFuelGrades(fuelType: FuelType): Promise<FuelGradeOption[]> {
|
||||
const res = await apiClient.get(`/fuel-logs/fuel-grades/${fuelType}`);
|
||||
return res.data.grades;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
|
||||
import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
fuelUnits?: number;
|
||||
costPerUnit?: number;
|
||||
calculatedCost: number;
|
||||
unitSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export const CostCalculator: React.FC<Props> = ({ fuelUnits, costPerUnit, calculatedCost, unitSystem = 'imperial' }) => {
|
||||
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
|
||||
|
||||
// Ensure we have valid numbers
|
||||
const safeUnits = typeof fuelUnits === 'number' && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||
const safeCostPerUnit = typeof costPerUnit === 'number' && !isNaN(costPerUnit) ? costPerUnit : 0;
|
||||
const safeCost = typeof calculatedCost === 'number' && !isNaN(calculatedCost) ? calculatedCost : 0;
|
||||
|
||||
if (!fuelUnits || !costPerUnit || safeUnits <= 0 || safeCostPerUnit <= 0) {
|
||||
return (
|
||||
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">Enter fuel amount and cost per unit to see total cost.</Typography></CardContent></Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="body2" color="text.secondary">Cost Calculation</Typography>
|
||||
<Chip label="Real-time" size="small" color="primary" variant="outlined" />
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2">{safeUnits.toFixed(3)} {unitLabel} × ${safeCostPerUnit.toFixed(3)}</Typography>
|
||||
<Typography variant="h6" color="primary.main" fontWeight={700}>${safeCost.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
34
frontend/src/features/fuel-logs/components/DistanceInput.tsx
Normal file
34
frontend/src/features/fuel-logs/components/DistanceInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { TextField, InputAdornment, FormHelperText, Box } from '@mui/material';
|
||||
import { UnitSystem, DistanceType } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
type: DistanceType;
|
||||
value?: number;
|
||||
onChange: (value: number) => void;
|
||||
unitSystem?: UnitSystem;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DistanceInput: React.FC<Props> = ({ type, value, onChange, unitSystem = 'imperial', error, disabled }) => {
|
||||
const units = unitSystem === 'imperial' ? 'miles' : 'kilometers';
|
||||
const label = type === 'odometer' ? `Odometer (${units})` : `Trip Distance (${units})`;
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
label={label}
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
fullWidth
|
||||
error={!!error}
|
||||
disabled={disabled}
|
||||
inputProps={{ step: type === 'trip' ? 0.1 : 1, min: 0 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">{units}</InputAdornment> }}
|
||||
/>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
161
frontend/src/features/fuel-logs/components/FuelLogForm.tsx
Normal file
161
frontend/src/features/fuel-logs/components/FuelLogForm.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Grid, Card, CardHeader, CardContent, TextField, Switch, FormControlLabel, Box, Button, CircularProgress } from '@mui/material';
|
||||
import { VehicleSelector } from './VehicleSelector';
|
||||
import { DistanceInput } from './DistanceInput';
|
||||
import { FuelTypeSelector } from './FuelTypeSelector';
|
||||
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
||||
import { LocationInput } from './LocationInput';
|
||||
import { CostCalculator } from './CostCalculator';
|
||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||
import { useUserSettings } from '../hooks/useUserSettings';
|
||||
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||
|
||||
const schema = z.object({
|
||||
vehicleId: z.string().uuid(),
|
||||
dateTime: z.string().min(1),
|
||||
odometerReading: z.coerce.number().positive().optional(),
|
||||
tripDistance: z.coerce.number().positive().optional(),
|
||||
fuelType: z.nativeEnum(FuelType),
|
||||
fuelGrade: z.union([z.string(), z.null()]).optional(),
|
||||
fuelUnits: z.coerce.number().positive(),
|
||||
costPerUnit: z.coerce.number().positive(),
|
||||
locationData: z.any().optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
}).refine((d) => (d.odometerReading && d.odometerReading > 0) || (d.tripDistance && d.tripDistance > 0), {
|
||||
message: 'Either odometer reading or trip distance is required'
|
||||
}).refine((d) => !(d.odometerReading && d.tripDistance), {
|
||||
message: 'Cannot specify both odometer reading and trip distance'
|
||||
});
|
||||
|
||||
export const FuelLogForm: React.FC<{ onSuccess?: () => void; initial?: Partial<CreateFuelLogRequest> }> = ({ onSuccess, initial }) => {
|
||||
const { userSettings } = useUserSettings();
|
||||
const { createFuelLog, isLoading } = useFuelLogs();
|
||||
const [useOdometer, setUseOdometer] = useState(false);
|
||||
|
||||
const { control, handleSubmit, watch, setValue, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
dateTime: new Date().toISOString().slice(0, 16),
|
||||
fuelType: FuelType.GASOLINE,
|
||||
...initial
|
||||
} as any
|
||||
});
|
||||
|
||||
const watched = watch(['fuelUnits', 'costPerUnit']);
|
||||
const [fuelUnitsRaw, costPerUnitRaw] = watched as [string | number | undefined, string | number | undefined];
|
||||
|
||||
// Convert to numbers for calculation
|
||||
const fuelUnits = typeof fuelUnitsRaw === 'string' ? parseFloat(fuelUnitsRaw) : fuelUnitsRaw;
|
||||
const costPerUnit = typeof costPerUnitRaw === 'string' ? parseFloat(costPerUnitRaw) : costPerUnitRaw;
|
||||
|
||||
const calculatedCost = useMemo(() => {
|
||||
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||
const cost = costPerUnit && !isNaN(costPerUnit) ? costPerUnit : 0;
|
||||
return units > 0 && cost > 0 ? units * cost : 0;
|
||||
}, [fuelUnits, costPerUnit]);
|
||||
|
||||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||||
const payload: CreateFuelLogRequest = {
|
||||
...data,
|
||||
odometerReading: useOdometer ? data.odometerReading : undefined,
|
||||
tripDistance: useOdometer ? undefined : data.tripDistance,
|
||||
};
|
||||
await createFuelLog(payload);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useOdometer) setValue('tripDistance', undefined as any);
|
||||
else setValue('odometerReading', undefined as any);
|
||||
}, [useOdometer, setValue]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="vehicleId" control={control} render={({ field }) => (
|
||||
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name="dateTime" control={control} render={({ field }) => (
|
||||
<TextField {...field} label="Date & Time" type="datetime-local" fullWidth error={!!errors.dateTime} helperText={errors.dateTime?.message} InputLabelProps={{ shrink: true }} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
|
||||
<DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
|
||||
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
|
||||
<FuelTypeSelector fuelType={fuelTypeField.value} fuelGrade={fuelGradeField.value as any} onFuelTypeChange={fuelTypeField.onChange} onFuelGradeChange={fuelGradeField.onChange as any} error={(errors.fuelType?.message as any) || (errors.fuelGrade?.message as any)} />
|
||||
)} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name="fuelUnits" control={control} render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
label={`Fuel Amount (${userSettings?.unitSystem === 'imperial' ? 'gallons' : 'liters'})`}
|
||||
type="number"
|
||||
inputProps={{ step: 0.001, min: 0.001 }}
|
||||
fullWidth
|
||||
error={!!errors.fuelUnits}
|
||||
helperText={errors.fuelUnits?.message}
|
||||
/>
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name="costPerUnit" control={control} render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
label={`Cost Per ${userSettings?.unitSystem === 'imperial' ? 'Gallon' : 'Liter'}`}
|
||||
type="number"
|
||||
inputProps={{ step: 0.001, min: 0.001 }}
|
||||
fullWidth
|
||||
error={!!errors.costPerUnit}
|
||||
helperText={errors.costPerUnit?.message}
|
||||
/>
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<CostCalculator fuelUnits={fuelUnits} costPerUnit={costPerUnit} calculatedCost={calculatedCost} unitSystem={userSettings?.unitSystem} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="locationData" control={control} render={({ field }) => (
|
||||
<LocationInput value={field.value as any} onChange={field.onChange as any} placeholder="Station location (optional)" />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="notes" control={control} render={({ field }) => (
|
||||
<TextField {...field} label="Notes (optional)" multiline rows={3} fullWidth error={!!errors.notes} helperText={errors.notes?.message} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box display="flex" gap={2} justifyContent="flex-end">
|
||||
<Button type="submit" variant="contained" disabled={!isValid || isLoading} startIcon={isLoading ? <CircularProgress size={18} /> : undefined}>Add Fuel Log</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
27
frontend/src/features/fuel-logs/components/FuelLogsList.tsx
Normal file
27
frontend/src/features/fuel-logs/components/FuelLogsList.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Chip, Box } from '@mui/material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
|
||||
export const FuelLogsList: React.FC<{ logs?: FuelLogResponse[] }>= ({ logs }) => {
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">No fuel logs yet.</Typography></CardContent></Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List>
|
||||
{logs.map((log) => (
|
||||
<ListItem key={log.id} divider>
|
||||
<ListItemText
|
||||
primary={`${new Date(log.dateTime).toLocaleString()} – $${(log.totalCost || 0).toFixed(2)}`}
|
||||
secondary={`${(log.fuelUnits || 0).toFixed(3)} @ $${(log.costPerUnit || 0).toFixed(3)} • ${log.odometerReading ? `Odo: ${log.odometerReading}` : `Trip: ${log.tripDistance}`}`}
|
||||
/>
|
||||
{log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && (
|
||||
<Box><Chip label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`} size="small" color="primary" /></Box>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
38
frontend/src/features/fuel-logs/components/FuelStatsCard.tsx
Normal file
38
frontend/src/features/fuel-logs/components/FuelStatsCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardContent, Grid, Typography } from '@mui/material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
|
||||
export const FuelStatsCard: React.FC<{ logs?: FuelLogResponse[] }> = ({ logs }) => {
|
||||
const { unitSystem } = useUnits();
|
||||
const stats = useMemo(() => {
|
||||
if (!logs || logs.length === 0) return { count: 0, totalUnits: 0, totalCost: 0 };
|
||||
const totalUnits = logs.reduce((s, l) => s + (l.fuelUnits || 0), 0);
|
||||
const totalCost = logs.reduce((s, l) => s + (l.totalCost || 0), 0);
|
||||
return { count: logs.length, totalUnits, totalCost };
|
||||
}, [logs]);
|
||||
|
||||
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Logs</Typography>
|
||||
<Typography variant="h6">{stats.count}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Total Fuel</Typography>
|
||||
<Typography variant="h6">{(stats.totalUnits || 0).toFixed(2)} {unitLabel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Total Cost</Typography>
|
||||
<Typography variant="h6">${(stats.totalCost || 0).toFixed(2)}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormControl, InputLabel, Select, MenuItem, Grid, FormHelperText } from '@mui/material';
|
||||
import { FuelType, FuelGrade } from '../types/fuel-logs.types';
|
||||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||||
|
||||
interface Props {
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
onFuelTypeChange: (fuelType: FuelType) => void;
|
||||
onFuelGradeChange: (fuelGrade?: FuelGrade) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FuelTypeSelector: React.FC<Props> = ({ fuelType, fuelGrade, onFuelTypeChange, onFuelGradeChange, error, disabled }) => {
|
||||
const { fuelGrades, isLoading } = useFuelGrades(fuelType);
|
||||
|
||||
useEffect(() => {
|
||||
if (fuelGrade && fuelGrades && !fuelGrades.some(g => g.value === fuelGrade)) {
|
||||
onFuelGradeChange(undefined);
|
||||
}
|
||||
}, [fuelType, fuelGrades, fuelGrade, onFuelGradeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fuelGrade && fuelGrades && fuelGrades.length > 0) {
|
||||
onFuelGradeChange(fuelGrades[0].value as FuelGrade);
|
||||
}
|
||||
}, [fuelGrades, fuelGrade, onFuelGradeChange]);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth error={!!error}>
|
||||
<InputLabel>Fuel Type</InputLabel>
|
||||
<Select value={fuelType} label="Fuel Type" onChange={(e) => onFuelTypeChange(e.target.value as FuelType)} disabled={disabled}>
|
||||
<MenuItem value={FuelType.GASOLINE}>Gasoline</MenuItem>
|
||||
<MenuItem value={FuelType.DIESEL}>Diesel</MenuItem>
|
||||
<MenuItem value={FuelType.ELECTRIC}>Electric</MenuItem>
|
||||
</Select>
|
||||
{error && <FormHelperText>{error}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth disabled={disabled || isLoading || fuelType === FuelType.ELECTRIC}>
|
||||
<InputLabel>Fuel Grade</InputLabel>
|
||||
<Select value={fuelGrade || ''} label="Fuel Grade" onChange={(e) => onFuelGradeChange(e.target.value as FuelGrade)}>
|
||||
{fuelGrades?.map((g) => (
|
||||
<MenuItem key={g.value || 'none'} value={g.value || ''}>{g.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{fuelType !== FuelType.ELECTRIC && <FormHelperText>{isLoading ? 'Loading grades…' : 'Select a grade'}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
22
frontend/src/features/fuel-logs/components/LocationInput.tsx
Normal file
22
frontend/src/features/fuel-logs/components/LocationInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { LocationData } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
value?: LocationData;
|
||||
onChange: (value?: LocationData) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const LocationInput: React.FC<Props> = ({ value, onChange, placeholder }) => {
|
||||
return (
|
||||
<TextField
|
||||
label="Location (optional)"
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
value={value?.stationName || value?.address || ''}
|
||||
onChange={(e) => onChange({ ...(value || {}), stationName: e.target.value })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
|
||||
if (!unitSystem) return null;
|
||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, L/100km)';
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{showLabel ? `${showLabel} ` : ''}{label}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText, Box, Typography } from '@mui/material';
|
||||
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange: (vehicleId: string) => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VehicleSelector: React.FC<Props> = ({ value, onChange, error, required, disabled }) => {
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
|
||||
if (!isLoading && (vehicles?.length || 0) === 0) {
|
||||
return (
|
||||
<Box p={2} borderRadius={1} bgcolor={'background.default'}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You need to add a vehicle before creating fuel logs.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl fullWidth error={!!error} required={required}>
|
||||
<InputLabel>Select Vehicle</InputLabel>
|
||||
<Select value={value || ''} onChange={(e) => onChange(e.target.value as string)} label="Select Vehicle" disabled={disabled}>
|
||||
{vehicles?.map((v: Vehicle) => (
|
||||
<MenuItem key={v.id} value={v.id}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<DirectionsCarIcon fontSize="small" />
|
||||
<Typography variant="body2">{`${v.year || ''} ${v.make || ''} ${v.model || ''}`.trim()}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{error && <FormHelperText>{error}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/fuel-logs/hooks/useFuelGrades.tsx
Normal file
12
frontend/src/features/fuel-logs/hooks/useFuelGrades.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
import { FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
|
||||
export const useFuelGrades = (fuelType: FuelType) => {
|
||||
const { data, isLoading, error } = useQuery<FuelGradeOption[]>({
|
||||
queryKey: ['fuelGrades', fuelType],
|
||||
queryFn: () => fuelLogsApi.getFuelGrades(fuelType),
|
||||
});
|
||||
return { fuelGrades: data || [], isLoading, error };
|
||||
};
|
||||
|
||||
36
frontend/src/features/fuel-logs/hooks/useFuelLogs.tsx
Normal file
36
frontend/src/features/fuel-logs/hooks/useFuelLogs.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats } from '../types/fuel-logs.types';
|
||||
|
||||
export const useFuelLogs = (vehicleId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const logsQuery = useQuery<FuelLogResponse[]>({
|
||||
queryKey: ['fuelLogs', vehicleId || 'all'],
|
||||
queryFn: () => (vehicleId ? fuelLogsApi.getFuelLogsByVehicle(vehicleId) : fuelLogsApi.getUserFuelLogs()),
|
||||
});
|
||||
|
||||
const statsQuery = useQuery<EnhancedFuelStats>({
|
||||
queryKey: ['fuelLogsStats', vehicleId],
|
||||
queryFn: () => fuelLogsApi.getVehicleStats(vehicleId!),
|
||||
enabled: !!vehicleId,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateFuelLogRequest) => fuelLogsApi.create(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
fuelLogs: logsQuery.data,
|
||||
isLoading: logsQuery.isLoading || createMutation.isPending,
|
||||
error: logsQuery.error,
|
||||
stats: statsQuery.data,
|
||||
isStatsLoading: statsQuery.isLoading,
|
||||
createFuelLog: createMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
|
||||
15
frontend/src/features/fuel-logs/hooks/useUserSettings.tsx
Normal file
15
frontend/src/features/fuel-logs/hooks/useUserSettings.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
export const useUserSettings = () => {
|
||||
const { unitSystem } = useUnits();
|
||||
// Placeholder for future: fetch currency/timezone from a settings API
|
||||
return {
|
||||
userSettings: {
|
||||
unitSystem: unitSystem as UnitSystem,
|
||||
currencyCode: 'USD',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
24
frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx
Normal file
24
frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Grid, Typography } from '@mui/material';
|
||||
import { FuelLogForm } from '../components/FuelLogForm';
|
||||
import { FuelLogsList } from '../components/FuelLogsList';
|
||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||
import { FuelStatsCard } from '../components/FuelStatsCard';
|
||||
|
||||
export const FuelLogsPage: React.FC = () => {
|
||||
const { fuelLogs } = useFuelLogs();
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FuelLogForm />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" gutterBottom>Recent Fuel Logs</Typography>
|
||||
<FuelLogsList logs={fuelLogs} />
|
||||
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Summary</Typography>
|
||||
<FuelStatsCard logs={fuelLogs} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
72
frontend/src/features/fuel-logs/types/fuel-logs.types.ts
Normal file
72
frontend/src/features/fuel-logs/types/fuel-logs.types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @ai-summary Types for enhanced fuel logs UI
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export enum FuelType {
|
||||
GASOLINE = 'gasoline',
|
||||
DIESEL = 'diesel',
|
||||
ELECTRIC = 'electric'
|
||||
}
|
||||
|
||||
export type FuelGrade = '87' | '88' | '89' | '91' | '93' | '#1' | '#2' | null;
|
||||
|
||||
export interface LocationData {
|
||||
address?: string;
|
||||
coordinates?: { latitude: number; longitude: number };
|
||||
googlePlaceId?: string;
|
||||
stationName?: string;
|
||||
}
|
||||
|
||||
export type DistanceType = 'odometer' | 'trip';
|
||||
|
||||
export interface CreateFuelLogRequest {
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
locationData?: LocationData;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface FuelLogResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: LocationData;
|
||||
efficiency?: number;
|
||||
efficiencyLabel: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFuelStats {
|
||||
logCount: number;
|
||||
totalFuelUnits: number;
|
||||
totalCost: number;
|
||||
averageCostPerUnit: number;
|
||||
totalDistance: number;
|
||||
averageEfficiency: number;
|
||||
unitLabels: { fuelUnits: string; distanceUnits: string; efficiencyUnits: string };
|
||||
}
|
||||
|
||||
export interface FuelGradeOption {
|
||||
value: FuelGrade;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
58
frontend/src/features/settings/hooks/useSettings.ts
Normal file
58
frontend/src/features/settings/hooks/useSettings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSettingsPersistence, SettingsState } from './useSettingsPersistence';
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
darkMode: false,
|
||||
unitSystem: 'imperial',
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const { loadSettings, saveSettings } = useSettingsPersistence();
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const savedSettings = loadSettings();
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
const updateSetting = <K extends keyof SettingsState>(
|
||||
key: K,
|
||||
value: SettingsState[K]
|
||||
) => {
|
||||
try {
|
||||
setError(null);
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSetting,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export type { SettingsState } from './useSettingsPersistence';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export interface SettingsState {
|
||||
darkMode: boolean;
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
maintenance: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
|
||||
|
||||
export const useSettingsPersistence = () => {
|
||||
const loadSettings = useCallback((): SettingsState | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback((settings: SettingsState) => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
};
|
||||
};
|
||||
323
frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
Normal file
323
frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
label,
|
||||
description
|
||||
}) => (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{label}</p>
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({
|
||||
logoutParams: {
|
||||
returnTo: window.location.origin
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
// TODO: Implement data export functionality
|
||||
console.log('Exporting user data...');
|
||||
setShowDataExport(false);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
// TODO: Implement account deletion
|
||||
console.log('Deleting account...');
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading settings...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 mb-4">Failed to load settings</p>
|
||||
<p className="text-sm text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.picture && (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{user?.name}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2>
|
||||
<div className="space-y-3">
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.email}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
email: !settings.notifications.email
|
||||
})}
|
||||
label="Email Notifications"
|
||||
description="Receive updates via email"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.push}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
push: !settings.notifications.push
|
||||
})}
|
||||
label="Push Notifications"
|
||||
description="Receive mobile push notifications"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.maintenance}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
maintenance: !settings.notifications.maintenance
|
||||
})}
|
||||
label="Maintenance Reminders"
|
||||
description="Get reminded about vehicle maintenance"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Appearance & Units Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2>
|
||||
<div className="space-y-4">
|
||||
<ToggleSwitch
|
||||
enabled={settings.darkMode}
|
||||
onChange={() => updateSetting('darkMode', !settings.darkMode)}
|
||||
label="Dark Mode"
|
||||
description="Switch to dark theme"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">Unit System</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Currently using {settings.unitSystem === 'imperial' ? 'Miles & Gallons' : 'Kilometers & Liters'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
{settings.unitSystem === 'imperial' ? 'Switch to Metric' : 'Switch to Imperial'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(true)}
|
||||
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
<p className="text-sm text-slate-500">
|
||||
Download a copy of all your vehicle and fuel data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Account Actions Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full py-3 px-4 bg-red-50 text-red-600 rounded-lg text-left font-medium hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Data Export Modal */}
|
||||
<Modal
|
||||
isOpen={showDataExport}
|
||||
onClose={() => setShowDataExport(false)}
|
||||
title="Export Data"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Delete Account"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This action cannot be undone. All your data will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,9 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import axios from 'axios';
|
||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption, VINDecodeResponse } from '../types/vehicles.types';
|
||||
|
||||
// Unauthenticated client for dropdown data
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const dropdownClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
// All requests (including dropdowns) use authenticated apiClient
|
||||
|
||||
export const vehiclesApi = {
|
||||
getAll: async (): Promise<Vehicle[]> => {
|
||||
@@ -41,29 +32,40 @@ export const vehiclesApi = {
|
||||
await apiClient.delete(`/vehicles/${id}`);
|
||||
},
|
||||
|
||||
// Dropdown API methods (unauthenticated)
|
||||
getMakes: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/makes');
|
||||
// Dropdown API methods (authenticated)
|
||||
getYears: async (): Promise<number[]> => {
|
||||
const response = await apiClient.get('/vehicles/dropdown/years');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getModels: async (make: string): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`);
|
||||
getMakes: async (year: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/makes?year=${year}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTransmissions: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/transmissions');
|
||||
getModels: async (year: number, makeId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make_id=${makeId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getEngines: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/engines');
|
||||
getTransmissions: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make_id=${makeId}&model_id=${modelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrims: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/trims');
|
||||
getEngines: async (year: number, makeId: number, modelId: number, trimId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make_id=${makeId}&model_id=${modelId}&trim_id=${trimId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
getTrims: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make_id=${makeId}&model_id=${modelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// VIN decode method
|
||||
decodeVIN: async (vin: string): Promise<VINDecodeResponse> => {
|
||||
const response = await apiClient.post('/vehicles/decode-vin', { vin });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardActionArea, Box, Typography, IconButton } from '
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -35,8 +36,9 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
onDelete,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { formatDistance } = useUnits();
|
||||
const displayName = vehicle.nickname ||
|
||||
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -72,7 +74,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
|
||||
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
||||
Odometer: {formatDistance(vehicle.odometerReading)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
@@ -10,20 +10,49 @@ import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { CreateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
|
||||
const vehicleSchema = z.object({
|
||||
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
engine: z.string().optional(),
|
||||
transmission: z.string().optional(),
|
||||
trimLevel: z.string().optional(),
|
||||
driveType: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
licensePlate: z.string().optional(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
});
|
||||
const vehicleSchema = z
|
||||
.object({
|
||||
vin: z.string().optional(),
|
||||
year: z.number().min(1980).max(new Date().getFullYear() + 1).optional(),
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
engine: z.string().optional(),
|
||||
transmission: z.string().optional(),
|
||||
trimLevel: z.string().optional(),
|
||||
driveType: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
licensePlate: z.string().optional(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const vin = (data.vin || '').trim();
|
||||
const plate = (data.licensePlate || '').trim();
|
||||
// Must have either a valid 17-char VIN or a non-empty license plate
|
||||
if (vin.length === 17) return true;
|
||||
if (plate.length > 0) return true;
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: 'Either a valid 17-character VIN or a license plate is required',
|
||||
path: ['vin'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
const vin = (data.vin || '').trim();
|
||||
const plate = (data.licensePlate || '').trim();
|
||||
// If VIN provided but not 17 and no plate, fail; if plate exists, allow any VIN (or empty)
|
||||
if (plate.length > 0) return true;
|
||||
return vin.length === 17 || vin.length === 0;
|
||||
},
|
||||
{
|
||||
message: 'VIN must be exactly 17 characters when license plate is not provided',
|
||||
path: ['vin'],
|
||||
}
|
||||
);
|
||||
|
||||
interface VehicleFormProps {
|
||||
onSubmit: (data: CreateVehicleRequest) => void;
|
||||
@@ -38,13 +67,18 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
|
||||
const [engines, setEngines] = useState<DropdownOption[]>([]);
|
||||
const [trims, setTrims] = useState<DropdownOption[]>([]);
|
||||
const [selectedMake, setSelectedMake] = useState<string>('');
|
||||
const [selectedYear, setSelectedYear] = useState<number | undefined>();
|
||||
const [selectedMake, setSelectedMake] = useState<DropdownOption | undefined>();
|
||||
const [selectedModel, setSelectedModel] = useState<DropdownOption | undefined>();
|
||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||
const [selectedTrim, setSelectedTrim] = useState<DropdownOption | undefined>();
|
||||
const [decodingVIN, setDecodingVIN] = useState(false);
|
||||
const [decodeSuccess, setDecodeSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -57,73 +91,226 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
const watchedYear = watch('year');
|
||||
const watchedMake = watch('make');
|
||||
const watchedModel = watch('model');
|
||||
const watchedVIN = watch('vin');
|
||||
|
||||
// Load dropdown data on component mount
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const [makesData, transmissionsData, enginesData, trimsData] = await Promise.all([
|
||||
vehiclesApi.getMakes(),
|
||||
vehiclesApi.getTransmissions(),
|
||||
vehiclesApi.getEngines(),
|
||||
vehiclesApi.getTrims(),
|
||||
]);
|
||||
// VIN decode handler
|
||||
const handleDecodeVIN = async () => {
|
||||
const vin = watchedVIN;
|
||||
if (!vin || vin.length !== 17) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDecodingVIN(true);
|
||||
setDecodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await vehiclesApi.decodeVIN(vin);
|
||||
if (result.success) {
|
||||
// Auto-populate fields with decoded values
|
||||
if (result.year) setValue('year', result.year);
|
||||
if (result.make) setValue('make', result.make);
|
||||
if (result.model) setValue('model', result.model);
|
||||
if (result.transmission) setValue('transmission', result.transmission);
|
||||
if (result.engine) setValue('engine', result.engine);
|
||||
if (result.trimLevel) setValue('trimLevel', result.trimLevel);
|
||||
|
||||
setMakes(makesData);
|
||||
setTransmissions(transmissionsData);
|
||||
setEngines(enginesData);
|
||||
setTrims(trimsData);
|
||||
setDecodeSuccess(true);
|
||||
setTimeout(() => setDecodeSuccess(false), 3000); // Hide success after 3 seconds
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('VIN decode failed:', error);
|
||||
} finally {
|
||||
setDecodingVIN(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load years on component mount
|
||||
useEffect(() => {
|
||||
const loadYears = async () => {
|
||||
try {
|
||||
const yearsData = await vehiclesApi.getYears();
|
||||
setYears(yearsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dropdown data:', error);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
console.error('Failed to load years:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
loadYears();
|
||||
}, []);
|
||||
|
||||
// Load models when make changes
|
||||
// Load makes when year changes
|
||||
useEffect(() => {
|
||||
if (watchedMake && watchedMake !== selectedMake) {
|
||||
const loadModels = async () => {
|
||||
if (watchedYear && watchedYear !== selectedYear) {
|
||||
const loadMakes = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const modelsData = await vehiclesApi.getModels(watchedMake);
|
||||
setModels(modelsData);
|
||||
setSelectedMake(watchedMake);
|
||||
const makesData = await vehiclesApi.getMakes(watchedYear);
|
||||
setMakes(makesData);
|
||||
setSelectedYear(watchedYear);
|
||||
|
||||
// Clear model selection when make changes
|
||||
setValue('model', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
// Clear dependent selections
|
||||
setModels([]);
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedMake(undefined);
|
||||
setSelectedModel(undefined);
|
||||
setValue('make', '');
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load makes:', error);
|
||||
setMakes([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
loadMakes();
|
||||
}
|
||||
}, [watchedMake, selectedMake, setValue]);
|
||||
}, [watchedYear, selectedYear, setValue]);
|
||||
|
||||
// Load models when make changes
|
||||
useEffect(() => {
|
||||
if (watchedMake && watchedYear && watchedMake !== selectedMake?.name) {
|
||||
const makeOption = makes.find(make => make.name === watchedMake);
|
||||
if (makeOption) {
|
||||
const loadModels = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const modelsData = await vehiclesApi.getModels(watchedYear, makeOption.id);
|
||||
setModels(modelsData);
|
||||
setSelectedMake(makeOption);
|
||||
|
||||
// Clear dependent selections
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedModel(undefined);
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}
|
||||
}
|
||||
}, [watchedMake, watchedYear, selectedMake, makes, setValue]);
|
||||
|
||||
// Load trims when model changes
|
||||
useEffect(() => {
|
||||
if (watchedModel && watchedYear && selectedMake && watchedModel !== selectedModel?.name) {
|
||||
const modelOption = models.find(model => model.name === watchedModel);
|
||||
if (modelOption) {
|
||||
const loadTrims = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const trimsData = await vehiclesApi.getTrims(watchedYear, selectedMake.id, modelOption.id);
|
||||
setTrims(trimsData);
|
||||
setSelectedModel(modelOption);
|
||||
// Clear deeper selections
|
||||
setEngines([]);
|
||||
setSelectedTrim(undefined);
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load detailed data:', error);
|
||||
setTrims([]);
|
||||
setEngines([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTrims();
|
||||
}
|
||||
}
|
||||
}, [watchedModel, watchedYear, selectedMake, selectedModel, models, setValue]);
|
||||
|
||||
// Load engines when trim changes
|
||||
useEffect(() => {
|
||||
const trimName = watch('trimLevel');
|
||||
if (trimName && watchedYear && selectedMake && selectedModel) {
|
||||
const trimOption = trims.find(t => t.name === trimName);
|
||||
if (trimOption) {
|
||||
const loadEngines = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const enginesData = await vehiclesApi.getEngines(watchedYear, selectedMake.id, selectedModel.id, trimOption.id);
|
||||
setEngines(enginesData);
|
||||
setSelectedTrim(trimOption);
|
||||
} catch (error) {
|
||||
console.error('Failed to load engines:', error);
|
||||
setEngines([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
loadEngines();
|
||||
}
|
||||
}
|
||||
}, [trims, selectedMake, selectedModel, watchedYear, setValue, watch('trimLevel')]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
VIN <span className="text-red-500">*</span>
|
||||
VIN or License Plate <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN (optional if License Plate provided)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDecodeVIN}
|
||||
loading={decodingVIN}
|
||||
disabled={!watchedVIN || watchedVIN.length !== 17}
|
||||
variant="secondary"
|
||||
>
|
||||
Decode
|
||||
</Button>
|
||||
</div>
|
||||
{decodeSuccess && (
|
||||
<p className="mt-1 text-sm text-green-600">VIN decoded successfully! Fields populated.</p>
|
||||
)}
|
||||
{errors.vin && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Specification Dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Year
|
||||
</label>
|
||||
<select
|
||||
{...register('year', { valueAsNumber: true })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Select Year</option>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Make
|
||||
@@ -131,7 +318,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('make')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedYear}
|
||||
>
|
||||
<option value="">Select Make</option>
|
||||
{makes.map((make) => (
|
||||
@@ -149,7 +336,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('model')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={!watchedMake || models.length === 0}
|
||||
disabled={loadingDropdowns || !watchedMake || models.length === 0}
|
||||
>
|
||||
<option value="">Select Model</option>
|
||||
{models.map((model) => (
|
||||
@@ -162,6 +349,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Trim (left) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trim
|
||||
</label>
|
||||
<select
|
||||
{...register('trimLevel')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine (middle) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Engine
|
||||
@@ -169,7 +376,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('engine')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedModel || !selectedTrim || engines.length === 0}
|
||||
>
|
||||
<option value="">Select Engine</option>
|
||||
{engines.map((engine) => (
|
||||
@@ -180,6 +387,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Transmission (right, static options) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transmission
|
||||
@@ -187,32 +395,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('transmission')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Transmission</option>
|
||||
{transmissions.map((transmission) => (
|
||||
<option key={transmission.id} value={transmission.name}>
|
||||
{transmission.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trim Level
|
||||
</label>
|
||||
<select
|
||||
{...register('trimLevel')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="Automatic">Automatic</option>
|
||||
<option value="Manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,8 +433,11 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<input
|
||||
{...register('licensePlate')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., ABC-123"
|
||||
placeholder="e.g., ABC-123 (required if VIN omitted)"
|
||||
/>
|
||||
{errors.licensePlate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,4 +463,4 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
onLogFuel
|
||||
}) => {
|
||||
const displayName = vehicle.nickname ||
|
||||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
const displayModel = vehicle.model || 'Unknown Model';
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||
compact = false
|
||||
}) => {
|
||||
const displayName = vehicle.nickname ||
|
||||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
const displayModel = vehicle.model || 'Unknown Model';
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import React, { useTransition, useEffect } from 'react';
|
||||
import { Box, Typography, Grid } from '@mui/material';
|
||||
import { Box, Typography, Grid, Fab } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
||||
@@ -14,6 +15,7 @@ import { Vehicle } from '../types/vehicles.types';
|
||||
|
||||
interface VehiclesMobileScreenProps {
|
||||
onVehicleSelect?: (vehicle: Vehicle) => void;
|
||||
onAddVehicle?: () => void;
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
|
||||
@@ -33,7 +35,8 @@ const Section: React.FC<{ title: string; children: React.ReactNode; right?: Reac
|
||||
);
|
||||
|
||||
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
onVehicleSelect
|
||||
onVehicleSelect,
|
||||
onAddVehicle
|
||||
}) => {
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
@@ -66,7 +69,12 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
return (
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography color="text.secondary">Loading vehicles...</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
Loading your vehicles...
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Please wait a moment
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -74,7 +82,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
|
||||
if (!optimisticVehicles.length) {
|
||||
return (
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Section title="Vehicles">
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
@@ -85,13 +93,27 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileVehiclesSuspense>
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
|
||||
<Grid container spacing={2}>
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
@@ -104,6 +126,20 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
</MobileVehiclesSuspense>
|
||||
);
|
||||
|
||||
255
frontend/src/features/vehicles/pages/VehicleDetailPage.tsx
Normal file
255
frontend/src/features/vehicles/pages/VehicleDetailPage.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @ai-summary Vehicle detail page matching VehicleForm styling
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button as MuiButton, Divider } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { VehicleForm } from '../components/VehicleForm';
|
||||
|
||||
const DetailField: React.FC<{
|
||||
label: string;
|
||||
value?: string | number;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
}> = ({ label, value, isRequired, className = "" }) => (
|
||||
<div className={`space-y-1 ${className}`}>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label} {isRequired && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<span className="text-gray-900">
|
||||
{value || <span className="text-gray-400 italic">Not provided</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const VehicleDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadVehicle = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const vehicleData = await vehiclesApi.getById(id);
|
||||
setVehicle(vehicleData);
|
||||
} catch (err) {
|
||||
setError('Failed to load vehicle details');
|
||||
console.error('Error loading vehicle:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVehicle();
|
||||
}, [id]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/vehicles');
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleUpdateVehicle = async (data: any) => {
|
||||
if (!vehicle) return;
|
||||
|
||||
try {
|
||||
const updatedVehicle = await vehiclesApi.update(vehicle.id, data);
|
||||
setVehicle(updatedVehicle);
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
console.error('Error updating vehicle:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '50vh'
|
||||
}}>
|
||||
<Typography color="text.secondary">Loading vehicle details...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !vehicle) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Card>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography color="error.main" sx={{ mb: 3 }}>
|
||||
{error || 'Vehicle not found'}
|
||||
</Typography>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
onClick={handleBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Back to Vehicles
|
||||
</MuiButton>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = vehicle.nickname ||
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 4
|
||||
}}>
|
||||
<MuiButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleCancelEdit}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Cancel
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
Edit {displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<VehicleForm
|
||||
initialData={vehicle}
|
||||
onSubmit={handleUpdateVehicle}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 4
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MuiButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={handleEdit}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Edit Vehicle
|
||||
</MuiButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
startIcon={<LocalGasStationIcon />}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Add Fuel Log
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
startIcon={<BuildIcon />}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Schedule Maintenance
|
||||
</MuiButton>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Vehicle Details
|
||||
</Typography>
|
||||
|
||||
<form className="space-y-4">
|
||||
<DetailField
|
||||
label="VIN or License Plate"
|
||||
value={vehicle.vin || vehicle.licensePlate}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
{/* Vehicle Specification Section */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DetailField label="Year" value={vehicle.year} />
|
||||
<DetailField label="Make" value={vehicle.make} />
|
||||
<DetailField label="Model" value={vehicle.model} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DetailField label="Trim" value={vehicle.trimLevel} />
|
||||
<DetailField label="Engine" value={vehicle.engine} />
|
||||
<DetailField label="Transmission" value={vehicle.transmission} />
|
||||
</div>
|
||||
|
||||
<DetailField label="Nickname" value={vehicle.nickname} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField label="Color" value={vehicle.color} />
|
||||
<DetailField label="License Plate" value={vehicle.licensePlate} />
|
||||
</div>
|
||||
|
||||
<DetailField
|
||||
label="Current Odometer Reading"
|
||||
value={vehicle.odometerReading ? `${vehicle.odometerReading.toLocaleString()} mi` : undefined}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Vehicle Information
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 4, color: 'text.secondary', fontSize: '0.875rem' }}>
|
||||
<span>Added: {new Date(vehicle.createdAt).toLocaleDateString()}</span>
|
||||
{vehicle.updatedAt !== vehicle.createdAt && (
|
||||
<span>Last updated: {new Date(vehicle.updatedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -51,7 +51,8 @@ export const VehiclesPage: React.FC = () => {
|
||||
const handleSelectVehicle = (id: string) => {
|
||||
// Use transition for navigation to avoid blocking UI
|
||||
startTransition(() => {
|
||||
setSelectedVehicle(id);
|
||||
const vehicle = optimisticVehicles.find(v => v.id === id);
|
||||
setSelectedVehicle(vehicle || null);
|
||||
navigate(`/vehicles/${id}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Vehicle {
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
@@ -55,4 +56,17 @@ export interface UpdateVehicleRequest {
|
||||
export interface DropdownOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
success: boolean;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
trimLevel?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
confidence?: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -5,20 +5,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { Auth0Provider } from './core/auth/Auth0Provider';
|
||||
import { createEnhancedQueryClient } from './core/query/query-config';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const queryClient = createEnhancedQueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
271
frontend/src/pages/SettingsPage.tsx
Normal file
271
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @ai-summary Settings page component for desktop application
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useUnits } from '../core/units/UnitsContext';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Switch,
|
||||
Divider,
|
||||
Avatar,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Button as MuiButton,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl
|
||||
} from '@mui/material';
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import { Card } from '../shared-minimal/components/Card';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const { unitSystem, setUnitSystem } = useUnits();
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [emailUpdates, setEmailUpdates] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({ logoutParams: { returnTo: window.location.origin } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Account Section */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Account
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
mr: 3
|
||||
}}
|
||||
>
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 500 }}>
|
||||
{user?.name || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{user?.email}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Verified account
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Profile Information"
|
||||
secondary="Manage your account details"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Edit
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<SecurityIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Security & Privacy"
|
||||
secondary="Password, two-factor authentication"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Manage
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Notifications
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<NotificationsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Push Notifications"
|
||||
secondary="Receive notifications about your vehicles"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={notifications}
|
||||
onChange={(e) => setNotifications(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Email Updates"
|
||||
secondary="Receive maintenance reminders and updates"
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={emailUpdates}
|
||||
onChange={(e) => setEmailUpdates(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Appearance & Units Section */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Appearance & Units
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<PaletteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Dark Mode"
|
||||
secondary="Use dark theme for better night viewing"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={darkMode}
|
||||
onChange={(e) => setDarkMode(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Units for distance and capacity"
|
||||
secondary="Choose between Imperial (miles, gallons) or Metric (kilometers, liters)"
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={unitSystem}
|
||||
onChange={(e) => setUnitSystem(e.target.value as 'imperial' | 'metric')}
|
||||
displayEmpty
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
'& .MuiSelect-select': {
|
||||
py: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="imperial">Imperial</MenuItem>
|
||||
<MenuItem value="metric">Metric</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Data & Storage Section */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Data & Storage
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<StorageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Export Data"
|
||||
secondary="Download your vehicle and fuel log data"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small">
|
||||
Export
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Clear Cache"
|
||||
secondary="Remove cached data to free up space"
|
||||
sx={{ pl: 7 }}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<MuiButton variant="outlined" size="small" color="warning">
|
||||
Clear
|
||||
</MuiButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Account Actions */}
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'error.main' }}>
|
||||
Account Actions
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleLogout}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Sign Out
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Delete Account
|
||||
</MuiButton>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
2
frontend/test/__mocks__/fileMock.js
Normal file
2
frontend/test/__mocks__/fileMock.js
Normal file
@@ -0,0 +1,2 @@
|
||||
module.exports = 'test-file-stub';
|
||||
|
||||
2
frontend/test/__mocks__/styleMock.js
Normal file
2
frontend/test/__mocks__/styleMock.js
Normal file
@@ -0,0 +1,2 @@
|
||||
module.exports = {};
|
||||
|
||||
30
frontend/test/fuel-logs/FuelLogForm.test.tsx
Normal file
30
frontend/test/fuel-logs/FuelLogForm.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { FuelLogForm } from '../../src/features/fuel-logs/components/FuelLogForm';
|
||||
import { UnitsProvider } from '../../src/core/units/UnitsContext';
|
||||
|
||||
jest.mock('../../src/features/fuel-logs/hooks/useFuelLogs', () => ({
|
||||
useFuelLogs: () => ({ createFuelLog: jest.fn().mockResolvedValue({}), isLoading: false })
|
||||
}));
|
||||
|
||||
const qc = new QueryClient();
|
||||
|
||||
describe('FuelLogForm', () => {
|
||||
it('shows validation error when no distance provided', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UnitsProvider>
|
||||
<FuelLogForm />
|
||||
</UnitsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// Attempt submit without distance
|
||||
const submit = screen.getByRole('button', { name: /add fuel log/i });
|
||||
fireEvent.click(submit);
|
||||
|
||||
expect(await screen.findByText(/Either odometer reading or trip distance is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
20
frontend/test/fuel-logs/useFuelGrades.test.tsx
Normal file
20
frontend/test/fuel-logs/useFuelGrades.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useFuelGrades } from '../../src/features/fuel-logs/hooks/useFuelGrades';
|
||||
import * as api from '../../src/features/fuel-logs/api/fuel-logs.api';
|
||||
|
||||
const qc = new QueryClient();
|
||||
|
||||
jest.spyOn(api.fuelLogsApi, 'getFuelGrades').mockResolvedValue([
|
||||
{ value: '87', label: '87 (Regular)' },
|
||||
{ value: '91', label: '91 (Premium)' },
|
||||
]);
|
||||
|
||||
describe('useFuelGrades', () => {
|
||||
it('returns grades for gasoline', async () => {
|
||||
const wrapper = ({ children }: any) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
const { result } = renderHook(() => useFuelGrades('gasoline' as any), { wrapper });
|
||||
await waitFor(() => expect(result.current.fuelGrades.length).toBeGreaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user