From 325cf08df028b4a7e6ef33cf315f8ba42c7ce354 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:32:40 -0600 Subject: [PATCH 01/25] fix: promote vehicle display utils to core with null safety (refs #165) Create shared getVehicleLabel/getVehicleSubtitle in core/utils with VehicleLike interface. Replace all direct year/make/model concatenation across 17 consumer files to prevent null values in vehicle names. Co-Authored-By: Claude Opus 4.6 --- frontend/README.md | 1 + frontend/src/core/utils/vehicleDisplay.ts | 27 +++++++++++++++++++ .../admin/mobile/AdminUsersMobileScreen.tsx | 3 ++- .../dashboard/components/VehicleAttention.tsx | 3 ++- frontend/src/features/documents/CLAUDE.md | 1 - .../mobile/DocumentsMobileScreen.tsx | 2 +- .../documents/pages/DocumentDetailPage.tsx | 2 +- .../documents/pages/DocumentsPage.tsx | 2 +- .../features/documents/utils/vehicleLabel.ts | 11 -------- .../components/ResolveAssociationDialog.tsx | 5 ++-- .../MaintenanceRecordEditDialog.tsx | 6 ++--- .../components/MaintenanceRecordForm.tsx | 3 ++- .../MaintenanceScheduleEditDialog.tsx | 6 ++--- .../components/MaintenanceScheduleForm.tsx | 3 ++- .../mobile/MaintenanceMobileScreen.tsx | 3 ++- .../maintenance/pages/MaintenancePage.tsx | 3 ++- .../settings/mobile/MobileSettingsScreen.tsx | 3 ++- .../components/VehicleSelectionDialog.tsx | 8 +----- .../vehicles/components/VehicleCard.tsx | 4 +-- .../vehicles/mobile/VehicleDetailMobile.tsx | 4 +-- .../vehicles/mobile/VehicleMobileCard.tsx | 4 +-- .../vehicles/pages/VehicleDetailPage.tsx | 7 +++-- frontend/src/pages/SettingsPage.tsx | 3 ++- 23 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 frontend/src/core/utils/vehicleDisplay.ts delete mode 100644 frontend/src/features/documents/utils/vehicleLabel.ts 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/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/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/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/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index 56040e8..aa09092 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -10,7 +10,7 @@ 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'; export const DocumentsMobileScreen: React.FC = () => { console.log('[DocumentsMobileScreen] Component initializing'); diff --git a/frontend/src/features/documents/pages/DocumentDetailPage.tsx b/frontend/src/features/documents/pages/DocumentDetailPage.tsx index 7cf4d5a..680a9a8 100644 --- a/frontend/src/features/documents/pages/DocumentDetailPage.tsx +++ b/frontend/src/features/documents/pages/DocumentDetailPage.tsx @@ -12,7 +12,7 @@ import { EditDocumentDialog } from '../components/EditDocumentDialog'; import { ExpirationBadge } from '../components/ExpirationBadge'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles'; -import { getVehicleLabel } from '../utils/vehicleLabel'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; export const DocumentDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); diff --git a/frontend/src/features/documents/pages/DocumentsPage.tsx b/frontend/src/features/documents/pages/DocumentsPage.tsx index 9f44d19..93bb0a6 100644 --- a/frontend/src/features/documents/pages/DocumentsPage.tsx +++ b/frontend/src/features/documents/pages/DocumentsPage.tsx @@ -21,7 +21,7 @@ import { ExpirationBadge } from '../components/ExpirationBadge'; import type { DocumentRecord } from '../types/documents.types'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; -import { getVehicleLabel } from '../utils/vehicleLabel'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; export const DocumentsPage: React.FC = () => { const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0(); diff --git a/frontend/src/features/documents/utils/vehicleLabel.ts b/frontend/src/features/documents/utils/vehicleLabel.ts deleted file mode 100644 index b040fbb..0000000 --- a/frontend/src/features/documents/utils/vehicleLabel.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Vehicle } from '../../vehicles/types/vehicles.types'; - -export const getVehicleLabel = (vehicle: Vehicle | 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); - const primary = parts.join(' ').trim(); - if (primary.length > 0) return primary; - if (vehicle.vin?.length > 0) return vehicle.vin; - return vehicle.id.slice(0, 8) + '...'; -}; diff --git a/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx index 0c6425a..f5ef8eb 100644 --- a/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx +++ b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx @@ -23,6 +23,7 @@ import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useResolveAssociation } from '../hooks/usePendingAssociations'; import type { PendingVehicleAssociation } from '../types/email-ingestion.types'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; interface ResolveAssociationDialogProps { open: boolean; @@ -166,9 +167,7 @@ export const ResolveAssociationDialog: React.FC = {vehicles.map((vehicle) => { const isSelected = selectedVehicleId === vehicle.id; - const vehicleName = vehicle.nickname - || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ') - || 'Unnamed Vehicle'; + const vehicleName = getVehicleLabel(vehicle); return ( { 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..cd2d699 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -46,6 +46,7 @@ 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' }), @@ -279,7 +280,7 @@ export const MaintenanceRecordForm: React.FC = () => { {vehicles && vehicles.length > 0 ? ( vehicles.map((vehicle) => ( - {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} )) ) : ( diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleEditDialog.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleEditDialog.tsx index 2b94d2a..3af3644 100644 --- a/frontend/src/features/maintenance/components/MaintenanceScheduleEditDialog.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleEditDialog.tsx @@ -39,6 +39,7 @@ import { import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; +import { getVehicleLabel } from '@/core/utils/vehicleDisplay'; interface MaintenanceScheduleEditDialogProps { open: boolean; @@ -206,10 +207,7 @@ export const MaintenanceScheduleEditDialog: React.FC { 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..f9ccc98 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({ @@ -214,7 +215,7 @@ export const MaintenanceScheduleForm: React.FC = () => { {vehicles && vehicles.length > 0 ? ( vehicles.map((vehicle) => ( - {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} )) ) : ( diff --git a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx index edc5e39..5bc618c 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'} )) ) : ( diff --git a/frontend/src/features/maintenance/pages/MaintenancePage.tsx b/frontend/src/features/maintenance/pages/MaintenancePage.tsx index ff0a241..ca513e7 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(); @@ -156,7 +157,7 @@ export const MaintenancePage: React.FC = () => { {vehicles && vehicles.length > 0 ? ( vehicles.map((vehicle) => ( - {vehicle.year} {vehicle.make} {vehicle.model} + {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'} )) ) : ( 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..276b85d 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 } from '@/core/utils/vehicleDisplay'; interface VehicleCardProps { vehicle: Vehicle; @@ -24,8 +25,7 @@ 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); return ( = ({ 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..14aec11 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 } from '@/core/utils/vehicleDisplay'; interface VehicleMobileCardProps { vehicle: Vehicle; @@ -18,8 +19,7 @@ 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 displayName = getVehicleLabel(vehicle); const displayModel = vehicle.model || 'Unknown Model'; return ( 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 && } -- 2.49.1 From 0e8c6070ef327b0ae28385f09ee2c70dd7741894 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:35:53 -0600 Subject: [PATCH 02/25] fix: sync mobile routing with browser URL for direct navigation (refs #163) URL-to-screen sync on mount and screen-to-URL sync via replaceState enable direct URL navigation, page refresh, and bookmarks on mobile. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 18 +++++++++++- frontend/src/core/store/index.ts | 2 +- frontend/src/core/store/navigation.ts | 40 ++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) 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/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, -- 2.49.1 From 73976a73562c522f792d2a10e9210270d0fe36b8 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:38:21 -0600 Subject: [PATCH 03/25] fix: add Maintenance to mobile More menu (refs #164) Co-Authored-By: Claude Opus 4.6 --- .../src/shared-minimal/components/mobile/HamburgerDrawer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx index d8ecf65..e46a8e1 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,6 +42,7 @@ interface MenuItem { const menuItems: MenuItem[] = [ { screen: 'Settings', label: 'Settings', icon: }, { screen: 'Documents', label: 'Documents', icon: }, + { screen: 'Maintenance', label: 'Maintenance', icon: }, { screen: 'Stations', label: 'Stations', icon: }, { screen: 'Log Fuel', label: 'Log Fuel', icon: }, { screen: 'Vehicles', label: 'Vehicles', icon: }, -- 2.49.1 From 7a74c7f81fb8ad82002dc502b7021a49fe3b69af Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:39:16 -0600 Subject: [PATCH 04/25] chore: remove redundant Stations from mobile More menu (refs #173) Co-Authored-By: Claude Opus 4.6 --- .../src/shared-minimal/components/mobile/HamburgerDrawer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx index e46a8e1..da21282 100644 --- a/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx +++ b/frontend/src/shared-minimal/components/mobile/HamburgerDrawer.tsx @@ -43,7 +43,6 @@ const menuItems: MenuItem[] = [ { screen: 'Settings', label: 'Settings', icon: }, { screen: 'Documents', label: 'Documents', icon: }, { screen: 'Maintenance', label: 'Maintenance', icon: }, - { screen: 'Stations', label: 'Stations', icon: }, { screen: 'Log Fuel', label: 'Log Fuel', icon: }, { screen: 'Vehicles', label: 'Vehicles', icon: }, { screen: 'Dashboard', label: 'Dashboard', icon: }, -- 2.49.1 From bc9c386300e5b6fdec993c4d5279ccf460773853 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:40:09 -0600 Subject: [PATCH 05/25] chore: differentiate Stations icon from Fuel Logs in bottom nav (refs #181) Co-Authored-By: Claude Opus 4.6 --- .../src/shared-minimal/components/mobile/BottomNavigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = ({ -- 2.49.1 From 56be3ed34829f40f98e7f7c2d09d8effdf2cd26e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:41:23 -0600 Subject: [PATCH 06/25] chore: add Year Make Model subtitle to vehicle cards and hide empty VIN (refs #167) Co-Authored-By: Claude Opus 4.6 --- .../vehicles/components/VehicleCard.tsx | 21 +++++++++++++------ .../vehicles/mobile/VehicleMobileCard.tsx | 12 ++++++----- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/vehicles/components/VehicleCard.tsx b/frontend/src/features/vehicles/components/VehicleCard.tsx index 276b85d..81a4784 100644 --- a/frontend/src/features/vehicles/components/VehicleCard.tsx +++ b/frontend/src/features/vehicles/components/VehicleCard.tsx @@ -9,7 +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 } from '@/core/utils/vehicleDisplay'; +import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; interface VehicleCardProps { vehicle: Vehicle; @@ -26,6 +26,7 @@ export const VehicleCard: React.FC = ({ }) => { const { formatDistance } = useUnits(); 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/VehicleMobileCard.tsx b/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx index 14aec11..316703f 100644 --- a/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx +++ b/frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx @@ -6,7 +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 } from '@/core/utils/vehicleDisplay'; +import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay'; interface VehicleMobileCardProps { vehicle: Vehicle; @@ -20,7 +20,7 @@ export const VehicleMobileCard: React.FC = ({ compact = false }) => { const displayName = getVehicleLabel(vehicle); - const displayModel = vehicle.model || 'Unknown Model'; + const subtitle = getVehicleSubtitle(vehicle); return ( = ({ {displayName} - - {displayModel} - + {subtitle && ( + + {subtitle} + + )} {vehicle.licensePlate && ( {vehicle.licensePlate} -- 2.49.1 From 0dc273d2380ad3632d0cd715b332edde1488aa5c Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:43:57 -0600 Subject: [PATCH 07/25] chore: remove dashboard auto-refresh footer text (refs #178) Co-Authored-By: Claude Opus 4.6 --- .../src/features/dashboard/components/DashboardScreen.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index e9444c4..7265978 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -135,13 +135,6 @@ export const DashboardScreen: React.FC = ({ onViewVehicles={() => onNavigate?.('Vehicles')} /> - {/* Footer Hint */} -
-

- Dashboard updates every 2 minutes -

-
- {/* Pending Receipts Dialog */} Date: Fri, 13 Feb 2026 19:45:14 -0600 Subject: [PATCH 08/25] feat: add call-to-action links in zero-state dashboard stats cards (refs #179) Co-Authored-By: Claude Opus 4.6 --- .../dashboard/components/DashboardScreen.tsx | 2 +- .../dashboard/components/SummaryCards.tsx | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index 7265978..a65ac9d 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -112,7 +112,7 @@ export const DashboardScreen: React.FC = ({ setShowPendingReceipts(true)} /> {/* Summary Cards */} - + {/* Vehicles Needing Attention */} {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( 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} -- 2.49.1 From f2b20aab1a8acc2d6a2f889cfbf5f5bbd5787342 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:48:06 -0600 Subject: [PATCH 09/25] feat: add recent activity feed to dashboard (refs #166) Co-Authored-By: Claude Opus 4.6 --- .../dashboard/components/DashboardScreen.tsx | 8 +- .../dashboard/components/RecentActivity.tsx | 118 ++++++++++++++++++ .../dashboard/hooks/useDashboardData.ts | 44 ++++++- frontend/src/features/dashboard/index.ts | 5 +- .../src/features/dashboard/types/index.ts | 9 ++ 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/dashboard/components/RecentActivity.tsx diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index a65ac9d..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 = ({
+
); @@ -127,6 +130,9 @@ export const DashboardScreen: React.FC = ({ /> )} + {/* Recent Activity */} + {recentActivity && } + {/* Quick Actions */} onNavigate?.('Vehicles'))} diff --git a/frontend/src/features/dashboard/components/RecentActivity.tsx b/frontend/src/features/dashboard/components/RecentActivity.tsx new file mode 100644 index 0000000..51e6b9f --- /dev/null +++ b/frontend/src/features/dashboard/components/RecentActivity.tsx @@ -0,0 +1,118 @@ +/** + * @ai-summary Recent activity feed showing latest fuel logs and maintenance events + */ + +import React from 'react'; +import { Box } from '@mui/material'; +import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; +import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { RecentActivityItem } from '../types'; + +interface RecentActivityProps { + items: RecentActivityItem[]; +} + +const formatRelativeTime = (timestamp: string): string => { + 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/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[]; } -- 2.49.1 From e4be744643837954f6e847043f5d0d2b720d4764 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:49:46 -0600 Subject: [PATCH 10/25] chore: restructure Fuel Logs to list-first with add dialog (refs #168) Co-Authored-By: Claude Opus 4.6 --- .../fuel-logs/components/AddFuelLogDialog.tsx | 44 +++++++++++++ .../features/fuel-logs/pages/FuelLogsPage.tsx | 66 ++++++++++--------- 2 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 frontend/src/features/fuel-logs/components/AddFuelLogDialog.tsx diff --git a/frontend/src/features/fuel-logs/components/AddFuelLogDialog.tsx b/frontend/src/features/fuel-logs/components/AddFuelLogDialog.tsx new file mode 100644 index 0000000..2fe8548 --- /dev/null +++ b/frontend/src/features/fuel-logs/components/AddFuelLogDialog.tsx @@ -0,0 +1,44 @@ +/** + * @ai-summary Dialog wrapper for FuelLogForm to create new fuel logs + */ + +import React from 'react'; +import { Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { FuelLogForm } from './FuelLogForm'; + +interface AddFuelLogDialogProps { + open: boolean; + onClose: () => void; +} + +export const AddFuelLogDialog: React.FC = ({ open, onClose }) => { + const isSmallScreen = useMediaQuery('(max-width:600px)'); + + return ( + + + Log Fuel + + + + + + + + + ); +}; diff --git a/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx b/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx index 01c4f08..822ba7a 100644 --- a/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx +++ b/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; -import { Grid, Typography, Box } from '@mui/material'; +import { Typography, Box, Button as MuiButton } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; import { useQueryClient } from '@tanstack/react-query'; -import { FuelLogForm } from '../components/FuelLogForm'; import { FuelLogsList } from '../components/FuelLogsList'; import { FuelLogEditDialog } from '../components/FuelLogEditDialog'; +import { AddFuelLogDialog } from '../components/AddFuelLogDialog'; import { useFuelLogs } from '../hooks/useFuelLogs'; import { FuelStatsCard } from '../components/FuelStatsCard'; -import { FormSuspense } from '../../../components/SuspenseWrappers'; import { FuelLogResponse, UpdateFuelLogRequest } from '../types/fuel-logs.types'; import { fuelLogsApi } from '../api/fuel-logs.api'; @@ -14,9 +14,7 @@ export const FuelLogsPage: React.FC = () => { const { fuelLogs, isLoading, error } = useFuelLogs(); const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); - - // DEBUG: Log page renders - console.log('[FuelLogsPage] Render - fuel logs count:', fuelLogs?.length, 'isLoading:', isLoading, 'error:', !!error); + const [showAddDialog, setShowAddDialog] = useState(false); const handleEdit = (log: FuelLogResponse) => { setEditingLog(log); @@ -24,9 +22,6 @@ export const FuelLogsPage: React.FC = () => { const handleDelete = async (_logId: string) => { try { - console.log('[FuelLogsPage] handleDelete called - using targeted query updates'); - // Use targeted invalidation instead of broad invalidation - // This prevents unnecessary re-renders of the form queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] }); } catch (error) { console.error('Failed to refresh fuel logs after delete:', error); @@ -35,15 +30,12 @@ export const FuelLogsPage: React.FC = () => { const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => { try { - console.log('[FuelLogsPage] handleSaveEdit called - using targeted query updates'); await fuelLogsApi.update(id, data); - // Use targeted refetch instead of broad invalidation - // This prevents unnecessary re-renders of the form queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] }); setEditingLog(null); } catch (error) { console.error('Failed to update fuel log:', error); - throw error; // Re-throw to let the dialog handle the error + throw error; } }; @@ -78,22 +70,36 @@ export const FuelLogsPage: React.FC = () => { } return ( - - - - - - - Summary - - Recent Fuel Logs - - - + + {/* Header with Add button */} + + Fuel Logs + } + onClick={() => setShowAddDialog(true)} + > + Add Fuel Log + + + + {/* Summary Stats */} + + + + + {/* Fuel Logs List */} + + + {/* Add Dialog */} + setShowAddDialog(false)} + /> {/* Edit Dialog */} { onClose={handleCloseEdit} onSave={handleSaveEdit} /> - + ); }; -- 2.49.1 From f03cd420ef6484ef6d896c0d2c3c7beca34f3e19 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:53:13 -0600 Subject: [PATCH 11/25] chore: add Maintenance page title and remove duplicate vehicle dropdown (refs #169) Co-Authored-By: Claude Opus 4.6 --- .../components/MaintenanceRecordForm.tsx | 75 +++++++++++-------- .../components/MaintenanceScheduleForm.tsx | 63 +++++++++------- .../mobile/MaintenanceMobileScreen.tsx | 4 +- .../maintenance/pages/MaintenancePage.tsx | 7 +- 4 files changed, 87 insertions(+), 62 deletions(-) diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index cd2d699..f1baa68 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -63,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); @@ -102,7 +106,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], @@ -113,6 +117,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(() => { @@ -263,37 +272,39 @@ export const MaintenanceRecordForm: React.FC = () => {
- {/* 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 */} diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx index f9ccc98..3996d8d 100644 --- a/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx @@ -98,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); @@ -114,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, @@ -128,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'); @@ -198,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} )} @@ -229,6 +239,7 @@ export const MaintenanceScheduleForm: React.FC = () => { )} /> + )} {/* Category Selection */} diff --git a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx index 5bc618c..a4c8b27 100644 --- a/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx +++ b/frontend/src/features/maintenance/mobile/MaintenanceMobileScreen.tsx @@ -199,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 ca513e7..9272ed4 100644 --- a/frontend/src/features/maintenance/pages/MaintenancePage.tsx +++ b/frontend/src/features/maintenance/pages/MaintenancePage.tsx @@ -142,6 +142,9 @@ export const MaintenancePage: React.FC = () => { return ( + {/* Page Title */} + Maintenance + {/* Vehicle Selector */} @@ -182,7 +185,7 @@ export const MaintenancePage: React.FC = () => { {/* Top: Form */} - + {/* Bottom: Records List */} @@ -203,7 +206,7 @@ export const MaintenancePage: React.FC = () => { {/* Top: Form */} - + {/* Bottom: Schedules List */} -- 2.49.1 From afd458345041fb0daddab11187aa5193e5b45e25 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:55:25 -0600 Subject: [PATCH 12/25] chore: show service type in maintenance schedule names for differentiation (refs #174) Co-Authored-By: Claude Opus 4.6 --- .../components/MaintenanceSchedulesList.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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)} )} -- 2.49.1 From daa0cd072eeebc887af1adb85d75eb3e1af95333 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:56:34 -0600 Subject: [PATCH 13/25] chore: remove Insurance default bias from Add Document modal (refs #175) Co-Authored-By: Claude Opus 4.6 --- .../src/features/documents/components/DocumentForm.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 > + -- 2.49.1 From 553877bfc6507f1b595bfe3095f246778c93d845 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:00:49 -0600 Subject: [PATCH 14/25] chore: add upload date and file type icon to document cards (refs #172) Co-Authored-By: Claude Opus 4.6 --- .../mobile/DocumentsMobileScreen.tsx | 24 ++++++++++++++++--- .../documents/pages/DocumentsPage.tsx | 21 +++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index aa09092..5140d6c 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -11,6 +11,13 @@ import { ExpirationBadge } from '../components/ExpirationBadge'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; 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'} +
+ + {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 }}> 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 -- 2.49.1 From 6751766b0a75eb59d11209eff241f96739c8a649 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:55:21 -0600 Subject: [PATCH 18/25] chore: create AddReceiptDialog component with upload and camera options (refs #183) Co-Authored-By: Claude Opus 4.6 --- .../components/AddReceiptDialog.tsx | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 frontend/src/features/maintenance/components/AddReceiptDialog.tsx diff --git a/frontend/src/features/maintenance/components/AddReceiptDialog.tsx b/frontend/src/features/maintenance/components/AddReceiptDialog.tsx new file mode 100644 index 0000000..a2728d8 --- /dev/null +++ b/frontend/src/features/maintenance/components/AddReceiptDialog.tsx @@ -0,0 +1,272 @@ +/** + * @ai-summary Full-screen dialog with upload and camera options for receipt input + * @ai-context Replaces direct camera launch with upload-first pattern; both paths feed OCR pipeline + */ + +import React, { useRef, useState, useCallback } from 'react'; +import { + Dialog, + Box, + Typography, + Button, + IconButton, + Alert, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; +import { + DEFAULT_ACCEPTED_FORMATS, + DEFAULT_MAX_FILE_SIZE, +} from '../../../shared/components/CameraCapture/types'; + +interface AddReceiptDialogProps { + open: boolean; + onClose: () => void; + onFileSelect: (file: File) => void; + onStartCamera: () => void; +} + +export const AddReceiptDialog: React.FC = ({ + open, + onClose, + onFileSelect, + onStartCamera, +}) => { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + + const validateFile = useCallback((file: File): string | null => { + const isValidType = DEFAULT_ACCEPTED_FORMATS.some((format) => { + if (format === 'image/heic' || format === 'image/heif') { + return ( + file.type === 'image/heic' || + file.type === 'image/heif' || + file.name.toLowerCase().endsWith('.heic') || + file.name.toLowerCase().endsWith('.heif') + ); + } + return file.type === format; + }); + + if (!isValidType) { + return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC'; + } + + if (file.size > DEFAULT_MAX_FILE_SIZE) { + return `File too large. Maximum size: ${(DEFAULT_MAX_FILE_SIZE / (1024 * 1024)).toFixed(0)}MB`; + } + + return null; + }, []); + + const handleFile = useCallback( + (file: File) => { + setError(null); + const validationError = validateFile(file); + if (validationError) { + setError(validationError); + return; + } + onFileSelect(file); + }, + [validateFile, onFileSelect] + ); + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFile(file); + } + if (inputRef.current) { + inputRef.current.value = ''; + } + }, + [handleFile] + ); + + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + const file = event.dataTransfer.files[0]; + if (file) { + handleFile(file); + } + }, + [handleFile] + ); + + const handleClickUpload = useCallback(() => { + inputRef.current?.click(); + }, []); + + // Reset error state when dialog closes + const handleClose = useCallback(() => { + setError(null); + setIsDragging(false); + onClose(); + }, [onClose]); + + return ( + + {/* Header */} + + Add Receipt + + + + + + {/* Content */} + + {/* Drag-and-drop upload zone */} + + + + {isDragging ? 'Drop image here' : 'Drag and drop an image, or tap to browse'} + + + JPEG, PNG, HEIC -- up to 10MB + + + + {error && ( + + {error} + + )} + + {/* Divider with "or" */} + + + + or + + + + + {/* Take Photo button */} + + + + {/* Hidden file input */} + + + ); +}; -- 2.49.1 From 812823f2f1dece22c2aec21eb0863020fa3a61d7 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:57:37 -0600 Subject: [PATCH 19/25] chore: integrate AddReceiptDialog into MaintenanceRecordForm (refs #184) Replace ReceiptCameraButton with "Add Receipt" button that opens AddReceiptDialog. Upload path feeds handleCaptureImage, camera path calls startCapture. Tier gating preserved. Co-Authored-By: Claude Opus 4.6 --- .../components/MaintenanceRecordForm.tsx | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index f1baa68..f20aae7 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -40,7 +40,7 @@ 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'; @@ -92,6 +92,9 @@ export const MaintenanceRecordForm: React.FC = ({ ve updateField, } = useMaintenanceReceiptOcr(); + // AddReceiptDialog visibility state + const [showAddReceiptDialog, setShowAddReceiptDialog] = useState(false); + // Store captured file for document upload on submit const [capturedReceiptFile, setCapturedReceiptFile] = useState(null); @@ -245,7 +248,7 @@ export const MaintenanceRecordForm: React.FC = ({ ve - {/* Receipt Scan Button */} + {/* Add Receipt Button */} = ({ ve 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 + @@ -507,6 +517,20 @@ export const MaintenanceRecordForm: React.FC = ({ ve + {/* Add Receipt Dialog */} + setShowAddReceiptDialog(false)} + onFileSelect={(file) => { + setShowAddReceiptDialog(false); + handleCaptureImage(file); + }} + onStartCamera={() => { + setShowAddReceiptDialog(false); + startCapture(); + }} + /> + {/* Camera Capture Modal */} Date: Fri, 13 Feb 2026 21:14:22 -0600 Subject: [PATCH 20/25] chore: accept PDF files in receipt upload dialog (refs #182) Co-Authored-By: Claude Opus 4.6 --- .../maintenance/components/AddReceiptDialog.tsx | 14 ++++++++++---- .../src/shared/components/CameraCapture/types.ts | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/maintenance/components/AddReceiptDialog.tsx b/frontend/src/features/maintenance/components/AddReceiptDialog.tsx index a2728d8..65123ff 100644 --- a/frontend/src/features/maintenance/components/AddReceiptDialog.tsx +++ b/frontend/src/features/maintenance/components/AddReceiptDialog.tsx @@ -47,11 +47,17 @@ export const AddReceiptDialog: React.FC = ({ file.name.toLowerCase().endsWith('.heif') ); } + if (format === 'application/pdf') { + return ( + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ); + } return file.type === format; }); if (!isValidType) { - return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC'; + return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC, PDF'; } if (file.size > DEFAULT_MAX_FILE_SIZE) { @@ -207,10 +213,10 @@ export const AddReceiptDialog: React.FC = ({ textAlign="center" fontWeight={500} > - {isDragging ? 'Drop image here' : 'Drag and drop an image, or tap to browse'} + {isDragging ? 'Drop file here' : 'Drag and drop an image or PDF, or tap to browse'} - JPEG, PNG, HEIC -- up to 10MB + JPEG, PNG, HEIC, PDF -- up to 10MB @@ -265,7 +271,7 @@ export const AddReceiptDialog: React.FC = ({ accept={DEFAULT_ACCEPTED_FORMATS.join(',')} onChange={handleInputChange} style={{ display: 'none' }} - aria-label="Select receipt image" + aria-label="Select receipt file" />
); 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) */ -- 2.49.1 From 653c535165a2f3cc5590346ab0c60c77acf2e084 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:22:40 -0600 Subject: [PATCH 21/25] chore: add PDF support to receipt OCR pipeline (refs #182) The receipt extractor only accepted image MIME types, rejecting PDFs at the OCR layer. Added application/pdf to supported types and PDF-to-image conversion (first page at 300 DPI) before OCR preprocessing. Co-Authored-By: Claude Opus 4.6 --- .../maintenance_receipt_extractor.py | 2 +- ocr/app/extractors/receipt_extractor.py | 31 ++++++++++++++++++- ocr/app/routers/extract.py | 4 +-- 3 files changed, 33 insertions(+), 4 deletions(-) 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..07ee32a 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,23 @@ 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: + from pdf2image import convert_from_bytes + + 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() + except ImportError: + logger.warning("pdf2image 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" -- 2.49.1 From 5877b531f9fbbc60447bcbc9a9296bbafa3f4c08 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:27:40 -0600 Subject: [PATCH 22/25] fix: allow PDF uploads in backend OCR controller and service (refs #182) The backend SUPPORTED_IMAGE_TYPES set excluded application/pdf, returning 415 before the request ever reached the OCR microservice. Added PDF to the allowed types in both controller and service validation layers. Co-Authored-By: Claude Opus 4.6 --- backend/src/features/ocr/api/ocr.controller.ts | 7 ++++--- backend/src/features/ocr/domain/ocr.service.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) 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', ]); /** -- 2.49.1 From 5e4515da7ca84c1ef300caa96cd462af32d1c2ec Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:34:17 -0600 Subject: [PATCH 23/25] fix: use PyMuPDF instead of pdf2image for PDF-to-image conversion (refs #182) pdf2image requires poppler-utils which is not installed in the OCR container. PyMuPDF is already in requirements.txt and can render PDF pages to PNG at 300 DPI natively without extra system dependencies. Co-Authored-By: Claude Opus 4.6 --- ocr/app/extractors/receipt_extractor.py | 17 ++++++++++------- ocr/app/services/ocr_service.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/ocr/app/extractors/receipt_extractor.py b/ocr/app/extractors/receipt_extractor.py index 07ee32a..8468398 100644 --- a/ocr/app/extractors/receipt_extractor.py +++ b/ocr/app/extractors/receipt_extractor.py @@ -162,15 +162,18 @@ class ReceiptExtractor(BaseExtractor): def _extract_pdf_first_page(self, pdf_bytes: bytes) -> bytes: """Extract first page of PDF as PNG image for OCR processing.""" try: - 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] + # 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("pdf2image not available, PDF support limited") + logger.warning("PyMuPDF not available, PDF support limited") except Exception as e: logger.error(f"PDF first page extraction failed: {e}") 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}") -- 2.49.1 From 220f8ea3ac70d03ed256398caa41b737f1135e4b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:38:05 -0600 Subject: [PATCH 24/25] fix: increase hybrid engine cloud timeout for WIF token exchange (refs #182) The 5s cloud timeout was too tight for the initial WIF authentication which requires 3 HTTP round-trips (STS, IAM credentials, resource manager). First call took 5.5s and was discarded, falling back to slow CPU-based PaddleOCR. Increased to 10s to accommodate cold-start auth. Co-Authored-By: Claude Opus 4.6 --- ocr/app/engines/hybrid_engine.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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" -- 2.49.1 From 7f6e4e0ec2fee97b2cb97933cffd73b59dd7bea9 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:43:47 -0600 Subject: [PATCH 25/25] fix: skip image preview for PDF receipt uploads (refs #182) URL.createObjectURL on a PDF creates a blob URL that cannot render in an img tag, showing broken image alt text. Skip preview creation for PDF files so the review modal displays without a thumbnail. Co-Authored-By: Claude Opus 4.6 --- .../features/maintenance/hooks/useMaintenanceReceiptOcr.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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); -- 2.49.1