diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index 4da998c..18860a6 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -15,12 +15,13 @@ const SUPPORTED_TYPES = new Set([ 'application/pdf', ]); -/** Image-only MIME types for receipt extraction (no PDF) */ +/** Image-only MIME types for receipt extraction */ const SUPPORTED_IMAGE_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/heic', 'image/heif', + 'application/pdf', ]); export class OcrController { @@ -268,7 +269,7 @@ export class OcrController { }); return reply.code(415).send({ error: 'Unsupported Media Type', - message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`, + message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`, }); } @@ -380,7 +381,7 @@ export class OcrController { }); return reply.code(415).send({ error: 'Unsupported Media Type', - message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`, + message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`, }); } diff --git a/backend/src/features/ocr/domain/ocr.service.ts b/backend/src/features/ocr/domain/ocr.service.ts index 30ef7e6..0f50af2 100644 --- a/backend/src/features/ocr/domain/ocr.service.ts +++ b/backend/src/features/ocr/domain/ocr.service.ts @@ -31,12 +31,13 @@ const SUPPORTED_TYPES = new Set([ 'application/pdf', ]); -/** Image-only MIME types for receipt extraction (no PDF) */ +/** MIME types for receipt extraction */ const SUPPORTED_IMAGE_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/heic', 'image/heif', + 'application/pdf', ]); /** diff --git a/frontend/README.md b/frontend/README.md index 9955dbc..4c7ffc7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,6 +13,7 @@ - `src/App.tsx`, `src/main.tsx` — app entry. - `src/features/*` — feature pages/components/hooks. - `src/core/*` — auth, api, store, hooks, query config, utils. +- `src/core/utils/vehicleDisplay.ts` — shared vehicle display helpers: `getVehicleLabel()` (display name with fallback chain) and `getVehicleSubtitle()` (Year Make Model formatting). - `src/shared-minimal/*` — shared UI components and theme. ## Mobile + Desktop (required) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f66ea77..0aa66e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,7 +81,7 @@ import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVe import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types'; import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen'; import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen'; -import { useNavigationStore, useUserStore } from './core/store'; +import { useNavigationStore, useUserStore, routeToScreen, screenToRoute } from './core/store'; import { useNeedsVehicleSelection, useDowngrade } from './features/subscription/hooks/useSubscription'; import { useVehicles } from './features/vehicles/hooks/useVehicles'; import { VehicleSelectionDialog } from './features/subscription/components/VehicleSelectionDialog'; @@ -364,6 +364,22 @@ function App() { const [selectedVehicle, setSelectedVehicle] = useState(null); const [showAddVehicle, setShowAddVehicle] = useState(false); + // Sync browser URL to Zustand screen state on mount (enables direct URL navigation on mobile) + useEffect(() => { + const screen = routeToScreen[window.location.pathname]; + if (screen && screen !== activeScreen) { + navigateToScreen(screen, { source: 'url-sync' }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally runs once on mount + + // Sync Zustand screen changes back to browser URL (enables bookmarks and URL sharing) + useEffect(() => { + const targetPath = screenToRoute[activeScreen]; + if (targetPath && window.location.pathname !== targetPath) { + window.history.replaceState(null, '', targetPath); + } + }, [activeScreen]); + // Update mobile mode on window resize useEffect(() => { const checkMobileMode = () => { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 161db61..3fd08f1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { Link, useLocation } from 'react-router-dom'; import { useLogout } from '../core/auth/useLogout'; -import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; +import { Container, Paper, Typography, Box, IconButton, Avatar, Tooltip } from '@mui/material'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; @@ -15,7 +15,8 @@ import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import MenuIcon from '@mui/icons-material/Menu'; -import CloseIcon from '@mui/icons-material/Close'; +import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import { useAppStore } from '../core/store'; import { Button } from '../shared-minimal/components/Button'; import { NotificationBell } from '../features/notifications'; @@ -29,7 +30,7 @@ interface LayoutProps { export const Layout: React.FC = ({ children, mobileMode = false }) => { const { user } = useAuth0(); const { logout } = useLogout(); - const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); + const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, toggleSidebarCollapse } = useAppStore(); const location = useLocation(); // Sync theme preference with backend @@ -52,6 +53,8 @@ export const Layout: React.FC = ({ children, mobileMode = false }) { name: 'Settings', href: '/garage/settings', icon: }, ]; + const sidebarWidth = sidebarCollapsed ? 64 : 256; + // Mobile layout if (mobileMode) { return ( @@ -107,61 +110,65 @@ export const Layout: React.FC = ({ children, mobileMode = false }) top: 0, left: 0, height: '100vh', - width: 256, + width: sidebarWidth, zIndex: 1000, borderRadius: 0, borderRight: 1, borderColor: 'divider', transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)', - transition: 'transform 0.2s ease-in-out', + transition: 'transform 0.2s ease-in-out, width 0.2s ease-in-out', display: 'flex', - flexDirection: 'column' + flexDirection: 'column', + overflow: 'hidden', }} > - ({ - backgroundColor: 'primary.main', - ...theme.applyStyles('dark', { - backgroundColor: 'transparent', - }), - borderRadius: 0.5, - px: 1, - py: 0.5, - display: 'inline-flex', - alignItems: 'center' - })} - > - MotoVaultPro - + {!sidebarCollapsed && ( + ({ + backgroundColor: 'primary.main', + ...theme.applyStyles('dark', { + backgroundColor: 'transparent', + }), + borderRadius: 0.5, + px: 1, + py: 0.5, + display: 'inline-flex', + alignItems: 'center', + overflow: 'hidden', + })} + > + MotoVaultPro + + )} - + {sidebarCollapsed ? : } - + {navigation.map((item) => { const isActive = location.pathname.startsWith(item.href); - return ( + const navItem = ( = ({ children, mobileMode = false }) sx={{ display: 'flex', alignItems: 'center', - px: 2, + justifyContent: sidebarCollapsed ? 'center' : 'flex-start', + px: sidebarCollapsed ? 1 : 2, py: 1.5, mb: 0.5, borderRadius: 2, @@ -189,52 +197,82 @@ export const Layout: React.FC = ({ children, mobileMode = false }) } }} > - + {item.icon} - - {item.name} - + {!sidebarCollapsed && ( + + {item.name} + + )} ); + return sidebarCollapsed ? ( + + {navItem} + + ) : ( + navItem + ); })} - - - - {user?.name?.charAt(0) || user?.email?.charAt(0)} - - - - {user?.name || user?.email} - - - - + + {sidebarCollapsed ? ( + + logout()} + > + {user?.name?.charAt(0) || user?.email?.charAt(0)} + + + ) : ( + <> + + + {user?.name?.charAt(0) || user?.email?.charAt(0)} + + + + {user?.name || user?.email} + + + + + + )} {/* Main content */} @@ -255,7 +293,7 @@ export const Layout: React.FC = ({ children, mobileMode = false }) px: 3 }}> @@ -263,7 +301,7 @@ export const Layout: React.FC = ({ children, mobileMode = false }) - Welcome back, {user?.name || user?.email} + Welcome back, {user?.given_name || user?.name?.split(' ')[0] || user?.nickname || user?.email} diff --git a/frontend/src/core/store/app.ts b/frontend/src/core/store/app.ts index a1d7e53..165330b 100644 --- a/frontend/src/core/store/app.ts +++ b/frontend/src/core/store/app.ts @@ -4,21 +4,31 @@ import { Vehicle } from '../../features/vehicles/types/vehicles.types'; interface AppState { // UI state sidebarOpen: boolean; + sidebarCollapsed: boolean; selectedVehicle: Vehicle | null; // Actions toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; + toggleSidebarCollapse: () => void; setSelectedVehicle: (vehicle: Vehicle | null) => void; } +const savedCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + export const useAppStore = create((set) => ({ // Initial state sidebarOpen: false, + sidebarCollapsed: savedCollapsed, selectedVehicle: null, // Actions toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }), + toggleSidebarCollapse: () => set((state) => { + const next = !state.sidebarCollapsed; + localStorage.setItem('sidebarCollapsed', String(next)); + return { sidebarCollapsed: next }; + }), setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }), })); \ No newline at end of file diff --git a/frontend/src/core/store/index.ts b/frontend/src/core/store/index.ts index 20f3c4b..d8ec504 100644 --- a/frontend/src/core/store/index.ts +++ b/frontend/src/core/store/index.ts @@ -1,5 +1,5 @@ // Export navigation store -export { useNavigationStore } from './navigation'; +export { useNavigationStore, routeToScreen, screenToRoute } from './navigation'; export type { MobileScreen, VehicleSubScreen } from './navigation'; // Export user store diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index fb507a0..e2a8d34 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -5,6 +5,45 @@ import { safeStorage } from '../utils/safe-storage'; export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'Subscription' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; +/** Maps browser URL paths to mobile screen names for direct URL navigation */ +export const routeToScreen: Record = { + '/garage': 'Dashboard', + '/garage/dashboard': 'Dashboard', + '/garage/vehicles': 'Vehicles', + '/garage/fuel-logs': 'Log Fuel', + '/garage/maintenance': 'Maintenance', + '/garage/stations': 'Stations', + '/garage/documents': 'Documents', + '/garage/settings': 'Settings', + '/garage/settings/security': 'Security', + '/garage/settings/subscription': 'Subscription', + '/garage/settings/admin/users': 'AdminUsers', + '/garage/settings/admin/catalog': 'AdminCatalog', + '/garage/settings/admin/community-stations': 'AdminCommunityStations', + '/garage/settings/admin/email-templates': 'AdminEmailTemplates', + '/garage/settings/admin/backup': 'AdminBackup', + '/garage/settings/admin/logs': 'AdminLogs', +}; + +/** Reverse mapping: mobile screen name to canonical URL path */ +export const screenToRoute: Record = { + 'Dashboard': '/garage/dashboard', + 'Vehicles': '/garage/vehicles', + 'Log Fuel': '/garage/fuel-logs', + 'Maintenance': '/garage/maintenance', + 'Stations': '/garage/stations', + 'Documents': '/garage/documents', + 'Settings': '/garage/settings', + 'Security': '/garage/settings/security', + 'Subscription': '/garage/settings/subscription', + 'AdminUsers': '/garage/settings/admin/users', + 'AdminCatalog': '/garage/settings/admin/catalog', + 'AdminCommunityStations': '/garage/settings/admin/community-stations', + 'AdminEmailTemplates': '/garage/settings/admin/email-templates', + 'AdminBackup': '/garage/settings/admin/backup', + 'AdminLogs': '/garage/settings/admin/logs', +}; + interface NavigationHistory { screen: MobileScreen; vehicleSubScreen?: VehicleSubScreen; @@ -196,7 +235,6 @@ export const useNavigationStore = create()( name: 'motovaultpro-mobile-navigation', storage: createJSONStorage(() => safeStorage), partialize: (state) => ({ - activeScreen: state.activeScreen, vehicleSubScreen: state.vehicleSubScreen, selectedVehicleId: state.selectedVehicleId, formStates: state.formStates, diff --git a/frontend/src/core/utils/vehicleDisplay.ts b/frontend/src/core/utils/vehicleDisplay.ts new file mode 100644 index 0000000..2828e79 --- /dev/null +++ b/frontend/src/core/utils/vehicleDisplay.ts @@ -0,0 +1,27 @@ +/** Vehicle-like object with minimal fields for display purposes */ +export interface VehicleLike { + year?: number | null; + make?: string | null; + model?: string | null; + trimLevel?: string | null; + nickname?: string | null; + vin?: string | null; + id?: string | null; +} + +/** Primary display name with fallback chain: nickname -> year/make/model -> VIN -> ID */ +export const getVehicleLabel = (vehicle: VehicleLike | undefined): string => { + if (!vehicle) return 'Unknown Vehicle'; + if (vehicle.nickname?.trim()) return vehicle.nickname.trim(); + const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean); + if (parts.length > 0) return parts.join(' '); + if (vehicle.vin) return vehicle.vin; + return vehicle.id ? `${vehicle.id.substring(0, 8)}...` : 'Unknown Vehicle'; +}; + +/** Subtitle line: "Year Make Model" with null safety. Returns empty string if insufficient data. */ +export const getVehicleSubtitle = (vehicle: VehicleLike | undefined): string => { + if (!vehicle) return ''; + const parts = [vehicle.year?.toString(), vehicle.make, vehicle.model].filter(Boolean); + return parts.length >= 2 ? parts.join(' ') : ''; +}; diff --git a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx index 0c552b0..4cfc876 100644 --- a/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminUsersMobileScreen.tsx @@ -24,6 +24,7 @@ import { SubscriptionTier, ListUsersParams, } from '../types/admin.types'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; // Modal component for dialogs interface ModalProps { @@ -128,7 +129,7 @@ const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ aut
{data.vehicles.map((vehicle, idx) => (
- {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
))}
diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index e9444c4..01b76aa 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -10,7 +10,8 @@ import CloseIcon from '@mui/icons-material/Close'; import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; import { QuickActions, QuickActionsSkeleton } from './QuickActions'; -import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData'; +import { RecentActivity, RecentActivitySkeleton } from './RecentActivity'; +import { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from '../hooks/useDashboardData'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { Button } from '../../../shared-minimal/components/Button'; import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner'; @@ -37,6 +38,7 @@ export const DashboardScreen: React.FC = ({ const [showPendingReceipts, setShowPendingReceipts] = useState(false); const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); + const { data: recentActivity } = useRecentActivity(); // Error state if (summaryError || attentionError) { @@ -72,6 +74,7 @@ export const DashboardScreen: React.FC = ({
+
); @@ -112,7 +115,7 @@ export const DashboardScreen: React.FC = ({ setShowPendingReceipts(true)} /> {/* Summary Cards */} - + {/* Vehicles Needing Attention */} {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( @@ -127,6 +130,9 @@ export const DashboardScreen: React.FC = ({ /> )} + {/* Recent Activity */} + {recentActivity && } + {/* Quick Actions */} onNavigate?.('Vehicles'))} @@ -135,13 +141,6 @@ export const DashboardScreen: React.FC = ({ onViewVehicles={() => onNavigate?.('Vehicles')} /> - {/* Footer Hint */} -
-

- Dashboard updates every 2 minutes -

-
- {/* Pending Receipts Dialog */} { + const now = new Date(); + const date = new Date(timestamp); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays < 0) { + // Future date (upcoming maintenance) + const absDays = Math.abs(diffDays); + if (absDays === 0) return 'Today'; + if (absDays === 1) return 'Tomorrow'; + return `In ${absDays} days`; + } + if (diffDays === 0) { + if (diffHours === 0) return diffMins <= 1 ? 'Just now' : `${diffMins}m ago`; + return `${diffHours}h ago`; + } + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +}; + +export const RecentActivity: React.FC = ({ items }) => { + if (items.length === 0) { + return ( + +

+ Recent Activity +

+

+ No recent activity. Start by logging fuel or scheduling maintenance. +

+
+ ); + } + + return ( + +

+ Recent Activity +

+
+ {items.map((item, index) => ( +
+ + {item.type === 'fuel' ? ( + + ) : ( + + )} + +
+

+ {item.vehicleName} +

+

+ {item.description} +

+
+ + {formatRelativeTime(item.timestamp)} + +
+ ))} +
+
+ ); +}; + +export const RecentActivitySkeleton: React.FC = () => { + return ( + +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ + ); +}; diff --git a/frontend/src/features/dashboard/components/SummaryCards.tsx b/frontend/src/features/dashboard/components/SummaryCards.tsx index eaf6ec8..9b14463 100644 --- a/frontend/src/features/dashboard/components/SummaryCards.tsx +++ b/frontend/src/features/dashboard/components/SummaryCards.tsx @@ -9,18 +9,22 @@ 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'; +import { MobileScreen } from '../../../core/store'; interface SummaryCardsProps { summary: DashboardSummary; + onNavigate?: (screen: MobileScreen) => void; } -export const SummaryCards: React.FC = ({ summary }) => { +export const SummaryCards: React.FC = ({ summary, onNavigate }) => { const cards = [ { title: 'Total Vehicles', value: summary.totalVehicles, icon: DirectionsCarRoundedIcon, color: 'primary.main', + ctaText: 'Add a vehicle', + ctaScreen: 'Vehicles' as MobileScreen, }, { title: 'Upcoming Maintenance', @@ -28,6 +32,8 @@ export const SummaryCards: React.FC = ({ summary }) => { subtitle: 'Next 30 days', icon: BuildRoundedIcon, color: 'primary.main', + ctaText: 'Schedule maintenance', + ctaScreen: 'Maintenance' as MobileScreen, }, { title: 'Recent Fuel Logs', @@ -35,6 +41,8 @@ export const SummaryCards: React.FC = ({ summary }) => { subtitle: 'Last 7 days', icon: LocalGasStationRoundedIcon, color: 'primary.main', + ctaText: 'Log your first fill-up', + ctaScreen: 'Log Fuel' as MobileScreen, }, ]; @@ -74,11 +82,29 @@ export const SummaryCards: React.FC = ({ summary }) => { > {card.value} - {card.subtitle && ( + {card.value === 0 && card.ctaText ? ( + onNavigate?.(card.ctaScreen)} + sx={{ + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', + color: 'primary.main', + fontSize: '0.75rem', + fontWeight: 500, + mt: 0.5, + '&:hover': { textDecoration: 'underline' }, + }} + > + {card.ctaText} + + ) : card.subtitle ? (

{card.subtitle}

- )} + ) : null}
diff --git a/frontend/src/features/dashboard/components/VehicleAttention.tsx b/frontend/src/features/dashboard/components/VehicleAttention.tsx index 2ee6b67..89fbabc 100644 --- a/frontend/src/features/dashboard/components/VehicleAttention.tsx +++ b/frontend/src/features/dashboard/components/VehicleAttention.tsx @@ -9,6 +9,7 @@ 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 { getVehicleLabel } from '@/core/utils/vehicleDisplay'; import { VehicleNeedingAttention } from '../types'; interface VehicleAttentionProps { @@ -104,7 +105,7 @@ export const VehicleAttention: React.FC = ({ vehicles, on mb: 0.5, }} > - {vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`} + {getVehicleLabel(vehicle)}

{vehicle.reason} diff --git a/frontend/src/features/dashboard/hooks/useDashboardData.ts b/frontend/src/features/dashboard/hooks/useDashboardData.ts index d85011a..28dc63a 100644 --- a/frontend/src/features/dashboard/hooks/useDashboardData.ts +++ b/frontend/src/features/dashboard/hooks/useDashboardData.ts @@ -8,8 +8,9 @@ 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'; +import { DashboardSummary, VehicleNeedingAttention, RecentActivityItem } from '../types'; import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; /** * Combined dashboard data structure @@ -17,6 +18,7 @@ import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types'; interface DashboardData { summary: DashboardSummary; vehiclesNeedingAttention: VehicleNeedingAttention[]; + recentActivity: RecentActivityItem[]; } /** @@ -115,7 +117,30 @@ export const useDashboardData = () => { const priorityOrder = { high: 0, medium: 1, low: 2 }; vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); - return { summary, vehiclesNeedingAttention }; + // Build recent activity feed + const vehicleMap = new Map(vehicles.map(v => [v.id, v])); + + const fuelActivity: RecentActivityItem[] = recentFuelLogs.map(log => ({ + type: 'fuel' as const, + vehicleId: log.vehicleId, + vehicleName: getVehicleLabel(vehicleMap.get(log.vehicleId)), + description: `Filled ${log.fuelUnits.toFixed(1)} gal at $${log.costPerUnit.toFixed(2)}/gal`, + timestamp: log.dateTime, + })); + + const maintenanceActivity: RecentActivityItem[] = upcomingMaintenance.map(schedule => ({ + type: 'maintenance' as const, + vehicleId: schedule.vehicleId, + vehicleName: getVehicleLabel(vehicleMap.get(schedule.vehicleId)), + description: `${schedule.category.replace(/_/g, ' ')} due${schedule.nextDueDate ? ` ${new Date(schedule.nextDueDate).toLocaleDateString()}` : ''}`, + timestamp: schedule.nextDueDate || now.toISOString(), + })); + + const recentActivity = [...fuelActivity, ...maintenanceActivity] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 7); + + return { summary, vehiclesNeedingAttention, recentActivity }; }, enabled: isAuthenticated && !authLoading, staleTime: 2 * 60 * 1000, // 2 minutes @@ -146,6 +171,21 @@ export const useDashboardSummary = () => { }; }; +/** + * Hook to fetch recent activity feed + * Derives from unified dashboard data query + */ +export const useRecentActivity = () => { + const { data, isLoading, error, refetch } = useDashboardData(); + + return { + data: data?.recentActivity, + isLoading, + error, + refetch, + }; +}; + /** * Hook to fetch vehicles needing attention (overdue maintenance) * Derives from unified dashboard data query diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts index 09b2fde..cac9ef7 100644 --- a/frontend/src/features/dashboard/index.ts +++ b/frontend/src/features/dashboard/index.ts @@ -7,5 +7,6 @@ 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'; +export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity'; +export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData'; +export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types'; diff --git a/frontend/src/features/dashboard/types/index.ts b/frontend/src/features/dashboard/types/index.ts index 2b87066..0992627 100644 --- a/frontend/src/features/dashboard/types/index.ts +++ b/frontend/src/features/dashboard/types/index.ts @@ -15,7 +15,16 @@ export interface VehicleNeedingAttention extends Vehicle { priority: 'high' | 'medium' | 'low'; } +export interface RecentActivityItem { + type: 'fuel' | 'maintenance'; + vehicleId: string; + vehicleName: string; + description: string; + timestamp: string; +} + export interface DashboardData { summary: DashboardSummary; vehiclesNeedingAttention: VehicleNeedingAttention[]; + recentActivity: RecentActivityItem[]; } diff --git a/frontend/src/features/documents/CLAUDE.md b/frontend/src/features/documents/CLAUDE.md index b3b0e9c..c115134 100644 --- a/frontend/src/features/documents/CLAUDE.md +++ b/frontend/src/features/documents/CLAUDE.md @@ -12,7 +12,6 @@ Document management UI with maintenance manual extraction. Handles file uploads, | `mobile/` | Mobile-specific document layout | Mobile UI | | `pages/` | DocumentsPage, DocumentDetailPage | Page layout | | `types/` | TypeScript type definitions | Type changes | -| `utils/` | Utility functions (vehicle label formatting) | Helper logic | ## Key Files diff --git a/frontend/src/features/documents/components/DocumentForm.tsx b/frontend/src/features/documents/components/DocumentForm.tsx index 3af4775..f2ed139 100644 --- a/frontend/src/features/documents/components/DocumentForm.tsx +++ b/frontend/src/features/documents/components/DocumentForm.tsx @@ -31,8 +31,8 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel }) => { - const [documentType, setDocumentType] = React.useState( - initialValues?.documentType || 'insurance' + const [documentType, setDocumentType] = React.useState( + initialValues?.documentType || '' ); const [vehicleID, setVehicleID] = React.useState(initialValues?.vehicleId || ''); const [title, setTitle] = React.useState(initialValues?.title || ''); @@ -152,6 +152,10 @@ export const DocumentForm: React.FC = ({ setError('Please select a vehicle.'); return; } + if (!documentType) { + setError('Please select a document type.'); + return; + } if (!title.trim()) { setError('Please enter a title.'); return; @@ -337,7 +341,9 @@ export const DocumentForm: React.FC = ({ value={documentType} onChange={(e) => setDocumentType(e.target.value as DocumentType)} disabled={mode === 'edit'} + required > + diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index 56040e8..5140d6c 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -10,7 +10,14 @@ import { AddDocumentDialog } from '../components/AddDocumentDialog'; import { ExpirationBadge } from '../components/ExpirationBadge'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; -import { getVehicleLabel } from '../utils/vehicleLabel'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; +import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded'; +import ImageRoundedIcon from '@mui/icons-material/ImageRounded'; +import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); export const DocumentsMobileScreen: React.FC = () => { console.log('[DocumentsMobileScreen] Component initializing'); @@ -30,6 +37,13 @@ export const DocumentsMobileScreen: React.FC = () => { const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); + const getFileTypeIcon = (contentType: string | null | undefined) => { + if (!contentType) return ; + if (contentType === 'application/pdf') return ; + if (contentType.startsWith('image/')) return ; + return ; + }; + const triggerUpload = (docId: string) => { try { setCurrentId(docId); @@ -187,9 +201,13 @@ export const DocumentsMobileScreen: React.FC = () => { {doc.title}

-
- {doc.documentType} - {isShared && ' • Shared'} +
+ {getFileTypeIcon(doc.contentType)} + + {doc.documentType} + {doc.createdAt && ` \u00B7 ${dayjs(doc.createdAt).fromNow()}`} + {isShared && ' \u00B7 Shared'} +
+ + + {/* Hidden file input */} + +
+ ); +}; diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx index 5482c47..7c59cf0 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx @@ -36,6 +36,7 @@ import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { documentsApi } from '../../documents/api/documents.api'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; interface MaintenanceRecordEditDialogProps { open: boolean; @@ -218,10 +219,7 @@ export const MaintenanceRecordEditDialog: React.FC { const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicleId); - if (!vehicle) return 'Unknown Vehicle'; - if (vehicle.nickname?.trim()) return vehicle.nickname.trim(); - const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean); - return parts.length > 0 ? parts.join(' ') : 'Vehicle'; + return getVehicleLabel(vehicle); })()} helperText="Vehicle cannot be changed when editing" /> diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index 755373d..f20aae7 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -40,12 +40,13 @@ import { } from '../types/maintenance.types'; import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr'; import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal'; -import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton'; +import { AddReceiptDialog } from './AddReceiptDialog'; import { CameraCapture } from '../../../shared/components/CameraCapture'; import { useTierAccess } from '../../../core/hooks/useTierAccess'; import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; import { documentsApi } from '../../documents/api/documents.api'; import toast from 'react-hot-toast'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; const schema = z.object({ vehicle_id: z.string().uuid({ message: 'Please select a vehicle' }), @@ -62,7 +63,11 @@ const schema = z.object({ type FormData = z.infer; -export const MaintenanceRecordForm: React.FC = () => { +interface MaintenanceRecordFormProps { + vehicleId?: string; +} + +export const MaintenanceRecordForm: React.FC = ({ vehicleId }) => { const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { createRecord, isRecordMutating } = useMaintenanceRecords(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -87,6 +92,9 @@ export const MaintenanceRecordForm: React.FC = () => { updateField, } = useMaintenanceReceiptOcr(); + // AddReceiptDialog visibility state + const [showAddReceiptDialog, setShowAddReceiptDialog] = useState(false); + // Store captured file for document upload on submit const [capturedReceiptFile, setCapturedReceiptFile] = useState(null); @@ -101,7 +109,7 @@ export const MaintenanceRecordForm: React.FC = () => { resolver: zodResolver(schema), mode: 'onChange', defaultValues: { - vehicle_id: '', + vehicle_id: vehicleId || '', category: undefined as any, subtypes: [], date: new Date().toISOString().split('T')[0], @@ -112,6 +120,11 @@ export const MaintenanceRecordForm: React.FC = () => { }, }); + // Sync vehicle_id when parent prop changes + useEffect(() => { + if (vehicleId) setValue('vehicle_id', vehicleId); + }, [vehicleId, setValue]); + // Watch category changes to reset subtypes const watchedCategory = watch('category'); useEffect(() => { @@ -235,7 +248,7 @@ export const MaintenanceRecordForm: React.FC = () => { - {/* Receipt Scan Button */} + {/* Add Receipt Button */} { borderColor: 'divider', }} > - { if (!hasReceiptScanAccess) { setShowUpgradeDialog(true); return; } - startCapture(); + setShowAddReceiptDialog(true); }} disabled={isProcessing || isRecordMutating} - variant="button" - locked={!hasReceiptScanAccess} - /> + sx={{ + minHeight: 44, + borderStyle: 'dashed', + borderWidth: 2, + '&:hover': { borderWidth: 2 }, + }} + > + Add Receipt +
- {/* Vehicle Selection */} - - ( - - Vehicle * - + {vehicles && vehicles.length > 0 ? ( + vehicles.map((vehicle) => ( + + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} + + )) + ) : ( + No vehicles available + )} + + {errors.vehicle_id && ( + {errors.vehicle_id.message} )} - - {errors.vehicle_id && ( - {errors.vehicle_id.message} - )} - - )} - /> - + + )} + /> + + )} {/* Category Selection */} @@ -495,6 +517,20 @@ export const MaintenanceRecordForm: React.FC = () => { + {/* Add Receipt Dialog */} + setShowAddReceiptDialog(false)} + onFileSelect={(file) => { + setShowAddReceiptDialog(false); + handleCaptureImage(file); + }} + onStartCamera={() => { + setShowAddReceiptDialog(false); + startCapture(); + }} + /> + {/* Camera Capture Modal */} { const vehicle = vehicles?.find((v: Vehicle) => v.id === schedule.vehicleId); - if (!vehicle) return 'Unknown Vehicle'; - if (vehicle.nickname?.trim()) return vehicle.nickname.trim(); - const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean); - return parts.length > 0 ? parts.join(' ') : 'Vehicle'; + return getVehicleLabel(vehicle); })()} helperText="Vehicle cannot be changed when editing" /> diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx index a1836c7..3996d8d 100644 --- a/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx @@ -42,6 +42,7 @@ import { getCategoryDisplayName, } from '../types/maintenance.types'; import toast from 'react-hot-toast'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; const schema = z .object({ @@ -97,7 +98,11 @@ const REMINDER_OPTIONS = [ { value: '60', label: '60 days' }, ]; -export const MaintenanceScheduleForm: React.FC = () => { +interface MaintenanceScheduleFormProps { + vehicleId?: string; +} + +export const MaintenanceScheduleForm: React.FC = ({ vehicleId }) => { const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { createSchedule, isScheduleMutating } = useMaintenanceRecords(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -113,7 +118,7 @@ export const MaintenanceScheduleForm: React.FC = () => { resolver: zodResolver(schema), mode: 'onChange', defaultValues: { - vehicle_id: '', + vehicle_id: vehicleId || '', category: undefined as any, subtypes: [], schedule_type: 'interval' as ScheduleType, @@ -127,6 +132,11 @@ export const MaintenanceScheduleForm: React.FC = () => { }, }); + // Sync vehicle_id when parent prop changes + useEffect(() => { + if (vehicleId) setValue('vehicle_id', vehicleId); + }, [vehicleId, setValue]); + // Watch category and schedule type changes const watchedCategory = watch('category'); const watchedScheduleType = watch('schedule_type'); @@ -197,30 +207,31 @@ export const MaintenanceScheduleForm: React.FC = () => { - {/* Vehicle Selection */} - - ( - - Vehicle * - + {/* Vehicle Selection (hidden when vehicleId prop is provided) */} + {!vehicleId && ( + + ( + + Vehicle * + {errors.vehicle_id && ( {errors.vehicle_id.message} )} @@ -228,6 +239,7 @@ export const MaintenanceScheduleForm: React.FC = () => { )} /> + )} {/* Category Selection */} diff --git a/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx b/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx index 03e7f56..a3e6d0b 100644 --- a/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx @@ -191,9 +191,11 @@ export const MaintenanceSchedulesList: React.FC = }} > - + - {categoryDisplay} + {schedule.subtypes && schedule.subtypes.length > 0 + ? `${schedule.subtypes.join(', ')} \u2014 ${categoryDisplay}` + : categoryDisplay} = {scheduleToDelete && ( - {getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)} + {scheduleToDelete.subtypes && scheduleToDelete.subtypes.length > 0 + ? `${scheduleToDelete.subtypes.join(', ')} \u2014 ${getCategoryDisplayName(scheduleToDelete.category)}` + : getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)} )} diff --git a/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts index 725ff1a..03a6a10 100644 --- a/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts +++ b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts @@ -235,7 +235,9 @@ export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn { setResult(null); const imageToProcess = croppedFile || file; - const imageUrl = URL.createObjectURL(imageToProcess); + const isPdf = imageToProcess.type === 'application/pdf' || + imageToProcess.name.toLowerCase().endsWith('.pdf'); + const imageUrl = isPdf ? null : URL.createObjectURL(imageToProcess); setReceiptImageUrl(imageUrl); try { @@ -255,7 +257,7 @@ export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn { console.error('Maintenance receipt OCR processing failed:', err); const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image'; setError(message); - URL.revokeObjectURL(imageUrl); + if (imageUrl) URL.revokeObjectURL(imageUrl); setReceiptImageUrl(null); } finally { setIsProcessing(false); diff --git a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx index edc5e39..a4c8b27 100644 --- a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx +++ b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx @@ -16,6 +16,7 @@ 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'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; export const MaintenanceMobileScreen: React.FC = () => { const queryClient = useQueryClient(); @@ -125,7 +126,7 @@ export const MaintenanceMobileScreen: React.FC = () => { {vehicles && vehicles.length > 0 ? ( vehicles.map((vehicle) => ( - {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} )) ) : ( @@ -198,9 +199,9 @@ export const MaintenanceMobileScreen: React.FC = () => { {activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'} {activeTab === 'records' ? ( - + ) : ( - + )} diff --git a/frontend/src/features/maintenance/pages/MaintenancePage.tsx b/frontend/src/features/maintenance/pages/MaintenancePage.tsx index ff0a241..9272ed4 100644 --- a/frontend/src/features/maintenance/pages/MaintenancePage.tsx +++ b/frontend/src/features/maintenance/pages/MaintenancePage.tsx @@ -16,6 +16,7 @@ import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { FormSuspense } from '../../../components/SuspenseWrappers'; import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; export const MaintenancePage: React.FC = () => { const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); @@ -141,6 +142,9 @@ export const MaintenancePage: React.FC = () => { return ( + {/* Page Title */} + Maintenance + {/* Vehicle Selector */} @@ -156,7 +160,7 @@ export const MaintenancePage: React.FC = () => { {vehicles && vehicles.length > 0 ? ( vehicles.map((vehicle) => ( - {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} )) ) : ( @@ -181,7 +185,7 @@ export const MaintenancePage: React.FC = () => { {/* Top: Form */} - + {/* Bottom: Records List */} @@ -202,7 +206,7 @@ export const MaintenancePage: React.FC = () => { {/* Top: Form */} - + {/* Bottom: Schedules List */} diff --git a/frontend/src/features/notifications/components/NotificationBell.tsx b/frontend/src/features/notifications/components/NotificationBell.tsx index ba905c0..d636ed8 100644 --- a/frontend/src/features/notifications/components/NotificationBell.tsx +++ b/frontend/src/features/notifications/components/NotificationBell.tsx @@ -130,8 +130,12 @@ export const NotificationBell: React.FC = () => { ) : notifications.length === 0 ? ( - - No notifications + + + No notifications + + You're all caught up + ) : ( diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index b2c1ae9..c8183a1 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -7,6 +7,7 @@ import { useSettings } from '../hooks/useSettings'; import { useProfile, useUpdateProfile } from '../hooks/useProfile'; import { useExportUserData } from '../hooks/useExportUserData'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; import { useSubscription } from '../../subscription/hooks/useSubscription'; import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useNavigationStore } from '../../../core/store'; @@ -373,7 +374,7 @@ export const MobileSettingsScreen: React.FC = () => { className="p-3 bg-slate-50 dark:bg-nero rounded-lg" >

- {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}

{vehicle.nickname && (

{vehicle.nickname}

diff --git a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx index e0bf2fc..afb4191 100644 --- a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx +++ b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx @@ -13,6 +13,7 @@ import { Box, } from '@mui/material'; import type { SubscriptionTier } from '../types/subscription.types'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; interface Vehicle { id: string; @@ -70,13 +71,6 @@ export const VehicleSelectionDialog = ({ onConfirm(selectedVehicleIds); }; - const getVehicleLabel = (vehicle: Vehicle): string => { - if (vehicle.nickname) { - return vehicle.nickname; - } - const parts = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean); - return parts.join(' ') || 'Unknown Vehicle'; - }; const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections; diff --git a/frontend/src/features/vehicles/components/VehicleCard.tsx b/frontend/src/features/vehicles/components/VehicleCard.tsx index 05ba0ea..81a4784 100644 --- a/frontend/src/features/vehicles/components/VehicleCard.tsx +++ b/frontend/src/features/vehicles/components/VehicleCard.tsx @@ -9,6 +9,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import { Vehicle } from '../types/vehicles.types'; import { useUnits } from '../../../core/units/UnitsContext'; import { VehicleImage } from './VehicleImage'; +import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; interface VehicleCardProps { vehicle: Vehicle; @@ -24,8 +25,8 @@ export const VehicleCard: React.FC = ({ onSelect, }) => { const { formatDistance } = useUnits(); - const displayName = vehicle.nickname || - [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' '); + const displayName = getVehicleLabel(vehicle); + const subtitle = getVehicleSubtitle(vehicle); return ( = ({ - + {displayName} - - - VIN: {vehicle.vin} - + + {subtitle && ( + + {subtitle} + + )} + + {vehicle.vin && ( + + VIN: {vehicle.vin} + + )} {vehicle.licensePlate && ( diff --git a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx index 2f15bef..b1504f8 100644 --- a/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx +++ b/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx @@ -12,6 +12,7 @@ import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog' import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { VehicleImage } from '../components/VehicleImage'; import { OwnershipCostsList } from '../../ownership-costs'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; interface VehicleDetailMobileProps { vehicle: Vehicle; @@ -38,8 +39,7 @@ export const VehicleDetailMobile: React.FC = ({ onLogFuel, onEdit }) => { - const displayName = vehicle.nickname || - [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle'; + const displayName = getVehicleLabel(vehicle); const displayModel = vehicle.model || 'Unknown Model'; const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All'); diff --git a/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx b/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx index 8f87617..316703f 100644 --- a/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx +++ b/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { Card, CardActionArea, Box, Typography } from '@mui/material'; import { Vehicle } from '../types/vehicles.types'; import { VehicleImage } from '../components/VehicleImage'; +import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; interface VehicleMobileCardProps { vehicle: Vehicle; @@ -18,9 +19,8 @@ export const VehicleMobileCard: React.FC = ({ onClick, compact = false }) => { - const displayName = vehicle.nickname || - [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle'; - const displayModel = vehicle.model || 'Unknown Model'; + const displayName = getVehicleLabel(vehicle); + const subtitle = getVehicleSubtitle(vehicle); return ( = ({ {displayName} - - {displayModel} - + {subtitle && ( + + {subtitle} + + )} {vehicle.licensePlate && ( {vehicle.licensePlate} diff --git a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx index 5e8fa0d..da384cc 100644 --- a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx +++ b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx @@ -12,6 +12,7 @@ import LocalGasStationIcon from '@mui/icons-material/LocalGasStation'; import BuildIcon from '@mui/icons-material/Build'; import DeleteIcon from '@mui/icons-material/Delete'; import { Vehicle } from '../types/vehicles.types'; +import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; import { vehiclesApi } from '../api/vehicles.api'; import { Card } from '../../../shared-minimal/components/Card'; import { VehicleForm } from '../components/VehicleForm'; @@ -224,8 +225,7 @@ export const VehicleDetailPage: React.FC = () => { ); } - const displayName = vehicle.nickname || - [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle'; + const displayName = getVehicleLabel(vehicle); const handleRowClick = (recId: string, type: VehicleRecord['type']) => { if (type === 'Fuel Logs') { @@ -373,8 +373,7 @@ export const VehicleDetailPage: React.FC = () => { Vehicle Details - {vehicle.year} {vehicle.make} {vehicle.model} - {vehicle.trimLevel && ` ${vehicle.trimLevel}`} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} {vehicle.vin && ( diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5c22daf..c7860f7 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -11,6 +11,7 @@ import { useAdminAccess } from '../core/auth/useAdminAccess'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; import { useExportUserData } from '../features/settings/hooks/useExportUserData'; import { useVehicles } from '../features/vehicles/hooks/useVehicles'; +import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; import { useSubscription } from '../features/subscription/hooks/useSubscription'; import { useTheme } from '../shared-minimal/theme/ThemeContext'; import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog'; @@ -375,7 +376,7 @@ export const SettingsPage: React.FC = () => { {index > 0 && } diff --git a/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx b/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx index 9529737..b1312b3 100644 --- a/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx +++ b/frontend/src/shared-minimal/components/mobile/BottomNavigation.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Box, IconButton, Typography, useTheme, SpeedDial, SpeedDialAction, Backdrop, SpeedDialIcon } 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 PlaceRoundedIcon from '@mui/icons-material/PlaceRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; @@ -33,7 +33,7 @@ const leftNavItems: NavItem[] = [ ]; const rightNavItems: NavItem[] = [ - { screen: 'Stations', label: 'Stations', icon: }, + { screen: 'Stations', label: 'Stations', icon: }, ]; export const BottomNavigation: React.FC = ({ diff --git a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx index d8ecf65..da21282 100644 --- a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx +++ b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx @@ -18,6 +18,7 @@ import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; +import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import { MobileScreen } from '../../../core/store/navigation'; @@ -41,7 +42,7 @@ interface MenuItem { const menuItems: MenuItem[] = [ { screen: 'Settings', label: 'Settings', icon: }, { screen: 'Documents', label: 'Documents', icon: }, - { screen: 'Stations', label: 'Stations', icon: }, + { screen: 'Maintenance', label: 'Maintenance', icon: }, { screen: 'Log Fuel', label: 'Log Fuel', icon: }, { screen: 'Vehicles', label: 'Vehicles', icon: }, { screen: 'Dashboard', label: 'Dashboard', icon: }, diff --git a/frontend/src/shared/components/CameraCapture/types.ts b/frontend/src/shared/components/CameraCapture/types.ts index f60f6e8..5be9dca 100644 --- a/frontend/src/shared/components/CameraCapture/types.ts +++ b/frontend/src/shared/components/CameraCapture/types.ts @@ -127,6 +127,7 @@ export const DEFAULT_ACCEPTED_FORMATS = [ 'image/png', 'image/heic', 'image/heif', + 'application/pdf', ]; /** Default max file size (10MB) */ diff --git a/ocr/app/engines/hybrid_engine.py b/ocr/app/engines/hybrid_engine.py index 525a669..4c1c90b 100644 --- a/ocr/app/engines/hybrid_engine.py +++ b/ocr/app/engines/hybrid_engine.py @@ -18,8 +18,11 @@ from app.engines.base_engine import ( logger = logging.getLogger(__name__) -# Maximum time (seconds) to wait for the cloud fallback -_CLOUD_TIMEOUT_SECONDS = 5.0 +# Maximum time (seconds) to wait for the cloud engine. +# WIF token exchange on first call requires 3 HTTP round-trips +# (STS -> IAM credentials -> resource manager) which can take 6-8s. +# Subsequent calls use cached tokens and are fast (<1s). +_CLOUD_TIMEOUT_SECONDS = 10.0 # Redis key prefix for monthly Vision API request counter _VISION_COUNTER_PREFIX = "ocr:vision_requests" diff --git a/ocr/app/extractors/maintenance_receipt_extractor.py b/ocr/app/extractors/maintenance_receipt_extractor.py index 93285ba..d5b4d13 100644 --- a/ocr/app/extractors/maintenance_receipt_extractor.py +++ b/ocr/app/extractors/maintenance_receipt_extractor.py @@ -98,7 +98,7 @@ class MaintenanceReceiptExtractor: """Extract maintenance receipt fields from an image. Args: - image_bytes: Raw image bytes (HEIC, JPEG, PNG). + image_bytes: Raw image or PDF bytes (HEIC, JPEG, PNG, PDF). content_type: MIME type (auto-detected if not provided). Returns: diff --git a/ocr/app/extractors/receipt_extractor.py b/ocr/app/extractors/receipt_extractor.py index 111cfb1..8468398 100644 --- a/ocr/app/extractors/receipt_extractor.py +++ b/ocr/app/extractors/receipt_extractor.py @@ -1,4 +1,5 @@ """Receipt-specific OCR extractor with field extraction.""" +import io import logging import time from dataclasses import dataclass, field @@ -47,6 +48,7 @@ class ReceiptExtractor(BaseExtractor): "image/png", "image/heic", "image/heif", + "application/pdf", } def __init__(self) -> None: @@ -63,7 +65,7 @@ class ReceiptExtractor(BaseExtractor): Extract data from a receipt image. Args: - image_bytes: Raw image bytes (HEIC, JPEG, PNG) + image_bytes: Raw image or PDF bytes (HEIC, JPEG, PNG, PDF) content_type: MIME type (auto-detected if not provided) receipt_type: Hint for receipt type ("fuel" for specialized extraction) @@ -85,6 +87,16 @@ class ReceiptExtractor(BaseExtractor): ) try: + # Convert PDF to image (first page) + if content_type == "application/pdf": + image_bytes = self._extract_pdf_first_page(image_bytes) + if not image_bytes: + return ReceiptExtractionResult( + success=False, + error="Failed to extract image from PDF", + processing_time_ms=int((time.time() - start_time) * 1000), + ) + # Apply receipt-optimized preprocessing preprocessing_result = receipt_preprocessor.preprocess(image_bytes) preprocessed_bytes = preprocessing_result.image_bytes @@ -147,6 +159,26 @@ class ReceiptExtractor(BaseExtractor): detected = mime.from_buffer(file_bytes) return detected or "application/octet-stream" + def _extract_pdf_first_page(self, pdf_bytes: bytes) -> bytes: + """Extract first page of PDF as PNG image for OCR processing.""" + try: + import fitz # PyMuPDF + + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + page = doc[0] + # Render at 300 DPI (default is 72, so scale factor = 300/72) + mat = fitz.Matrix(300 / 72, 300 / 72) + pix = page.get_pixmap(matrix=mat) + png_bytes = pix.tobytes("png") + doc.close() + return png_bytes + except ImportError: + logger.warning("PyMuPDF not available, PDF support limited") + except Exception as e: + logger.error(f"PDF first page extraction failed: {e}") + + return b"" + def _perform_ocr(self, image_bytes: bytes) -> str: """ Perform OCR on preprocessed image via engine abstraction. diff --git a/ocr/app/routers/extract.py b/ocr/app/routers/extract.py index 3c1d02f..52cf0d7 100644 --- a/ocr/app/routers/extract.py +++ b/ocr/app/routers/extract.py @@ -281,9 +281,9 @@ async def extract_maintenance_receipt( - Gemini semantic field extraction from OCR text - Regex cross-validation for dates, amounts, odometer - Supports HEIC, JPEG, PNG formats. + Supports HEIC, JPEG, PNG, and PDF formats. - - **file**: Maintenance receipt image file (max 10MB) + - **file**: Maintenance receipt image or PDF file (max 10MB) Returns: - **receiptType**: "maintenance" diff --git a/ocr/app/services/ocr_service.py b/ocr/app/services/ocr_service.py index 4d06452..4d32dfe 100644 --- a/ocr/app/services/ocr_service.py +++ b/ocr/app/services/ocr_service.py @@ -141,16 +141,17 @@ class OcrService: def _extract_pdf_first_page(self, pdf_bytes: bytes) -> bytes: """Extract first page of PDF as PNG image.""" try: - # Use pdf2image if available, otherwise return empty - from pdf2image import convert_from_bytes + import fitz # PyMuPDF - images = convert_from_bytes(pdf_bytes, first_page=1, last_page=1, dpi=300) - if images: - buffer = io.BytesIO() - images[0].save(buffer, format="PNG") - return buffer.getvalue() + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + page = doc[0] + mat = fitz.Matrix(300 / 72, 300 / 72) + pix = page.get_pixmap(matrix=mat) + png_bytes = pix.tobytes("png") + doc.close() + return png_bytes except ImportError: - logger.warning("pdf2image not available, PDF support limited") + logger.warning("PyMuPDF not available, PDF support limited") except Exception as e: logger.error(f"PDF extraction failed: {e}")