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

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-01-03 04:47:28 +00:00
17 changed files with 1403 additions and 21 deletions

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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 />

View File

@@ -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 }} /> },

View File

@@ -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: [],

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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),
});
};

View 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';

View 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>
);
};

View 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[];
}

View File

@@ -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;

View File

@@ -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(() => {