diff --git a/.gitea/issue_template/bug.yaml b/.gitea/issue_template/bug.yaml new file mode 100644 index 0000000..0a41f29 --- /dev/null +++ b/.gitea/issue_template/bug.yaml @@ -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 diff --git a/.gitea/issue_template/chore.yaml b/.gitea/issue_template/chore.yaml new file mode 100644 index 0000000..785b99a --- /dev/null +++ b/.gitea/issue_template/chore.yaml @@ -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 diff --git a/.gitea/issue_template/feature.yaml b/.gitea/issue_template/feature.yaml new file mode 100644 index 0000000..15becc8 --- /dev/null +++ b/.gitea/issue_template/feature.yaml @@ -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 diff --git a/.gitea/workflows/staging.yaml b/.gitea/workflows/staging.yaml index 106e88e..fec55b3 100644 --- a/.gitea/workflows/staging.yaml +++ b/.gitea/workflows/staging.yaml @@ -1,14 +1,16 @@ # MotoVaultPro Staging Deployment Workflow -# Triggers on push to main, builds and deploys to staging.motovaultpro.com +# Triggers on push to main or any pull request, builds and deploys to staging.motovaultpro.com # After verification, sends notification with link to trigger production deploy name: Deploy to Staging -run-name: Staging Deploy - ${{ gitea.sha }} +run-name: "Staging - ${{ gitea.event.pull_request.title || gitea.ref_name }}" on: push: branches: - main + pull_request: + types: [opened, synchronize, reopened] env: REGISTRY: git.motovaultpro.com diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05a1292..2f6c58c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,10 +22,12 @@ const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPag const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage }))); const MaintenancePage = lazy(() => import('./features/maintenance/pages/MaintenancePage').then(m => ({ default: m.MaintenancePage }))); const StationsPage = lazy(() => import('./features/stations/pages/StationsPage').then(m => ({ default: m.StationsPage }))); +const DashboardPage = lazy(() => import('./features/dashboard/pages/DashboardPage').then(m => ({ default: m.DashboardPage }))); const StationsMobileScreen = lazy(() => import('./features/stations/mobile/StationsMobileScreen').then(m => ({ default: m.default }))); const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen }))); const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile }))); const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen')); +const MaintenanceMobileScreen = lazy(() => import('./features/maintenance/mobile/MaintenanceMobileScreen')); // Admin pages (lazy-loaded) const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage }))); @@ -78,18 +80,7 @@ import { useDataSync } from './core/hooks/useDataSync'; import { MobileDebugPanel } from './core/debug/MobileDebugPanel'; import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary'; import { useLoginNotifications } from './features/notifications/hooks/useLoginNotifications'; - -// Hoisted mobile screen components to stabilize identity and prevent remounts -const DashboardScreen: React.FC = () => ( -
- -
-

Dashboard

-

Coming soon - Vehicle insights and analytics

-
-
-
-); +import { DashboardScreen as DashboardFeature } from './features/dashboard'; const LogFuelScreen: React.FC = () => { const queryClient = useQueryClient(); @@ -640,7 +631,16 @@ function App() { transition={{ duration: 0.2, ease: "easeOut" }} > - + navigateToScreen('Maintenance', { source: 'dashboard-maintenance' })} + onAddVehicle={() => { + setShowAddVehicle(true); + navigateToScreen('Vehicles', { source: 'dashboard-add-vehicle' }); + navigateToVehicleSubScreen('add', undefined, { source: 'dashboard-add-vehicle' }); + }} + /> )} @@ -691,6 +691,31 @@ function App() { )} + {activeScreen === "Maintenance" && ( + + + + +
+
+ Loading maintenance screen... +
+
+
+ + }> + +
+
+
+ )} {activeScreen === "Settings" && ( - } /> + } /> + } /> } /> } /> } /> @@ -965,7 +991,7 @@ function App() { } /> } /> } /> - } /> + } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 42c3d53..2dd2505 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { Link, useLocation } from 'react-router-dom'; import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; +import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; @@ -40,6 +41,7 @@ export const Layout: React.FC = ({ children, mobileMode = false }) }, [mobileMode, setSidebarOpen]); // Removed sidebarOpen from dependencies const navigation = [ + { name: 'Dashboard', href: '/garage/dashboard', icon: }, { name: 'Vehicles', href: '/garage/vehicles', icon: }, { name: 'Fuel Logs', href: '/garage/fuel-logs', icon: }, { name: 'Maintenance', href: '/garage/maintenance', icon: }, diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index 9b4c1d3..b4f677b 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { safeStorage } from '../utils/safe-storage'; -export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup'; +export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; interface NavigationHistory { @@ -51,7 +51,7 @@ export const useNavigationStore = create()( persist( (set, get) => ({ // Initial state - activeScreen: 'Vehicles', + activeScreen: 'Dashboard', vehicleSubScreen: 'list', selectedVehicleId: null, navigationHistory: [], diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx new file mode 100644 index 0000000..ae7d827 --- /dev/null +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -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) => void; + onVehicleClick?: (vehicle: Vehicle) => void; + onViewMaintenance?: () => void; + onAddVehicle?: () => void; +} + +export const DashboardScreen: React.FC = ({ + 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 ( +
+ +
+ + + +

+ Unable to Load Dashboard +

+

+ There was an error loading your dashboard data +

+ +
+
+
+ ); + } + + // Loading state + if (summaryLoading || attentionLoading || !summary || !vehiclesNeedingAttention) { + return ( +
+ + + +
+ ); + } + + // Empty state - no vehicles + if (summary.totalVehicles === 0) { + return ( +
+ +
+ + + +

+ Welcome to MotoVaultPro +

+

+ Get started by adding your first vehicle to track fuel logs, maintenance, and more +

+ +
+
+
+ ); + } + + // Main dashboard view + return ( +
+ {/* Summary Cards */} + + + {/* Vehicles Needing Attention */} + {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( + { + const vehicle = vehiclesNeedingAttention.find(v => v.id === vehicleId); + if (vehicle && onVehicleClick) { + onVehicleClick(vehicle); + } + }} + /> + )} + + {/* Quick Actions */} + onNavigate?.('Vehicles'))} + onLogFuel={() => onNavigate?.('Log Fuel')} + onViewMaintenance={onViewMaintenance ?? (() => onNavigate?.('Vehicles'))} + onViewVehicles={() => onNavigate?.('Vehicles')} + /> + + {/* Footer Hint */} +
+

+ Dashboard updates every 2 minutes +

+
+
+ ); +}; diff --git a/frontend/src/features/dashboard/components/QuickActions.tsx b/frontend/src/features/dashboard/components/QuickActions.tsx new file mode 100644 index 0000000..0a8b9fe --- /dev/null +++ b/frontend/src/features/dashboard/components/QuickActions.tsx @@ -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; + onClick: () => void; +} + +interface QuickActionsProps { + onAddVehicle: () => void; + onLogFuel: () => void; + onViewMaintenance: () => void; + onViewVehicles: () => void; +} + +export const QuickActions: React.FC = ({ + 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 ( + +
+

+ Quick Actions +

+

+ Common tasks and navigation +

+
+ +
+ {actions.map((action) => { + const IconComponent = action.icon; + return ( + + + + + + + {action.title} + + + {action.description} + + + + ); + })} +
+
+ ); +}; + +export const QuickActionsSkeleton: React.FC = () => { + return ( + +
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} +
+ + ); +}; diff --git a/frontend/src/features/dashboard/components/SummaryCards.tsx b/frontend/src/features/dashboard/components/SummaryCards.tsx new file mode 100644 index 0000000..eaf6ec8 --- /dev/null +++ b/frontend/src/features/dashboard/components/SummaryCards.tsx @@ -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 = ({ 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 ( +
+ {cards.map((card) => { + const IconComponent = card.icon; + return ( + +
+ + + +
+

+ {card.title} +

+ + {card.value} + + {card.subtitle && ( +

+ {card.subtitle} +

+ )} +
+
+
+ ); + })} +
+ ); +}; + +export const SummaryCardsSkeleton: React.FC = () => { + return ( +
+ {[1, 2, 3].map((i) => ( + +
+
+
+
+
+
+
+
+ + ))} +
+ ); +}; diff --git a/frontend/src/features/dashboard/components/VehicleAttention.tsx b/frontend/src/features/dashboard/components/VehicleAttention.tsx new file mode 100644 index 0000000..2ee6b67 --- /dev/null +++ b/frontend/src/features/dashboard/components/VehicleAttention.tsx @@ -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 = ({ vehicles, onVehicleClick }) => { + if (vehicles.length === 0) { + return ( + +
+ + + +

+ All Caught Up! +

+

+ No vehicles need immediate attention +

+
+
+ ); + } + + const priorityConfig: Record }> = { + high: { + color: 'error.main', + icon: ErrorRoundedIcon, + }, + medium: { + color: 'warning.main', + icon: WarningAmberRoundedIcon, + }, + low: { + color: 'info.main', + icon: ScheduleRoundedIcon, + }, + }; + + return ( + +
+

+ Needs Attention +

+

+ Vehicles with overdue maintenance +

+
+ +
+ {vehicles.map((vehicle) => { + const config = priorityConfig[vehicle.priority]; + const IconComponent = config.icon; + return ( + 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', + } : {}, + }} + > +
+ + + +
+ + {vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`} + +

+ {vehicle.reason} +

+ + {vehicle.priority.toUpperCase()} PRIORITY + +
+
+
+ ); + })} +
+
+ ); +}; + +export const VehicleAttentionSkeleton: React.FC = () => { + return ( + +
+
+
+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ + ); +}; diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts new file mode 100644 index 0000000..de61bc9 --- /dev/null +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -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 => { + // 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 => { + // 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), + }); +}; diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts new file mode 100644 index 0000000..09b2fde --- /dev/null +++ b/frontend/src/features/dashboard/index.ts @@ -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'; diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx new file mode 100644 index 0000000..39bd4d7 --- /dev/null +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -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 ( + + + Dashboard + + + + ); +}; diff --git a/frontend/src/features/dashboard/types/index.ts b/frontend/src/features/dashboard/types/index.ts new file mode 100644 index 0000000..2b87066 --- /dev/null +++ b/frontend/src/features/dashboard/types/index.ts @@ -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[]; +} diff --git a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx new file mode 100644 index 0000000..1186481 --- /dev/null +++ b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx @@ -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(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(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 ( +
+ {/* Header */} + +
+

Maintenance

+ + {/* Tabs */} + + { + setActiveTab(v as 'records' | 'schedules'); + setShowForm(false); + }} + aria-label="Maintenance tabs" + sx={{ + minHeight: 40, + '& .MuiTab-root': { + minHeight: 40, + fontSize: '0.875rem', + textTransform: 'none', + }, + }} + > + + + + + + {/* Add Button */} +
+ +
+ + {/* Loading State */} + {isLoading && ( +
+ Loading maintenance {activeTab}... +
+ )} + + {/* Error State */} + {hasError && ( +
+

Failed to load maintenance {activeTab}

+ +
+ )} +
+
+ + {/* Form */} + {showForm && !isLoading && !hasError && ( + +
+

+ {activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'} +

+ {activeTab === 'records' ? ( + + ) : ( + + )} +
+
+ )} + + {/* Records List */} + {activeTab === 'records' && !isLoading && !hasError && ( + +
+

+ Recent Records +

+ {records && records.length === 0 ? ( +
+

No maintenance records yet

+

+ Add a record to track your vehicle maintenance +

+
+ ) : ( + + )} +
+
+ )} + + {/* Schedules List */} + {activeTab === 'schedules' && !isLoading && !hasError && ( + +
+

+ Maintenance Schedules +

+ {schedules && schedules.length === 0 ? ( +
+

No schedules yet

+

+ Create a schedule to get maintenance reminders +

+
+ ) : ( + + )} +
+
+ )} + + {/* Edit Dialogs */} + + +
+ ); +}; + +export default MaintenanceMobileScreen; diff --git a/frontend/src/features/vehicles/pages/VehiclesPage.tsx b/frontend/src/features/vehicles/pages/VehiclesPage.tsx index c060903..f0873d4 100644 --- a/frontend/src/features/vehicles/pages/VehiclesPage.tsx +++ b/frontend/src/features/vehicles/pages/VehiclesPage.tsx @@ -3,7 +3,7 @@ * @ai-context Enhanced with Suspense, useOptimistic, and useTransition */ -import React, { useState, useTransition, useMemo } from 'react'; +import React, { useState, useTransition, useMemo, useEffect } from 'react'; import { Box, Typography, Grid, Button as MuiButton, TextField, IconButton } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import SearchIcon from '@mui/icons-material/Search'; @@ -16,12 +16,13 @@ import { VehicleForm } from '../components/VehicleForm'; import { Card } from '../../../shared-minimal/components/Card'; import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers'; import { useAppStore } from '../../../core/store'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { vehiclesApi } from '../api/vehicles.api'; export const VehiclesPage: React.FC = () => { const navigate = useNavigate(); + const location = useLocation(); const queryClient = useQueryClient(); const { data: vehicles, isLoading } = useVehicles(); const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle); @@ -52,6 +53,16 @@ export const VehiclesPage: React.FC = () => { const [showForm, setShowForm] = useState(false); const [stagedImageFile, setStagedImageFile] = useState(null); + // Auto-show form if navigated with showAddForm state (from dashboard) + useEffect(() => { + const state = location.state as { showAddForm?: boolean } | null; + if (state?.showAddForm) { + setShowForm(true); + // Clear the state to prevent re-triggering on refresh + navigate(location.pathname, { replace: true, state: {} }); + } + }, [location.state, location.pathname, navigate]); + const handleSelectVehicle = (id: string) => { // Use transition for navigation to avoid blocking UI startTransition(() => {