feat: Dashboard - Vehicle Fleet Overview (#2) #3
85
.gitea/issue_template/bug.yaml
Normal file
85
.gitea/issue_template/bug.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- type/bug
|
||||
- status/backlog
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug Report
|
||||
Use this template to report bugs. Provide as much detail as possible to help reproduce the issue.
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Where did you encounter this bug?
|
||||
options:
|
||||
- Mobile (iOS)
|
||||
- Mobile (Android)
|
||||
- Desktop (Chrome)
|
||||
- Desktop (Firefox)
|
||||
- Desktop (Safari)
|
||||
- Desktop (Edge)
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: What went wrong? Be specific.
|
||||
placeholder: "When I click X, I expected Y to happen, but instead Z happened."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this bug?
|
||||
placeholder: |
|
||||
1. Navigate to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Screenshots, error messages, console logs, etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: fix-hints
|
||||
attributes:
|
||||
label: Fix Hints (if known)
|
||||
description: Any ideas on what might be causing this or how to fix it?
|
||||
placeholder: |
|
||||
- Might be related to: [file or component]
|
||||
- Similar issue in: [other feature]
|
||||
validations:
|
||||
required: false
|
||||
70
.gitea/issue_template/chore.yaml
Normal file
70
.gitea/issue_template/chore.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Chore / Maintenance
|
||||
about: Technical debt, refactoring, dependency updates, infrastructure
|
||||
title: "[Chore]: "
|
||||
labels:
|
||||
- type/chore
|
||||
- status/backlog
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Chore / Maintenance Task
|
||||
Use this template for technical debt, refactoring, dependency updates, or infrastructure work.
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: What type of chore is this?
|
||||
options:
|
||||
- Refactoring
|
||||
- Dependency update
|
||||
- Performance optimization
|
||||
- Technical debt cleanup
|
||||
- Infrastructure / DevOps
|
||||
- Testing improvements
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What needs to be done and why?
|
||||
placeholder: "We need to refactor X because Y..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope / Files Affected
|
||||
description: What parts of the codebase will be touched?
|
||||
placeholder: |
|
||||
- frontend/src/features/[name]/
|
||||
- backend/src/features/[name]/
|
||||
- docker-compose.yml
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Definition of Done
|
||||
description: How do we know this is complete?
|
||||
placeholder: |
|
||||
- [ ] All tests pass
|
||||
- [ ] No new linting errors
|
||||
- [ ] Performance benchmark improved by X%
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: risks
|
||||
attributes:
|
||||
label: Risks / Breaking Changes
|
||||
description: Any potential issues or breaking changes to be aware of?
|
||||
validations:
|
||||
required: false
|
||||
137
.gitea/issue_template/feature.yaml
Normal file
137
.gitea/issue_template/feature.yaml
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Feature Request
|
||||
about: Propose a new feature for MotoVaultPro
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- type/feature
|
||||
- status/backlog
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Feature Request
|
||||
Use this template to propose new features. Be specific about requirements and integration points.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / User Need
|
||||
description: What problem does this feature solve? Who needs it and why?
|
||||
placeholder: "As a [user type], I want to [goal] so that [benefit]..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the feature and how it should work
|
||||
placeholder: "When the user does X, the system should Y..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: non-goals
|
||||
attributes:
|
||||
label: Non-goals / Out of Scope
|
||||
description: What is explicitly NOT part of this feature?
|
||||
placeholder: |
|
||||
- Advanced analytics (future enhancement)
|
||||
- Data export functionality
|
||||
- etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance Criteria (Feature Behavior)
|
||||
description: What must be true for this feature to be complete?
|
||||
placeholder: |
|
||||
- [ ] User can see X
|
||||
- [ ] System displays Y when Z
|
||||
- [ ] Works on mobile viewport (320px) with touch-friendly targets
|
||||
- [ ] Works on desktop viewport (1920px) with keyboard navigation
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: integration-criteria
|
||||
attributes:
|
||||
label: Integration Criteria (App Flow)
|
||||
description: How does this feature integrate into the app? This prevents missed navigation/routing.
|
||||
value: |
|
||||
### Navigation
|
||||
- [ ] Desktop sidebar: [not needed / add as item #X / replace existing]
|
||||
- [ ] Mobile bottom nav: [not needed / add to left/right items]
|
||||
- [ ] Mobile hamburger menu: [not needed / add to menu items]
|
||||
|
||||
### Routing
|
||||
- [ ] Desktop route path: `/garage/[feature-name]`
|
||||
- [ ] Is this the default landing page after login? [yes / no]
|
||||
- [ ] Replaces existing placeholder/route: [none / specify what]
|
||||
|
||||
### State Management
|
||||
- [ ] Mobile screen type needed in navigation store? [yes / no]
|
||||
- [ ] New Zustand store needed? [yes / no]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: visual-integration
|
||||
attributes:
|
||||
label: Visual Integration (Design Consistency)
|
||||
description: Ensure the feature matches the app's visual language. Reference existing patterns.
|
||||
value: |
|
||||
### Icons
|
||||
- [ ] Use MUI Rounded icons only (e.g., `HomeRoundedIcon`, `DirectionsCarRoundedIcon`)
|
||||
- [ ] Icon reference: Check `frontend/src/components/Layout.tsx` for existing icons
|
||||
- [ ] No emoji icons in UI (text content only)
|
||||
|
||||
### Colors
|
||||
- [ ] Use theme colors via MUI sx prop: `color: 'primary.main'`, `bgcolor: 'background.paper'`
|
||||
- [ ] No hardcoded hex colors (use Tailwind theme classes or MUI theme)
|
||||
- [ ] Dark mode support: Use `dark:` Tailwind variants or MUI `theme.applyStyles('dark', ...)`
|
||||
|
||||
### Components
|
||||
- [ ] Use existing shared components: `GlassCard`, `Button`, `Input` from `shared-minimal/`
|
||||
- [ ] Follow card patterns in: `frontend/src/features/vehicles/` or `frontend/src/features/fuel-logs/`
|
||||
- [ ] Loading states: Use skeleton patterns from existing features
|
||||
|
||||
### Typography & Spacing
|
||||
- [ ] Use MUI Typography variants: `h4`, `h5`, `body1`, `body2`, `caption`
|
||||
- [ ] Use consistent spacing: `gap-4`, `space-y-4`, `p-4` (multiples of 4)
|
||||
- [ ] Mobile padding: `px-5 pt-5 pb-3` pattern from Layout.tsx
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation-notes
|
||||
attributes:
|
||||
label: Implementation Notes
|
||||
description: Technical hints, existing patterns to follow, files to modify
|
||||
placeholder: |
|
||||
- Current placeholder: frontend/src/App.tsx lines X-Y
|
||||
- Create new feature directory: frontend/src/features/[name]/
|
||||
- Backend APIs already exist for X, Y, Z
|
||||
- Follow pattern in: frontend/src/features/vehicles/
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: test-plan
|
||||
attributes:
|
||||
label: Test Plan
|
||||
description: How will this feature be tested?
|
||||
placeholder: |
|
||||
**Unit tests:**
|
||||
- Component tests for X, Y, Z
|
||||
|
||||
**Integration tests:**
|
||||
- Test data fetching with mocked API responses
|
||||
|
||||
**Manual testing:**
|
||||
- Verify mobile layout at 320px, 768px viewports
|
||||
- Verify desktop layout at 1920px viewport
|
||||
- Test with 0 items, 1 item, 10+ items
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,14 +1,16 @@
|
||||
# MotoVaultPro Staging Deployment Workflow
|
||||
# Triggers on push to main, builds and deploys to staging.motovaultpro.com
|
||||
# Triggers on push to main or any pull request, builds and deploys to staging.motovaultpro.com
|
||||
# After verification, sends notification with link to trigger production deploy
|
||||
|
||||
name: Deploy to Staging
|
||||
run-name: Staging Deploy - ${{ gitea.sha }}
|
||||
run-name: "Staging - ${{ gitea.event.pull_request.title || gitea.ref_name }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
env:
|
||||
REGISTRY: git.motovaultpro.com
|
||||
|
||||
@@ -22,10 +22,12 @@ const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPag
|
||||
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
|
||||
const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage })));
|
||||
const StationsPage = lazy(() => import('./features/stations/pages/StationsPage').then(m => ({ default: m.StationsPage })));
|
||||
const DashboardPage = lazy(() => import('./features/dashboard/pages/DashboardPage').then(m => ({ default: m.DashboardPage })));
|
||||
const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen').then(m => ({ default: m.default })));
|
||||
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 })));
|
||||
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
|
||||
const MaintenanceMobileScreen = lazy(() => import('./features/maintenance/mobile/MaintenanceMobileScreen'));
|
||||
|
||||
// Admin pages (lazy-loaded)
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
|
||||
@@ -78,18 +80,7 @@ import { useDataSync } from './core/hooks/useDataSync';
|
||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||
import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications';
|
||||
|
||||
// Hoisted mobile screen components to stabilize identity and prevent remounts
|
||||
const DashboardScreen: React.FC = () => (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-2">Dashboard</h2>
|
||||
<p className="text-slate-500">Coming soon - Vehicle insights and analytics</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
import { DashboardScreen as DashboardFeature } from './features/dashboard';
|
||||
|
||||
const LogFuelScreen: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -640,7 +631,16 @@ function App() {
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Dashboard">
|
||||
<DashboardScreen />
|
||||
<DashboardFeature
|
||||
onNavigate={navigateToScreen}
|
||||
onVehicleClick={handleVehicleSelect}
|
||||
onViewMaintenance={() => navigateToScreen('Maintenance', { source: 'dashboard-maintenance' })}
|
||||
onAddVehicle={() => {
|
||||
setShowAddVehicle(true);
|
||||
navigateToScreen('Vehicles', { source: 'dashboard-add-vehicle' });
|
||||
navigateToVehicleSubScreen('add', undefined, { source: 'dashboard-add-vehicle' });
|
||||
}}
|
||||
/>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -691,6 +691,31 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Maintenance" && (
|
||||
<motion.div
|
||||
key="maintenance"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Maintenance">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading maintenance screen...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<MaintenanceMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Settings" && (
|
||||
<motion.div
|
||||
key="settings"
|
||||
@@ -950,7 +975,8 @@ function App() {
|
||||
<Layout mobileMode={false}>
|
||||
<RouteSuspense>
|
||||
<Routes>
|
||||
<Route path="/garage" element={<Navigate to="/garage/vehicles" replace />} />
|
||||
<Route path="/garage" element={<Navigate to="/garage/dashboard" replace />} />
|
||||
<Route path="/garage/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/garage/vehicles" element={<VehiclesPage />} />
|
||||
<Route path="/garage/vehicles/:id" element={<VehicleDetailPage />} />
|
||||
<Route path="/garage/fuel-logs" element={<FuelLogsPage />} />
|
||||
@@ -965,7 +991,7 @@ function App() {
|
||||
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
|
||||
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
|
||||
<Route path="/garage/settings/admin/backup" element={<AdminBackupPage />} />
|
||||
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
|
||||
<Route path="*" element={<Navigate to="/garage/dashboard" replace />} />
|
||||
</Routes>
|
||||
</RouteSuspense>
|
||||
<DebugInfo />
|
||||
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
|
||||
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
@@ -40,6 +41,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
|
||||
}, [mobileMode, setSidebarOpen]); // Removed sidebarOpen from dependencies
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/garage/dashboard', icon: <HomeRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Vehicles', href: '/garage/vehicles', icon: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Fuel Logs', href: '/garage/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
{ name: 'Maintenance', href: '/garage/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
@@ -51,7 +51,7 @@ export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
activeScreen: 'Vehicles',
|
||||
activeScreen: 'Dashboard',
|
||||
vehicleSubScreen: 'list',
|
||||
selectedVehicleId: null,
|
||||
navigationHistory: [],
|
||||
|
||||
137
frontend/src/features/dashboard/components/DashboardScreen.tsx
Normal file
137
frontend/src/features/dashboard/components/DashboardScreen.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @ai-summary Main dashboard screen component showing fleet overview
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
|
||||
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
|
||||
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
|
||||
import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
|
||||
import { MobileScreen } from '../../../core/store';
|
||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface DashboardScreenProps {
|
||||
onNavigate?: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
||||
onVehicleClick?: (vehicle: Vehicle) => void;
|
||||
onViewMaintenance?: () => void;
|
||||
onAddVehicle?: () => void;
|
||||
}
|
||||
|
||||
export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
onNavigate,
|
||||
onVehicleClick,
|
||||
onViewMaintenance,
|
||||
onAddVehicle
|
||||
}) => {
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
|
||||
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
|
||||
|
||||
// Error state
|
||||
if (summaryError || attentionError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-12">
|
||||
<Box sx={{ color: 'warning.main', mb: 1.5 }}>
|
||||
<WarningAmberRoundedIcon sx={{ fontSize: 48 }} />
|
||||
</Box>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
|
||||
Unable to Load Dashboard
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-titanio mb-4">
|
||||
There was an error loading your dashboard data
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SummaryCardsSkeleton />
|
||||
<VehicleAttentionSkeleton />
|
||||
<QuickActionsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state - no vehicles
|
||||
if (summary.totalVehicles === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GlassCard>
|
||||
<div className="text-center py-12">
|
||||
<Box sx={{ color: 'primary.main', mb: 2 }}>
|
||||
<DirectionsCarRoundedIcon sx={{ fontSize: 64 }} />
|
||||
</Box>
|
||||
<h2 className="text-xl font-semibold text-slate-800 dark:text-avus mb-3">
|
||||
Welcome to MotoVaultPro
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-titanio mb-6 max-w-md mx-auto">
|
||||
Get started by adding your first vehicle to track fuel logs, maintenance, and more
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
|
||||
>
|
||||
Add Your First Vehicle
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main dashboard view
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<SummaryCards summary={summary} />
|
||||
|
||||
{/* Vehicles Needing Attention */}
|
||||
{vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && (
|
||||
<VehicleAttention
|
||||
vehicles={vehiclesNeedingAttention}
|
||||
onVehicleClick={(vehicleId) => {
|
||||
const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId);
|
||||
if (vehicle && onVehicleClick) {
|
||||
onVehicleClick(vehicle);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<QuickActions
|
||||
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
|
||||
onLogFuel={() => onNavigate?.('Log Fuel')}
|
||||
onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))}
|
||||
onViewVehicles={() => onNavigate?.('Vehicles')}
|
||||
/>
|
||||
|
||||
{/* Footer Hint */}
|
||||
<div className="text-center py-4">
|
||||
<p className="text-xs text-slate-400 dark:text-canna">
|
||||
Dashboard updates every 2 minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
frontend/src/features/dashboard/components/QuickActions.tsx
Normal file
167
frontend/src/features/dashboard/components/QuickActions.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @ai-summary Quick action buttons for common tasks
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, SvgIconProps } from '@mui/material';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
import FormatListBulletedRoundedIcon from '@mui/icons-material/FormatListBulletedRounded';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<SvgIconProps>;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface QuickActionsProps {
|
||||
onAddVehicle: () => void;
|
||||
onLogFuel: () => void;
|
||||
onViewMaintenance: () => void;
|
||||
onViewVehicles: () => void;
|
||||
}
|
||||
|
||||
export const QuickActions: React.FC<QuickActionsProps> = ({
|
||||
onAddVehicle,
|
||||
onLogFuel,
|
||||
onViewMaintenance,
|
||||
onViewVehicles,
|
||||
}) => {
|
||||
const actions: QuickAction[] = [
|
||||
{
|
||||
id: 'add-vehicle',
|
||||
title: 'Add Vehicle',
|
||||
description: 'Register a new vehicle',
|
||||
icon: DirectionsCarRoundedIcon,
|
||||
onClick: onAddVehicle,
|
||||
},
|
||||
{
|
||||
id: 'log-fuel',
|
||||
title: 'Log Fuel',
|
||||
description: 'Record a fuel purchase',
|
||||
icon: LocalGasStationRoundedIcon,
|
||||
onClick: onLogFuel,
|
||||
},
|
||||
{
|
||||
id: 'view-maintenance',
|
||||
title: 'Maintenance',
|
||||
description: 'View maintenance records',
|
||||
icon: BuildRoundedIcon,
|
||||
onClick: onViewMaintenance,
|
||||
},
|
||||
{
|
||||
id: 'view-vehicles',
|
||||
title: 'My Vehicles',
|
||||
description: 'View all vehicles',
|
||||
icon: FormatListBulletedRoundedIcon,
|
||||
onClick: onViewVehicles,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
Common tasks and navigation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{actions.map((action) => {
|
||||
const IconComponent = action.icon;
|
||||
return (
|
||||
<Box
|
||||
key={action.id}
|
||||
component="button"
|
||||
onClick={action.onClick}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
minHeight: { xs: 100, sm: 120 },
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.selected',
|
||||
borderColor: 'divider',
|
||||
},
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<IconComponent sx={{ fontSize: 28 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
color: 'text.primary',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{action.title}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{action.description}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuickActionsSkeleton: React.FC = () => {
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800 min-h-[100px] sm:min-h-[120px]"
|
||||
>
|
||||
<div className="w-7 h-7 bg-slate-100 dark:bg-slate-700 rounded animate-pulse mb-3" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20 mb-2" />
|
||||
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-full hidden sm:block" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
108
frontend/src/features/dashboard/components/SummaryCards.tsx
Normal file
108
frontend/src/features/dashboard/components/SummaryCards.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @ai-summary Summary cards showing key dashboard metrics
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { DashboardSummary } from '../types';
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: DashboardSummary;
|
||||
}
|
||||
|
||||
export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Vehicles',
|
||||
value: summary.totalVehicles,
|
||||
icon: DirectionsCarRoundedIcon,
|
||||
color: 'primary.main',
|
||||
},
|
||||
{
|
||||
title: 'Upcoming Maintenance',
|
||||
value: summary.upcomingMaintenanceCount,
|
||||
subtitle: 'Next 30 days',
|
||||
icon: BuildRoundedIcon,
|
||||
color: 'primary.main',
|
||||
},
|
||||
{
|
||||
title: 'Recent Fuel Logs',
|
||||
value: summary.recentFuelLogsCount,
|
||||
subtitle: 'Last 7 days',
|
||||
icon: LocalGasStationRoundedIcon,
|
||||
color: 'primary.main',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{cards.map((card) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<GlassCard key={card.title} padding="md">
|
||||
<div className="flex items-start gap-3">
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<IconComponent sx={{ fontSize: 24, color: card.color }} />
|
||||
</Box>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-slate-500 dark:text-titanio font-medium mb-1">
|
||||
{card.title}
|
||||
</p>
|
||||
<Box
|
||||
component="p"
|
||||
sx={{
|
||||
fontSize: '1.875rem',
|
||||
fontWeight: 700,
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</Box>
|
||||
{card.subtitle && (
|
||||
<p className="text-xs text-slate-400 dark:text-canna mt-1">
|
||||
{card.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SummaryCardsSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<GlassCard key={i} padding="md">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 animate-pulse" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
|
||||
<div className="h-8 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-16" />
|
||||
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
161
frontend/src/features/dashboard/components/VehicleAttention.tsx
Normal file
161
frontend/src/features/dashboard/components/VehicleAttention.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @ai-summary List of vehicles needing attention (overdue maintenance)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, SvgIconProps } from '@mui/material';
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { VehicleNeedingAttention } from '../types';
|
||||
|
||||
interface VehicleAttentionProps {
|
||||
vehicles: VehicleNeedingAttention[];
|
||||
onVehicleClick?: (vehicleId: string) => void;
|
||||
}
|
||||
|
||||
export const VehicleAttention: React.FC<VehicleAttentionProps> = ({ vehicles, onVehicleClick }) => {
|
||||
if (vehicles.length === 0) {
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<Box sx={{ color: 'success.main', mb: 1.5 }}>
|
||||
<CheckCircleRoundedIcon sx={{ fontSize: 48 }} />
|
||||
</Box>
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
|
||||
All Caught Up!
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
No vehicles need immediate attention
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
const priorityConfig: Record<string, { color: string; icon: React.ComponentType<SvgIconProps> }> = {
|
||||
high: {
|
||||
color: 'error.main',
|
||||
icon: ErrorRoundedIcon,
|
||||
},
|
||||
medium: {
|
||||
color: 'warning.main',
|
||||
icon: WarningAmberRoundedIcon,
|
||||
},
|
||||
low: {
|
||||
color: 'info.main',
|
||||
icon: ScheduleRoundedIcon,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
||||
Needs Attention
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
Vehicles with overdue maintenance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{vehicles.map((vehicle) => {
|
||||
const config = priorityConfig[vehicle.priority];
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<Box
|
||||
key={vehicle.id}
|
||||
onClick={() => onVehicleClick?.(vehicle.id)}
|
||||
role={onVehicleClick ? 'button' : undefined}
|
||||
tabIndex={onVehicleClick ? 0 : undefined}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (onVehicleClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onVehicleClick(vehicle.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: onVehicleClick ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': onVehicleClick ? {
|
||||
bgcolor: 'action.selected',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Box sx={{ flexShrink: 0, color: config.color }}>
|
||||
<IconComponent sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Box
|
||||
component="h4"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
fontSize: '1rem',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
||||
</Box>
|
||||
<p className="text-sm text-slate-600 dark:text-titanio">
|
||||
{vehicle.reason}
|
||||
</p>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
mt: 1,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 2,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
bgcolor: 'action.selected',
|
||||
color: config.color,
|
||||
}}
|
||||
>
|
||||
{vehicle.priority.toUpperCase()} PRIORITY
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const VehicleAttentionSkeleton: React.FC = () => {
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-2" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-48" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-3/4" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/2" />
|
||||
<div className="h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-24 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
141
frontend/src/features/dashboard/hooks/useDashboardData.ts
Normal file
141
frontend/src/features/dashboard/hooks/useDashboardData.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for dashboard data
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||
import { maintenanceApi } from '../../maintenance/api/maintenance.api';
|
||||
import { DashboardSummary, VehicleNeedingAttention } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to fetch dashboard summary stats
|
||||
*/
|
||||
export const useDashboardSummary = () => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'summary'],
|
||||
queryFn: async (): Promise<DashboardSummary> => {
|
||||
// Fetch all required data in parallel
|
||||
const [vehicles, fuelLogs] = await Promise.all([
|
||||
vehiclesApi.getAll(),
|
||||
fuelLogsApi.getUserFuelLogs(),
|
||||
]);
|
||||
|
||||
// Fetch schedules for all vehicles to count upcoming maintenance
|
||||
const allSchedules = await Promise.all(
|
||||
vehicles.map(v => maintenanceApi.getSchedulesByVehicle(v.id))
|
||||
);
|
||||
const flatSchedules = allSchedules.flat();
|
||||
|
||||
// Calculate upcoming maintenance (next 30 days)
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
const upcomingMaintenance = flatSchedules.filter(schedule => {
|
||||
// Count schedules as upcoming if they have a next due date within 30 days
|
||||
if (!schedule.nextDueDate) return false;
|
||||
const dueDate = new Date(schedule.nextDueDate);
|
||||
return dueDate >= new Date() && dueDate <= thirtyDaysFromNow;
|
||||
});
|
||||
|
||||
// Calculate recent fuel logs (last 7 days)
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const recentFuelLogs = fuelLogs.filter(log => {
|
||||
const logDate = new Date(log.dateTime);
|
||||
return logDate >= sevenDaysAgo;
|
||||
});
|
||||
|
||||
return {
|
||||
totalVehicles: vehicles.length,
|
||||
upcomingMaintenanceCount: upcomingMaintenance.length,
|
||||
recentFuelLogsCount: recentFuelLogs.length,
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated && !authLoading,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes - fresher than other queries for dashboard
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: (failureCount, error: any) => {
|
||||
// Retry 401 errors up to 3 times for mobile auth timing issues
|
||||
if (error?.response?.status === 401 && failureCount < 3) {
|
||||
console.log(`[Mobile Auth] Dashboard retry ${failureCount + 1}/3 for 401 error`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch vehicles needing attention (overdue maintenance)
|
||||
*/
|
||||
export const useVehiclesNeedingAttention = () => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'vehiclesNeedingAttention'],
|
||||
queryFn: async (): Promise<VehicleNeedingAttention[]> => {
|
||||
// Fetch vehicles
|
||||
const vehicles = await vehiclesApi.getAll();
|
||||
|
||||
const vehiclesNeedingAttention: VehicleNeedingAttention[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Check each vehicle for overdue maintenance
|
||||
for (const vehicle of vehicles) {
|
||||
const schedules = await maintenanceApi.getSchedulesByVehicle(vehicle.id);
|
||||
|
||||
// Find overdue schedules
|
||||
const overdueSchedules = schedules.filter(schedule => {
|
||||
if (!schedule.nextDueDate) return false;
|
||||
const dueDate = new Date(schedule.nextDueDate);
|
||||
return dueDate < now;
|
||||
});
|
||||
|
||||
if (overdueSchedules.length > 0) {
|
||||
// Calculate priority based on how overdue the maintenance is
|
||||
const mostOverdue = overdueSchedules.reduce((oldest, current) => {
|
||||
const oldestDate = new Date(oldest.nextDueDate!);
|
||||
const currentDate = new Date(current.nextDueDate!);
|
||||
return currentDate < oldestDate ? current : oldest;
|
||||
});
|
||||
|
||||
const daysOverdue = Math.floor((now.getTime() - new Date(mostOverdue.nextDueDate!).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let priority: 'high' | 'medium' | 'low' = 'low';
|
||||
if (daysOverdue > 30) {
|
||||
priority = 'high';
|
||||
} else if (daysOverdue > 14) {
|
||||
priority = 'medium';
|
||||
}
|
||||
|
||||
vehiclesNeedingAttention.push({
|
||||
...vehicle,
|
||||
reason: `${overdueSchedules.length} overdue maintenance ${overdueSchedules.length === 1 ? 'item' : 'items'}`,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (high -> medium -> low)
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
},
|
||||
enabled: isAuthenticated && !authLoading,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.response?.status === 401 && failureCount < 3) {
|
||||
console.log(`[Mobile Auth] Vehicles attention retry ${failureCount + 1}/3 for 401 error`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
};
|
||||
11
frontend/src/features/dashboard/index.ts
Normal file
11
frontend/src/features/dashboard/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @ai-summary Dashboard feature public exports
|
||||
*/
|
||||
|
||||
export { DashboardScreen } from './components/DashboardScreen';
|
||||
export { DashboardPage } from './pages/DashboardPage';
|
||||
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards';
|
||||
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention';
|
||||
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions';
|
||||
export { useDashboardSummary, useVehiclesNeedingAttention } from './hooks/useDashboardData';
|
||||
export type { DashboardSummary, VehicleNeedingAttention, DashboardData } from './types';
|
||||
63
frontend/src/features/dashboard/pages/DashboardPage.tsx
Normal file
63
frontend/src/features/dashboard/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @ai-summary Desktop dashboard page wrapping the DashboardScreen component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { DashboardScreen } from '../components/DashboardScreen';
|
||||
import { MobileScreen } from '../../../core/store';
|
||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Map mobile screen names to desktop routes
|
||||
const handleNavigate = (screen: MobileScreen) => {
|
||||
switch (screen) {
|
||||
case 'Vehicles':
|
||||
navigate('/garage/vehicles');
|
||||
break;
|
||||
case 'Log Fuel':
|
||||
navigate('/garage/fuel-logs');
|
||||
break;
|
||||
case 'Stations':
|
||||
navigate('/garage/stations');
|
||||
break;
|
||||
case 'Documents':
|
||||
navigate('/garage/documents');
|
||||
break;
|
||||
case 'Settings':
|
||||
navigate('/garage/settings');
|
||||
break;
|
||||
default:
|
||||
navigate('/garage/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVehicleClick = (vehicle: Vehicle) => {
|
||||
navigate(`/garage/vehicles/${vehicle.id}`);
|
||||
};
|
||||
|
||||
const handleViewMaintenance = () => {
|
||||
navigate('/garage/maintenance');
|
||||
};
|
||||
|
||||
const handleAddVehicle = () => {
|
||||
navigate('/garage/vehicles', { state: { showAddForm: true } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<DashboardScreen
|
||||
onNavigate={handleNavigate}
|
||||
onVehicleClick={handleVehicleClick}
|
||||
onViewMaintenance={handleViewMaintenance}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
21
frontend/src/features/dashboard/types/index.ts
Normal file
21
frontend/src/features/dashboard/types/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @ai-summary Dashboard feature types
|
||||
*/
|
||||
|
||||
import { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
export interface DashboardSummary {
|
||||
totalVehicles: number;
|
||||
upcomingMaintenanceCount: number;
|
||||
recentFuelLogsCount: number;
|
||||
}
|
||||
|
||||
export interface VehicleNeedingAttention extends Vehicle {
|
||||
reason: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
summary: DashboardSummary;
|
||||
vehiclesNeedingAttention: VehicleNeedingAttention[];
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @ai-summary Mobile maintenance screen with tabs for records and schedules
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
|
||||
import { MaintenanceRecordForm } from '../components/MaintenanceRecordForm';
|
||||
import { MaintenanceRecordsList } from '../components/MaintenanceRecordsList';
|
||||
import { MaintenanceRecordEditDialog } from '../components/MaintenanceRecordEditDialog';
|
||||
import { MaintenanceScheduleForm } from '../components/MaintenanceScheduleForm';
|
||||
import { MaintenanceSchedulesList } from '../components/MaintenanceSchedulesList';
|
||||
import { MaintenanceScheduleEditDialog } from '../components/MaintenanceScheduleEditDialog';
|
||||
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types';
|
||||
|
||||
export const MaintenanceMobileScreen: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { records, schedules, isRecordsLoading, isSchedulesLoading, recordsError, schedulesError, updateRecord, deleteRecord, updateSchedule, deleteSchedule } = useMaintenanceRecords();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'records' | 'schedules'>('records');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<MaintenanceRecordResponse | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<MaintenanceScheduleResponse | null>(null);
|
||||
const [scheduleEditDialogOpen, setScheduleEditDialogOpen] = useState(false);
|
||||
|
||||
const handleEdit = (record: MaintenanceRecordResponse) => {
|
||||
setEditingRecord(record);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSave = async (id: string, data: UpdateMaintenanceRecordRequest) => {
|
||||
try {
|
||||
await updateRecord({ id, data });
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceRecords'] });
|
||||
setEditDialogOpen(false);
|
||||
setEditingRecord(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update maintenance record:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClose = () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingRecord(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (recordId: string) => {
|
||||
try {
|
||||
await deleteRecord(recordId);
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceRecords', 'all'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete maintenance record:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleEdit = (schedule: MaintenanceScheduleResponse) => {
|
||||
setEditingSchedule(schedule);
|
||||
setScheduleEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleScheduleEditSave = async (id: string, data: UpdateScheduleRequest) => {
|
||||
try {
|
||||
await updateSchedule({ id, data });
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] });
|
||||
setScheduleEditDialogOpen(false);
|
||||
setEditingSchedule(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update maintenance schedule:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleEditClose = () => {
|
||||
setScheduleEditDialogOpen(false);
|
||||
setEditingSchedule(null);
|
||||
};
|
||||
|
||||
const handleScheduleDelete = async (scheduleId: string) => {
|
||||
try {
|
||||
await deleteSchedule(scheduleId);
|
||||
queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete maintenance schedule:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = activeTab === 'records' ? isRecordsLoading : isSchedulesLoading;
|
||||
const hasError = activeTab === 'records' ? recordsError : schedulesError;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-3">Maintenance</h2>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => {
|
||||
setActiveTab(v as 'records' | 'schedules');
|
||||
setShowForm(false);
|
||||
}}
|
||||
aria-label="Maintenance tabs"
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 40,
|
||||
fontSize: '0.875rem',
|
||||
textTransform: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab label="Records" value="records" />
|
||||
<Tab label="Schedules" value="schedules" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Add Button */}
|
||||
<div className="flex justify-end mb-3">
|
||||
<Button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="min-h-[44px]"
|
||||
>
|
||||
{showForm ? 'Cancel' : activeTab === 'records' ? 'Add Record' : 'Add Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="text-slate-500 dark:text-titanio py-6 text-center">
|
||||
Loading maintenance {activeTab}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{hasError && (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">Failed to load maintenance {activeTab}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Form */}
|
||||
{showForm && !isLoading && !hasError && (
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
|
||||
{activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'}
|
||||
</h3>
|
||||
{activeTab === 'records' ? (
|
||||
<MaintenanceRecordForm />
|
||||
) : (
|
||||
<MaintenanceScheduleForm />
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Records List */}
|
||||
{activeTab === 'records' && !isLoading && !hasError && (
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
|
||||
Recent Records
|
||||
</h3>
|
||||
{records && records.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-slate-600 dark:text-titanio text-sm mb-3">No maintenance records yet</p>
|
||||
<p className="text-slate-500 dark:text-titanio text-xs">
|
||||
Add a record to track your vehicle maintenance
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MaintenanceRecordsList
|
||||
records={records || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Schedules List */}
|
||||
{activeTab === 'schedules' && !isLoading && !hasError && (
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
|
||||
Maintenance Schedules
|
||||
</h3>
|
||||
{schedules && schedules.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-slate-600 dark:text-titanio text-sm mb-3">No schedules yet</p>
|
||||
<p className="text-slate-500 dark:text-titanio text-xs">
|
||||
Create a schedule to get maintenance reminders
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MaintenanceSchedulesList
|
||||
schedules={schedules || []}
|
||||
onEdit={handleScheduleEdit}
|
||||
onDelete={handleScheduleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Edit Dialogs */}
|
||||
<MaintenanceRecordEditDialog
|
||||
open={editDialogOpen}
|
||||
record={editingRecord}
|
||||
onClose={handleEditClose}
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
<MaintenanceScheduleEditDialog
|
||||
open={scheduleEditDialogOpen}
|
||||
schedule={editingSchedule}
|
||||
onClose={handleScheduleEditClose}
|
||||
onSave={handleScheduleEditSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceMobileScreen;
|
||||
@@ -3,7 +3,7 @@
|
||||
* @ai-context Enhanced with Suspense, useOptimistic, and useTransition
|
||||
*/
|
||||
|
||||
import React, { useState, useTransition, useMemo } from 'react';
|
||||
import React, { useState, useTransition, useMemo, useEffect } from 'react';
|
||||
import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
@@ -16,12 +16,13 @@ import { VehicleForm } from '../components/VehicleForm';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
||||
import { useAppStore } from '../../../core/store';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
|
||||
export const VehiclesPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
||||
@@ -52,6 +53,16 @@ export const VehiclesPage: React.FC = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
||||
|
||||
// Auto-show form if navigated with showAddForm state (from dashboard)
|
||||
useEffect(() => {
|
||||
const state = location.state as { showAddForm?: boolean } | null;
|
||||
if (state?.showAddForm) {
|
||||
setShowForm(true);
|
||||
// Clear the state to prevent re-triggering on refresh
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
|
||||
const handleSelectVehicle = (id: string) => {
|
||||
// Use transition for navigation to avoid blocking UI
|
||||
startTransition(() => {
|
||||
|
||||
Reference in New Issue
Block a user