Merge pull request 'feat: Dashboard - Vehicle Fleet Overview (#2)' (#3) from issue-2-dashboard-fleet-overview into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 22s
Deploy to Staging / Deploy to Staging (push) Successful in 36s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 5s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 22s
Deploy to Staging / Deploy to Staging (push) Successful in 36s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 5s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
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
|
# 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
|
# After verification, sends notification with link to trigger production deploy
|
||||||
|
|
||||||
name: Deploy to Staging
|
name: Deploy to Staging
|
||||||
run-name: Staging Deploy - ${{ gitea.sha }}
|
run-name: "Staging - ${{ gitea.event.pull_request.title || gitea.ref_name }}"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.motovaultpro.com
|
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 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 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 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 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 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 VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
|
||||||
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
|
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
|
||||||
|
const MaintenanceMobileScreen = lazy(() => import('./features/maintenance/mobile/MaintenanceMobileScreen'));
|
||||||
|
|
||||||
// Admin pages (lazy-loaded)
|
// Admin pages (lazy-loaded)
|
||||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
|
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 { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||||
import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications';
|
import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications';
|
||||||
|
import { DashboardScreen as DashboardFeature } from './features/dashboard';
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LogFuelScreen: React.FC = () => {
|
const LogFuelScreen: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -640,7 +631,16 @@ function App() {
|
|||||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<MobileErrorBoundary screenName="Dashboard">
|
<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>
|
</MobileErrorBoundary>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -691,6 +691,31 @@ function App() {
|
|||||||
</MobileErrorBoundary>
|
</MobileErrorBoundary>
|
||||||
</motion.div>
|
</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" && (
|
{activeScreen === "Settings" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="settings"
|
key="settings"
|
||||||
@@ -950,7 +975,8 @@ function App() {
|
|||||||
<Layout mobileMode={false}>
|
<Layout mobileMode={false}>
|
||||||
<RouteSuspense>
|
<RouteSuspense>
|
||||||
<Routes>
|
<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" element={<VehiclesPage />} />
|
||||||
<Route path="/garage/vehicles/:id" element={<VehicleDetailPage />} />
|
<Route path="/garage/vehicles/:id" element={<VehicleDetailPage />} />
|
||||||
<Route path="/garage/fuel-logs" element={<FuelLogsPage />} />
|
<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/community-stations" element={<AdminCommunityStationsPage />} />
|
||||||
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
|
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
|
||||||
<Route path="/garage/settings/admin/backup" element={<AdminBackupPage />} />
|
<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>
|
</Routes>
|
||||||
</RouteSuspense>
|
</RouteSuspense>
|
||||||
<DebugInfo />
|
<DebugInfo />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React from 'react';
|
|||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
|
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 DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||||
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
|
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
|
}, [mobileMode, setSidebarOpen]); // Removed sidebarOpen from dependencies
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/garage/dashboard', icon: <HomeRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
{ name: 'Vehicles', href: '/garage/vehicles', icon: <DirectionsCarRoundedIcon 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: 'Fuel Logs', href: '/garage/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
{ name: 'Maintenance', href: '/garage/maintenance', icon: <BuildRoundedIcon 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 { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { safeStorage } from '../utils/safe-storage';
|
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';
|
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||||
|
|
||||||
interface NavigationHistory {
|
interface NavigationHistory {
|
||||||
@@ -51,7 +51,7 @@ export const useNavigationStore = create<NavigationState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
activeScreen: 'Vehicles',
|
activeScreen: 'Dashboard',
|
||||||
vehicleSubScreen: 'list',
|
vehicleSubScreen: 'list',
|
||||||
selectedVehicleId: null,
|
selectedVehicleId: null,
|
||||||
navigationHistory: [],
|
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
|
* @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 { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
@@ -16,12 +16,13 @@ import { VehicleForm } from '../components/VehicleForm';
|
|||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
|
||||||
import { useAppStore } from '../../../core/store';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { vehiclesApi } from '../api/vehicles.api';
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
|
||||||
export const VehiclesPage: React.FC = () => {
|
export const VehiclesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: vehicles, isLoading } = useVehicles();
|
const { data: vehicles, isLoading } = useVehicles();
|
||||||
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
|
||||||
@@ -52,6 +53,16 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
|
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) => {
|
const handleSelectVehicle = (id: string) => {
|
||||||
// Use transition for navigation to avoid blocking UI
|
// Use transition for navigation to avoid blocking UI
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user