chore: UX design audit cleanup and receipt flow improvements #186

Merged
egullickson merged 25 commits from issue-162-ux-design-audit-cleanup into main 2026-02-14 03:50:23 +00:00
51 changed files with 1064 additions and 276 deletions

View File

@@ -15,12 +15,13 @@ const SUPPORTED_TYPES = new Set([
'application/pdf', 'application/pdf',
]); ]);
/** Image-only MIME types for receipt extraction (no PDF) */ /** Image-only MIME types for receipt extraction */
const SUPPORTED_IMAGE_TYPES = new Set([ const SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/heic', 'image/heic',
'image/heif', 'image/heif',
'application/pdf',
]); ]);
export class OcrController { export class OcrController {
@@ -268,7 +269,7 @@ export class OcrController {
}); });
return reply.code(415).send({ return reply.code(415).send({
error: 'Unsupported Media Type', 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({ return reply.code(415).send({
error: 'Unsupported Media Type', error: 'Unsupported Media Type',
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`, message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
}); });
} }

View File

@@ -31,12 +31,13 @@ const SUPPORTED_TYPES = new Set([
'application/pdf', 'application/pdf',
]); ]);
/** Image-only MIME types for receipt extraction (no PDF) */ /** MIME types for receipt extraction */
const SUPPORTED_IMAGE_TYPES = new Set([ const SUPPORTED_IMAGE_TYPES = new Set([
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/heic', 'image/heic',
'image/heif', 'image/heif',
'application/pdf',
]); ]);
/** /**

View File

@@ -13,6 +13,7 @@
- `src/App.tsx`, `src/main.tsx` — app entry. - `src/App.tsx`, `src/main.tsx` — app entry.
- `src/features/*` — feature pages/components/hooks. - `src/features/*` — feature pages/components/hooks.
- `src/core/*` — auth, api, store, hooks, query config, utils. - `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. - `src/shared-minimal/*` — shared UI components and theme.
## Mobile + Desktop (required) ## Mobile + Desktop (required)

View File

@@ -81,7 +81,7 @@ import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVe
import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types'; import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen'; import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen'; 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 { useNeedsVehicleSelection, useDowngrade } from './features/subscription/hooks/useSubscription';
import { useVehicles } from './features/vehicles/hooks/useVehicles'; import { useVehicles } from './features/vehicles/hooks/useVehicles';
import { VehicleSelectionDialog } from './features/subscription/components/VehicleSelectionDialog'; import { VehicleSelectionDialog } from './features/subscription/components/VehicleSelectionDialog';
@@ -364,6 +364,22 @@ function App() {
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null); const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [showAddVehicle, setShowAddVehicle] = useState(false); 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 // Update mobile mode on window resize
useEffect(() => { useEffect(() => {
const checkMobileMode = () => { const checkMobileMode = () => {

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useLogout } from '../core/auth/useLogout'; 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 HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
@@ -15,7 +15,8 @@ import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import MenuIcon from '@mui/icons-material/Menu'; 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 { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button'; import { Button } from '../shared-minimal/components/Button';
import { NotificationBell } from '../features/notifications'; import { NotificationBell } from '../features/notifications';
@@ -29,7 +30,7 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => { export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user } = useAuth0(); const { user } = useAuth0();
const { logout } = useLogout(); const { logout } = useLogout();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, toggleSidebarCollapse } = useAppStore();
const location = useLocation(); const location = useLocation();
// Sync theme preference with backend // Sync theme preference with backend
@@ -52,6 +53,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
{ name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> }, { name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
]; ];
const sidebarWidth = sidebarCollapsed ? 64 : 256;
// Mobile layout // Mobile layout
if (mobileMode) { if (mobileMode) {
return ( return (
@@ -107,61 +110,65 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
top: 0, top: 0,
left: 0, left: 0,
height: '100vh', height: '100vh',
width: 256, width: sidebarWidth,
zIndex: 1000, zIndex: 1000,
borderRadius: 0, borderRadius: 0,
borderRight: 1, borderRight: 1,
borderColor: 'divider', borderColor: 'divider',
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)', 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', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
overflow: 'hidden',
}} }}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: sidebarCollapsed ? 'center' : 'space-between',
height: 64, height: 64,
px: 2, px: sidebarCollapsed ? 1 : 2,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: 'divider',
gap: 1 gap: 1
}} }}
> >
<Box {!sidebarCollapsed && (
sx={(theme) => ({ <Box
backgroundColor: 'primary.main', sx={(theme) => ({
...theme.applyStyles('dark', { backgroundColor: 'primary.main',
backgroundColor: 'transparent', ...theme.applyStyles('dark', {
}), backgroundColor: 'transparent',
borderRadius: 0.5, }),
px: 1, borderRadius: 0.5,
py: 0.5, px: 1,
display: 'inline-flex', py: 0.5,
alignItems: 'center' display: 'inline-flex',
})} alignItems: 'center',
> overflow: 'hidden',
<img })}
src="/images/logos/motovaultpro-title-slogan.png" >
alt="MotoVaultPro" <img
style={{ height: 24, width: 'auto', maxWidth: 180 }} src="/images/logos/motovaultpro-title-slogan.png"
/> alt="MotoVaultPro"
</Box> style={{ height: 24, width: 'auto', maxWidth: 180 }}
/>
</Box>
)}
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
size="small" size="small"
sx={{ color: 'text.secondary', flexShrink: 0 }} sx={{ color: 'text.secondary', flexShrink: 0 }}
> >
<CloseIcon /> {sidebarCollapsed ? <ChevronRightRoundedIcon /> : <ChevronLeftRoundedIcon />}
</IconButton> </IconButton>
</Box> </Box>
<Box sx={{ mt: 3, px: 2, flex: 1 }}> <Box sx={{ mt: 3, px: sidebarCollapsed ? 1 : 2, flex: 1 }}>
{navigation.map((item) => { {navigation.map((item) => {
const isActive = location.pathname.startsWith(item.href); const isActive = location.pathname.startsWith(item.href);
return ( const navItem = (
<Link <Link
key={item.name} key={item.name}
to={item.href} to={item.href}
@@ -171,7 +178,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
px: 2, justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
px: sidebarCollapsed ? 1 : 2,
py: 1.5, py: 1.5,
mb: 0.5, mb: 0.5,
borderRadius: 2, borderRadius: 2,
@@ -189,52 +197,82 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
} }
}} }}
> >
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}> <Box sx={{ mr: sidebarCollapsed ? 0 : 2, display: 'flex', alignItems: 'center' }}>
{item.icon} {item.icon}
</Box> </Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}> {!sidebarCollapsed && (
{item.name} <Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
</Typography> {item.name}
</Typography>
)}
</Box> </Box>
</Link> </Link>
); );
return sidebarCollapsed ? (
<Tooltip key={item.name} title={item.name} placement="right" arrow>
{navItem}
</Tooltip>
) : (
navItem
);
})} })}
</Box> </Box>
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}> <Box sx={{ p: sidebarCollapsed ? 1 : 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> {sidebarCollapsed ? (
<Avatar <Tooltip title={user?.name || user?.email || 'User'} placement="right" arrow>
sx={{ <Avatar
width: 32, sx={{
height: 32, width: 32,
bgcolor: 'primary.main', height: 32,
fontSize: '0.875rem', bgcolor: 'primary.main',
fontWeight: 600 fontSize: '0.875rem',
}} fontWeight: 600,
> mx: 'auto',
{user?.name?.charAt(0) || user?.email?.charAt(0)} cursor: 'pointer',
</Avatar> }}
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}> onClick={() => logout()}
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap> >
{user?.name || user?.email} {user?.name?.charAt(0) || user?.email?.charAt(0)}
</Typography> </Avatar>
</Box> </Tooltip>
</Box> ) : (
<Button <>
variant="secondary" <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
size="sm" <Avatar
className="w-full" sx={{
onClick={() => logout()} width: 32,
> height: 32,
Sign Out bgcolor: 'primary.main',
</Button> fontSize: '0.875rem',
fontWeight: 600
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{user?.name || user?.email}
</Typography>
</Box>
</Box>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => logout()}
>
Sign Out
</Button>
</>
)}
</Box> </Box>
</Paper> </Paper>
{/* Main content */} {/* Main content */}
<Box <Box
sx={{ sx={{
ml: sidebarOpen ? '256px' : '0', ml: sidebarOpen ? `${sidebarWidth}px` : '0',
transition: 'margin-left 0.2s ease-in-out', transition: 'margin-left 0.2s ease-in-out',
}} }}
> >
@@ -255,7 +293,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
px: 3 px: 3
}}> }}>
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
sx={{ color: 'text.secondary' }} sx={{ color: 'text.secondary' }}
> >
<MenuIcon /> <MenuIcon />
@@ -263,7 +301,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<NotificationBell /> <NotificationBell />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Welcome back, {user?.name || user?.email} Welcome back, {user?.given_name || user?.name?.split(' ')[0] || user?.nickname || user?.email}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@@ -4,21 +4,31 @@ import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface AppState { interface AppState {
// UI state // UI state
sidebarOpen: boolean; sidebarOpen: boolean;
sidebarCollapsed: boolean;
selectedVehicle: Vehicle | null; selectedVehicle: Vehicle | null;
// Actions // Actions
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapse: () => void;
setSelectedVehicle: (vehicle: Vehicle | null) => void; setSelectedVehicle: (vehicle: Vehicle | null) => void;
} }
const savedCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
// Initial state // Initial state
sidebarOpen: false, sidebarOpen: false,
sidebarCollapsed: savedCollapsed,
selectedVehicle: null, selectedVehicle: null,
// Actions // Actions
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }), 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 }), setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
})); }));

View File

@@ -1,5 +1,5 @@
// Export navigation store // Export navigation store
export { useNavigationStore } from './navigation'; export { useNavigationStore, routeToScreen, screenToRoute } from './navigation';
export type { MobileScreen, VehicleSubScreen } from './navigation'; export type { MobileScreen, VehicleSubScreen } from './navigation';
// Export user store // Export user store

View File

@@ -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 MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'Subscription' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit'; export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
/** Maps browser URL paths to mobile screen names for direct URL navigation */
export const routeToScreen: Record<string, MobileScreen> = {
'/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<MobileScreen, string> = {
'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 { interface NavigationHistory {
screen: MobileScreen; screen: MobileScreen;
vehicleSubScreen?: VehicleSubScreen; vehicleSubScreen?: VehicleSubScreen;
@@ -196,7 +235,6 @@ export const useNavigationStore = create<NavigationState>()(
name: 'motovaultpro-mobile-navigation', name: 'motovaultpro-mobile-navigation',
storage: createJSONStorage(() => safeStorage), storage: createJSONStorage(() => safeStorage),
partialize: (state) => ({ partialize: (state) => ({
activeScreen: state.activeScreen,
vehicleSubScreen: state.vehicleSubScreen, vehicleSubScreen: state.vehicleSubScreen,
selectedVehicleId: state.selectedVehicleId, selectedVehicleId: state.selectedVehicleId,
formStates: state.formStates, formStates: state.formStates,

View File

@@ -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(' ') : '';
};

View File

@@ -24,6 +24,7 @@ import {
SubscriptionTier, SubscriptionTier,
ListUsersParams, ListUsersParams,
} from '../types/admin.types'; } from '../types/admin.types';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
// Modal component for dialogs // Modal component for dialogs
interface ModalProps { interface ModalProps {
@@ -128,7 +129,7 @@ const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ aut
<div className="space-y-1"> <div className="space-y-1">
{data.vehicles.map((vehicle, idx) => ( {data.vehicles.map((vehicle, idx) => (
<div key={idx} className="text-sm text-slate-600 dark:text-silverstone bg-slate-50 dark:bg-carbon px-2 py-1 rounded"> <div key={idx} className="text-sm text-slate-600 dark:text-silverstone bg-slate-50 dark:bg-carbon px-2 py-1 rounded">
{vehicle.year} {vehicle.make} {vehicle.model} {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</div> </div>
))} ))}
</div> </div>

View File

@@ -10,7 +10,8 @@ import CloseIcon from '@mui/icons-material/Close';
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
import { QuickActions, QuickActionsSkeleton } from './QuickActions'; 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 { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button'; import { Button } from '../../../shared-minimal/components/Button';
import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner'; import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
@@ -37,6 +38,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
const [showPendingReceipts, setShowPendingReceipts] = useState(false); const [showPendingReceipts, setShowPendingReceipts] = useState(false);
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
const { data: recentActivity } = useRecentActivity();
// Error state // Error state
if (summaryError || attentionError) { if (summaryError || attentionError) {
@@ -72,6 +74,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
<div className="space-y-6"> <div className="space-y-6">
<SummaryCardsSkeleton /> <SummaryCardsSkeleton />
<VehicleAttentionSkeleton /> <VehicleAttentionSkeleton />
<RecentActivitySkeleton />
<QuickActionsSkeleton /> <QuickActionsSkeleton />
</div> </div>
); );
@@ -112,7 +115,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} /> <PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
{/* Summary Cards */} {/* Summary Cards */}
<SummaryCards summary={summary} /> <SummaryCards summary={summary} onNavigate={onNavigate} />
{/* Vehicles Needing Attention */} {/* Vehicles Needing Attention */}
{vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && ( {vehiclesNeedingAttention && vehiclesNeedingAttention.length > 0 && (
@@ -127,6 +130,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
/> />
)} )}
{/* Recent Activity */}
{recentActivity && <RecentActivity items={recentActivity} />}
{/* Quick Actions */} {/* Quick Actions */}
<QuickActions <QuickActions
onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))} onAddVehicle={onAddVehicle ?? (() => onNavigate?.('Vehicles'))}
@@ -135,13 +141,6 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
onViewVehicles={() => onNavigate?.('Vehicles')} onViewVehicles={() => onNavigate?.('Vehicles')}
/> />
{/* Footer Hint */}
<div className="text-center py-4">
<p className="text-xs text-slate-400 dark:text-canna">
Dashboard updates every 2 minutes
</p>
</div>
{/* Pending Receipts Dialog */} {/* Pending Receipts Dialog */}
<Dialog <Dialog
open={showPendingReceipts} open={showPendingReceipts}

View File

@@ -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<RecentActivityProps> = ({ items }) => {
if (items.length === 0) {
return (
<GlassCard padding="md">
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
Recent Activity
</h3>
<p className="text-sm text-slate-400 dark:text-canna text-center py-4">
No recent activity. Start by logging fuel or scheduling maintenance.
</p>
</GlassCard>
);
}
return (
<GlassCard padding="md">
<h3 className="text-base font-semibold text-slate-800 dark:text-avus mb-3">
Recent Activity
</h3>
<div className="space-y-1">
{items.map((item, index) => (
<div
key={`${item.type}-${item.timestamp}-${index}`}
className="flex items-start gap-3 py-2"
>
<Box
sx={{
flexShrink: 0,
width: 32,
height: 32,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
{item.type === 'fuel' ? (
<LocalGasStationRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
) : (
<BuildRoundedIcon sx={{ fontSize: 18, color: 'primary.main' }} />
)}
</Box>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-avus truncate">
{item.vehicleName}
</p>
<p className="text-xs text-slate-500 dark:text-titanio truncate">
{item.description}
</p>
</div>
<span className="text-xs text-slate-400 dark:text-canna whitespace-nowrap flex-shrink-0">
{formatRelativeTime(item.timestamp)}
</span>
</div>
))}
</div>
</GlassCard>
);
};
export const RecentActivitySkeleton: React.FC = () => {
return (
<GlassCard padding="md">
<div className="h-5 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-32 mb-3" />
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-24" />
<div className="h-3 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40" />
</div>
</div>
))}
</div>
</GlassCard>
);
};

View File

@@ -9,18 +9,22 @@ import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { DashboardSummary } from '../types'; import { DashboardSummary } from '../types';
import { MobileScreen } from '../../../core/store';
interface SummaryCardsProps { interface SummaryCardsProps {
summary: DashboardSummary; summary: DashboardSummary;
onNavigate?: (screen: MobileScreen) => void;
} }
export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => { export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary, onNavigate }) => {
const cards = [ const cards = [
{ {
title: 'Total Vehicles', title: 'Total Vehicles',
value: summary.totalVehicles, value: summary.totalVehicles,
icon: DirectionsCarRoundedIcon, icon: DirectionsCarRoundedIcon,
color: 'primary.main', color: 'primary.main',
ctaText: 'Add a vehicle',
ctaScreen: 'Vehicles' as MobileScreen,
}, },
{ {
title: 'Upcoming Maintenance', title: 'Upcoming Maintenance',
@@ -28,6 +32,8 @@ export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
subtitle: 'Next 30 days', subtitle: 'Next 30 days',
icon: BuildRoundedIcon, icon: BuildRoundedIcon,
color: 'primary.main', color: 'primary.main',
ctaText: 'Schedule maintenance',
ctaScreen: 'Maintenance' as MobileScreen,
}, },
{ {
title: 'Recent Fuel Logs', title: 'Recent Fuel Logs',
@@ -35,6 +41,8 @@ export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
subtitle: 'Last 7 days', subtitle: 'Last 7 days',
icon: LocalGasStationRoundedIcon, icon: LocalGasStationRoundedIcon,
color: 'primary.main', color: 'primary.main',
ctaText: 'Log your first fill-up',
ctaScreen: 'Log Fuel' as MobileScreen,
}, },
]; ];
@@ -74,11 +82,29 @@ export const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
> >
{card.value} {card.value}
</Box> </Box>
{card.subtitle && ( {card.value === 0 && card.ctaText ? (
<Box
component="button"
onClick={() => 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}
</Box>
) : card.subtitle ? (
<p className="text-xs text-slate-400 dark:text-canna mt-1"> <p className="text-xs text-slate-400 dark:text-canna mt-1">
{card.subtitle} {card.subtitle}
</p> </p>
)} ) : null}
</div> </div>
</div> </div>
</GlassCard> </GlassCard>

View File

@@ -9,6 +9,7 @@ import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded'; import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
import { VehicleNeedingAttention } from '../types'; import { VehicleNeedingAttention } from '../types';
interface VehicleAttentionProps { interface VehicleAttentionProps {
@@ -104,7 +105,7 @@ export const VehicleAttention: React.FC<VehicleAttentionProps> = ({ vehicles, on
mb: 0.5, mb: 0.5,
}} }}
> >
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`} {getVehicleLabel(vehicle)}
</Box> </Box>
<p className="text-sm text-slate-600 dark:text-titanio"> <p className="text-sm text-slate-600 dark:text-titanio">
{vehicle.reason} {vehicle.reason}

View File

@@ -8,8 +8,9 @@ import { useAuth0 } from '@auth0/auth0-react';
import { vehiclesApi } from '../../vehicles/api/vehicles.api'; import { vehiclesApi } from '../../vehicles/api/vehicles.api';
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { maintenanceApi } from '../../maintenance/api/maintenance.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 { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
/** /**
* Combined dashboard data structure * Combined dashboard data structure
@@ -17,6 +18,7 @@ import { MaintenanceSchedule } from '../../maintenance/types/maintenance.types';
interface DashboardData { interface DashboardData {
summary: DashboardSummary; summary: DashboardSummary;
vehiclesNeedingAttention: VehicleNeedingAttention[]; vehiclesNeedingAttention: VehicleNeedingAttention[];
recentActivity: RecentActivityItem[];
} }
/** /**
@@ -115,7 +117,30 @@ export const useDashboardData = () => {
const priorityOrder = { high: 0, medium: 1, low: 2 }; const priorityOrder = { high: 0, medium: 1, low: 2 };
vehiclesNeedingAttention.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); 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, enabled: isAuthenticated && !authLoading,
staleTime: 2 * 60 * 1000, // 2 minutes 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) * Hook to fetch vehicles needing attention (overdue maintenance)
* Derives from unified dashboard data query * Derives from unified dashboard data query

View File

@@ -7,5 +7,6 @@ export { DashboardPage } from './pages/DashboardPage';
export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards'; export { SummaryCards, SummaryCardsSkeleton } from './components/SummaryCards';
export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention'; export { VehicleAttention, VehicleAttentionSkeleton } from './components/VehicleAttention';
export { QuickActions, QuickActionsSkeleton } from './components/QuickActions'; export { QuickActions, QuickActionsSkeleton } from './components/QuickActions';
export { useDashboardSummary, useVehiclesNeedingAttention } from './hooks/useDashboardData'; export { RecentActivity, RecentActivitySkeleton } from './components/RecentActivity';
export type { DashboardSummary, VehicleNeedingAttention, DashboardData } from './types'; export { useDashboardSummary, useVehiclesNeedingAttention, useRecentActivity } from './hooks/useDashboardData';
export type { DashboardSummary, VehicleNeedingAttention, RecentActivityItem, DashboardData } from './types';

View File

@@ -15,7 +15,16 @@ export interface VehicleNeedingAttention extends Vehicle {
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
} }
export interface RecentActivityItem {
type: 'fuel' | 'maintenance';
vehicleId: string;
vehicleName: string;
description: string;
timestamp: string;
}
export interface DashboardData { export interface DashboardData {
summary: DashboardSummary; summary: DashboardSummary;
vehiclesNeedingAttention: VehicleNeedingAttention[]; vehiclesNeedingAttention: VehicleNeedingAttention[];
recentActivity: RecentActivityItem[];
} }

View File

@@ -12,7 +12,6 @@ Document management UI with maintenance manual extraction. Handles file uploads,
| `mobile/` | Mobile-specific document layout | Mobile UI | | `mobile/` | Mobile-specific document layout | Mobile UI |
| `pages/` | DocumentsPage, DocumentDetailPage | Page layout | | `pages/` | DocumentsPage, DocumentDetailPage | Page layout |
| `types/` | TypeScript type definitions | Type changes | | `types/` | TypeScript type definitions | Type changes |
| `utils/` | Utility functions (vehicle label formatting) | Helper logic |
## Key Files ## Key Files

View File

@@ -31,8 +31,8 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
onSuccess, onSuccess,
onCancel onCancel
}) => { }) => {
const [documentType, setDocumentType] = React.useState<DocumentType>( const [documentType, setDocumentType] = React.useState<DocumentType | ''>(
initialValues?.documentType || 'insurance' initialValues?.documentType || ''
); );
const [vehicleID, setVehicleID] = React.useState<string>(initialValues?.vehicleId || ''); const [vehicleID, setVehicleID] = React.useState<string>(initialValues?.vehicleId || '');
const [title, setTitle] = React.useState<string>(initialValues?.title || ''); const [title, setTitle] = React.useState<string>(initialValues?.title || '');
@@ -152,6 +152,10 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
setError('Please select a vehicle.'); setError('Please select a vehicle.');
return; return;
} }
if (!documentType) {
setError('Please select a document type.');
return;
}
if (!title.trim()) { if (!title.trim()) {
setError('Please enter a title.'); setError('Please enter a title.');
return; return;
@@ -337,7 +341,9 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
value={documentType} value={documentType}
onChange={(e) => setDocumentType(e.target.value as DocumentType)} onChange={(e) => setDocumentType(e.target.value as DocumentType)}
disabled={mode === 'edit'} disabled={mode === 'edit'}
required
> >
<option value="" disabled>Select a document type...</option>
<option value="insurance">Insurance</option> <option value="insurance">Insurance</option>
<option value="registration">Registration</option> <option value="registration">Registration</option>
<option value="manual">Manual</option> <option value="manual">Manual</option>

View File

@@ -10,7 +10,14 @@ import { AddDocumentDialog } from '../components/AddDocumentDialog';
import { ExpirationBadge } from '../components/ExpirationBadge'; import { ExpirationBadge } from '../components/ExpirationBadge';
import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; 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 = () => { export const DocumentsMobileScreen: React.FC = () => {
console.log('[DocumentsMobileScreen] Component initializing'); 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 vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const getFileTypeIcon = (contentType: string | null | undefined) => {
if (!contentType) return <InsertDriveFileRoundedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />;
if (contentType === 'application/pdf') return <PictureAsPdfRoundedIcon sx={{ fontSize: 14, color: 'error.main' }} />;
if (contentType.startsWith('image/')) return <ImageRoundedIcon sx={{ fontSize: 14, color: 'info.main' }} />;
return <InsertDriveFileRoundedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />;
};
const triggerUpload = (docId: string) => { const triggerUpload = (docId: string) => {
try { try {
setCurrentId(docId); setCurrentId(docId);
@@ -187,9 +201,13 @@ export const DocumentsMobileScreen: React.FC = () => {
<span className="font-medium text-slate-800 dark:text-avus">{doc.title}</span> <span className="font-medium text-slate-800 dark:text-avus">{doc.title}</span>
<ExpirationBadge expirationDate={doc.expirationDate} /> <ExpirationBadge expirationDate={doc.expirationDate} />
</div> </div>
<div className="text-xs text-slate-500 dark:text-titanio"> <div className="text-xs text-slate-500 dark:text-titanio flex items-center gap-1">
{doc.documentType} {getFileTypeIcon(doc.contentType)}
{isShared && ' • Shared'} <span>
{doc.documentType}
{doc.createdAt && ` \u00B7 ${dayjs(doc.createdAt).fromNow()}`}
{isShared && ' \u00B7 Shared'}
</span>
</div> </div>
<DocumentCardMetadata doc={doc} variant="mobile" /> <DocumentCardMetadata doc={doc} variant="mobile" />
<button <button

View File

@@ -12,7 +12,7 @@ import { EditDocumentDialog } from '../components/EditDocumentDialog';
import { ExpirationBadge } from '../components/ExpirationBadge'; import { ExpirationBadge } from '../components/ExpirationBadge';
import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata';
import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '../utils/vehicleLabel'; import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
export const DocumentDetailPage: React.FC = () => { export const DocumentDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();

View File

@@ -14,14 +14,21 @@ import {
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { AddDocumentDialog } from '../components/AddDocumentDialog'; import { AddDocumentDialog } from '../components/AddDocumentDialog';
import { EditDocumentDialog } from '../components/EditDocumentDialog'; import { EditDocumentDialog } from '../components/EditDocumentDialog';
import { ExpirationBadge } from '../components/ExpirationBadge'; import { ExpirationBadge } from '../components/ExpirationBadge';
import type { DocumentRecord } from '../types/documents.types'; import type { DocumentRecord } from '../types/documents.types';
import { DocumentCardMetadata } from '../components/DocumentCardMetadata'; import { DocumentCardMetadata } from '../components/DocumentCardMetadata';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleLabel } from '../utils/vehicleLabel'; import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
export const DocumentsPage: React.FC = () => { export const DocumentsPage: React.FC = () => {
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0(); const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
@@ -36,6 +43,13 @@ export const DocumentsPage: React.FC = () => {
const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]);
const getFileTypeIcon = (contentType: string | null | undefined) => {
if (!contentType) return <InsertDriveFileRoundedIcon fontSize="small" sx={{ color: 'text.secondary' }} />;
if (contentType === 'application/pdf') return <PictureAsPdfRoundedIcon fontSize="small" sx={{ color: 'error.main' }} />;
if (contentType.startsWith('image/')) return <ImageRoundedIcon fontSize="small" sx={{ color: 'info.main' }} />;
return <InsertDriveFileRoundedIcon fontSize="small" sx={{ color: 'text.secondary' }} />;
};
// Show loading while auth is initializing // Show loading while auth is initializing
if (authLoading) { if (authLoading) {
return ( return (
@@ -186,7 +200,12 @@ export const DocumentsPage: React.FC = () => {
<Typography variant="subtitle1" fontWeight={500}>{doc.title}</Typography> <Typography variant="subtitle1" fontWeight={500}>{doc.title}</Typography>
<ExpirationBadge expirationDate={doc.expirationDate} /> <ExpirationBadge expirationDate={doc.expirationDate} />
</Box> </Box>
<Typography variant="body2" color="text.secondary">Type: {doc.documentType}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{getFileTypeIcon(doc.contentType)}
<Typography variant="body2" color="text.secondary">
{doc.documentType} {doc.createdAt && `\u00B7 Uploaded ${dayjs(doc.createdAt).fromNow()}`}
</Typography>
</Box>
<DocumentCardMetadata doc={doc} variant="card" /> <DocumentCardMetadata doc={doc} variant="card" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" color="text.secondary">Vehicle:</Typography> <Typography variant="body2" color="text.secondary">Vehicle:</Typography>

View File

@@ -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) + '...';
};

View File

@@ -23,6 +23,7 @@ import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { useResolveAssociation } from '../hooks/usePendingAssociations'; import { useResolveAssociation } from '../hooks/usePendingAssociations';
import type { PendingVehicleAssociation } from '../types/email-ingestion.types'; import type { PendingVehicleAssociation } from '../types/email-ingestion.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface ResolveAssociationDialogProps { interface ResolveAssociationDialogProps {
open: boolean; open: boolean;
@@ -166,9 +167,7 @@ export const ResolveAssociationDialog: React.FC<ResolveAssociationDialogProps> =
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{vehicles.map((vehicle) => { {vehicles.map((vehicle) => {
const isSelected = selectedVehicleId === vehicle.id; const isSelected = selectedVehicleId === vehicle.id;
const vehicleName = vehicle.nickname const vehicleName = getVehicleLabel(vehicle);
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'Unnamed Vehicle';
return ( return (
<Box <Box

View File

@@ -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<AddFuelLogDialogProps> = ({ open, onClose }) => {
const isSmallScreen = useMediaQuery('(max-width:600px)');
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
fullScreen={isSmallScreen}
PaperProps={{
sx: { maxHeight: isSmallScreen ? '100%' : '90vh' },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Log Fuel
<IconButton
aria-label="close"
onClick={onClose}
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: { xs: 1, sm: 2 } }}>
<FuelLogForm onSuccess={onClose} />
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,12 @@
import React, { useState } from 'react'; 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 { useQueryClient } from '@tanstack/react-query';
import { FuelLogForm } from '../components/FuelLogForm';
import { FuelLogsList } from '../components/FuelLogsList'; import { FuelLogsList } from '../components/FuelLogsList';
import { FuelLogEditDialog } from '../components/FuelLogEditDialog'; import { FuelLogEditDialog } from '../components/FuelLogEditDialog';
import { AddFuelLogDialog } from '../components/AddFuelLogDialog';
import { useFuelLogs } from '../hooks/useFuelLogs'; import { useFuelLogs } from '../hooks/useFuelLogs';
import { FuelStatsCard } from '../components/FuelStatsCard'; import { FuelStatsCard } from '../components/FuelStatsCard';
import { FormSuspense } from '../../../components/SuspenseWrappers';
import { FuelLogResponse, UpdateFuelLogRequest } from '../types/fuel-logs.types'; import { FuelLogResponse, UpdateFuelLogRequest } from '../types/fuel-logs.types';
import { fuelLogsApi } from '../api/fuel-logs.api'; import { fuelLogsApi } from '../api/fuel-logs.api';
@@ -14,9 +14,7 @@ export const FuelLogsPage: React.FC = () => {
const { fuelLogs, isLoading, error } = useFuelLogs(); const { fuelLogs, isLoading, error } = useFuelLogs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null); const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
// DEBUG: Log page renders
console.log('[FuelLogsPage] Render - fuel logs count:', fuelLogs?.length, 'isLoading:', isLoading, 'error:', !!error);
const handleEdit = (log: FuelLogResponse) => { const handleEdit = (log: FuelLogResponse) => {
setEditingLog(log); setEditingLog(log);
@@ -24,9 +22,6 @@ export const FuelLogsPage: React.FC = () => {
const handleDelete = async (_logId: string) => { const handleDelete = async (_logId: string) => {
try { 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'] }); queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] });
} catch (error) { } catch (error) {
console.error('Failed to refresh fuel logs after delete:', 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) => { const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => {
try { try {
console.log('[FuelLogsPage] handleSaveEdit called - using targeted query updates');
await fuelLogsApi.update(id, data); 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'] }); queryClient.refetchQueries({ queryKey: ['fuelLogs', 'all'] });
setEditingLog(null); setEditingLog(null);
} catch (error) { } catch (error) {
console.error('Failed to update fuel log:', 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 ( return (
<FormSuspense> <Box>
<Grid container spacing={2}> {/* Header with Add button */}
<Grid item xs={12} md={6}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<FuelLogForm /> <Typography variant="h5" sx={{ fontWeight: 600 }}>Fuel Logs</Typography>
</Grid> <MuiButton
<Grid item xs={12} md={6}> variant="contained"
<Typography variant="h6" gutterBottom>Summary</Typography> startIcon={<AddIcon />}
<FuelStatsCard logs={fuelLogs} /> onClick={() => setShowAddDialog(true)}
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Recent Fuel Logs</Typography> >
<FuelLogsList Add Fuel Log
logs={fuelLogs} </MuiButton>
onEdit={handleEdit} </Box>
onDelete={handleDelete}
/> {/* Summary Stats */}
</Grid> <Box sx={{ mb: 3 }}>
</Grid> <FuelStatsCard logs={fuelLogs} />
</Box>
{/* Fuel Logs List */}
<FuelLogsList
logs={fuelLogs}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* Add Dialog */}
<AddFuelLogDialog
open={showAddDialog}
onClose={() => setShowAddDialog(false)}
/>
{/* Edit Dialog */} {/* Edit Dialog */}
<FuelLogEditDialog <FuelLogEditDialog
@@ -102,6 +108,6 @@ export const FuelLogsPage: React.FC = () => {
onClose={handleCloseEdit} onClose={handleCloseEdit}
onSave={handleSaveEdit} onSave={handleSaveEdit}
/> />
</FormSuspense> </Box>
); );
}; };

View File

@@ -0,0 +1,278 @@
/**
* @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<AddReceiptDialogProps> = ({
open,
onClose,
onFileSelect,
onStartCamera,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(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')
);
}
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, PDF';
}
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<HTMLInputElement>) => {
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 (
<Dialog
open={open}
onClose={handleClose}
fullScreen
PaperProps={{
sx: { backgroundColor: 'background.default' },
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper',
}}
>
<Typography variant="h6">Add Receipt</Typography>
<IconButton
onClick={handleClose}
aria-label="Close"
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</Box>
{/* Content */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
gap: 3,
}}
>
{/* Drag-and-drop upload zone */}
<Box
onClick={handleClickUpload}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
width: '100%',
maxWidth: 480,
py: 5,
px: 3,
border: 2,
borderStyle: 'dashed',
borderColor: isDragging ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragging ? 'action.hover' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
backgroundColor: 'action.hover',
},
}}
>
<CloudUploadIcon
sx={{
fontSize: 56,
color: isDragging ? 'primary.main' : 'text.secondary',
}}
/>
<Typography
variant="body1"
color={isDragging ? 'primary.main' : 'text.primary'}
textAlign="center"
fontWeight={500}
>
{isDragging ? 'Drop file here' : 'Drag and drop an image or PDF, or tap to browse'}
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
JPEG, PNG, HEIC, PDF -- up to 10MB
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ maxWidth: 480, width: '100%' }}>
{error}
</Alert>
)}
{/* Divider with "or" */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
maxWidth: 480,
gap: 2,
}}
>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
<Typography variant="body2" color="text.secondary">
or
</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
</Box>
{/* Take Photo button */}
<Button
variant="outlined"
startIcon={<CameraAltIcon />}
onClick={onStartCamera}
sx={{
minHeight: 56,
minWidth: 200,
maxWidth: 480,
width: '100%',
borderRadius: 2,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
Take Photo of Receipt
</Button>
</Box>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept={DEFAULT_ACCEPTED_FORMATS.join(',')}
onChange={handleInputChange}
style={{ display: 'none' }}
aria-label="Select receipt file"
/>
</Dialog>
);
};

View File

@@ -36,6 +36,7 @@ import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { documentsApi } from '../../documents/api/documents.api'; import { documentsApi } from '../../documents/api/documents.api';
import type { Vehicle } from '../../vehicles/types/vehicles.types'; import type { Vehicle } from '../../vehicles/types/vehicles.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface MaintenanceRecordEditDialogProps { interface MaintenanceRecordEditDialogProps {
open: boolean; open: boolean;
@@ -218,10 +219,7 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
disabled disabled
value={(() => { value={(() => {
const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicleId); const vehicle = vehicles?.find((v: Vehicle) => v.id === record.vehicleId);
if (!vehicle) return 'Unknown Vehicle'; return getVehicleLabel(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';
})()} })()}
helperText="Vehicle cannot be changed when editing" helperText="Vehicle cannot be changed when editing"
/> />

View File

@@ -40,12 +40,13 @@ import {
} from '../types/maintenance.types'; } from '../types/maintenance.types';
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr'; import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal'; import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton'; import { AddReceiptDialog } from './AddReceiptDialog';
import { CameraCapture } from '../../../shared/components/CameraCapture'; import { CameraCapture } from '../../../shared/components/CameraCapture';
import { useTierAccess } from '../../../core/hooks/useTierAccess'; import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
import { documentsApi } from '../../documents/api/documents.api'; import { documentsApi } from '../../documents/api/documents.api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
const schema = z.object({ const schema = z.object({
vehicle_id: z.string().uuid({ message: 'Please select a vehicle' }), vehicle_id: z.string().uuid({ message: 'Please select a vehicle' }),
@@ -62,7 +63,11 @@ const schema = z.object({
type FormData = z.infer<typeof schema>; type FormData = z.infer<typeof schema>;
export const MaintenanceRecordForm: React.FC = () => { interface MaintenanceRecordFormProps {
vehicleId?: string;
}
export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ vehicleId }) => {
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
const { createRecord, isRecordMutating } = useMaintenanceRecords(); const { createRecord, isRecordMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null); const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
@@ -87,6 +92,9 @@ export const MaintenanceRecordForm: React.FC = () => {
updateField, updateField,
} = useMaintenanceReceiptOcr(); } = useMaintenanceReceiptOcr();
// AddReceiptDialog visibility state
const [showAddReceiptDialog, setShowAddReceiptDialog] = useState(false);
// Store captured file for document upload on submit // Store captured file for document upload on submit
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null); const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
@@ -101,7 +109,7 @@ export const MaintenanceRecordForm: React.FC = () => {
resolver: zodResolver(schema), resolver: zodResolver(schema),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
vehicle_id: '', vehicle_id: vehicleId || '',
category: undefined as any, category: undefined as any,
subtypes: [], subtypes: [],
date: new Date().toISOString().split('T')[0], 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 // Watch category changes to reset subtypes
const watchedCategory = watch('category'); const watchedCategory = watch('category');
useEffect(() => { useEffect(() => {
@@ -235,7 +248,7 @@ export const MaintenanceRecordForm: React.FC = () => {
<Card> <Card>
<CardHeader title="Add Maintenance Record" /> <CardHeader title="Add Maintenance Record" />
<CardContent> <CardContent>
{/* Receipt Scan Button */} {/* Add Receipt Button */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -246,53 +259,62 @@ export const MaintenanceRecordForm: React.FC = () => {
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
<ReceiptCameraButton <Button
variant="outlined"
onClick={() => { onClick={() => {
if (!hasReceiptScanAccess) { if (!hasReceiptScanAccess) {
setShowUpgradeDialog(true); setShowUpgradeDialog(true);
return; return;
} }
startCapture(); setShowAddReceiptDialog(true);
}} }}
disabled={isProcessing || isRecordMutating} disabled={isProcessing || isRecordMutating}
variant="button" sx={{
locked={!hasReceiptScanAccess} minHeight: 44,
/> borderStyle: 'dashed',
borderWidth: 2,
'&:hover': { borderWidth: 2 },
}}
>
Add Receipt
</Button>
</Box> </Box>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Vehicle Selection */} {/* Vehicle Selection (hidden when vehicleId prop is provided) */}
<Grid item xs={12}> {!vehicleId && (
<Controller <Grid item xs={12}>
name="vehicle_id" <Controller
control={control} name="vehicle_id"
render={({ field }) => ( control={control}
<FormControl fullWidth error={!!errors.vehicle_id}> render={({ field }) => (
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel> <FormControl fullWidth error={!!errors.vehicle_id}>
<Select <InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
{...field} <Select
labelId="vehicle-select-label" {...field}
label="Vehicle *" labelId="vehicle-select-label"
sx={{ minHeight: 56 }} label="Vehicle *"
> sx={{ minHeight: 56 }}
{vehicles && vehicles.length > 0 ? ( >
vehicles.map((vehicle) => ( {vehicles && vehicles.length > 0 ? (
<MenuItem key={vehicle.id} value={vehicle.id}> vehicles.map((vehicle) => (
{vehicle.year} {vehicle.make} {vehicle.model} <MenuItem key={vehicle.id} value={vehicle.id}>
</MenuItem> {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
)) </MenuItem>
) : ( ))
<MenuItem disabled>No vehicles available</MenuItem> ) : (
<MenuItem disabled>No vehicles available</MenuItem>
)}
</Select>
{errors.vehicle_id && (
<FormHelperText>{errors.vehicle_id.message}</FormHelperText>
)} )}
</Select> </FormControl>
{errors.vehicle_id && ( )}
<FormHelperText>{errors.vehicle_id.message}</FormHelperText> />
)} </Grid>
</FormControl> )}
)}
/>
</Grid>
{/* Category Selection */} {/* Category Selection */}
<Grid item xs={12}> <Grid item xs={12}>
@@ -495,6 +517,20 @@ export const MaintenanceRecordForm: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
{/* Add Receipt Dialog */}
<AddReceiptDialog
open={showAddReceiptDialog}
onClose={() => setShowAddReceiptDialog(false)}
onFileSelect={(file) => {
setShowAddReceiptDialog(false);
handleCaptureImage(file);
}}
onStartCamera={() => {
setShowAddReceiptDialog(false);
startCapture();
}}
/>
{/* Camera Capture Modal */} {/* Camera Capture Modal */}
<Dialog <Dialog
open={isCapturing} open={isCapturing}

View File

@@ -39,6 +39,7 @@ import {
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup'; import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import type { Vehicle } from '../../vehicles/types/vehicles.types'; import type { Vehicle } from '../../vehicles/types/vehicles.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface MaintenanceScheduleEditDialogProps { interface MaintenanceScheduleEditDialogProps {
open: boolean; open: boolean;
@@ -206,10 +207,7 @@ export const MaintenanceScheduleEditDialog: React.FC<MaintenanceScheduleEditDial
disabled disabled
value={(() => { value={(() => {
const vehicle = vehicles?.find((v: Vehicle) => v.id === schedule.vehicleId); const vehicle = vehicles?.find((v: Vehicle) => v.id === schedule.vehicleId);
if (!vehicle) return 'Unknown Vehicle'; return getVehicleLabel(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';
})()} })()}
helperText="Vehicle cannot be changed when editing" helperText="Vehicle cannot be changed when editing"
/> />

View File

@@ -42,6 +42,7 @@ import {
getCategoryDisplayName, getCategoryDisplayName,
} from '../types/maintenance.types'; } from '../types/maintenance.types';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
const schema = z const schema = z
.object({ .object({
@@ -97,7 +98,11 @@ const REMINDER_OPTIONS = [
{ value: '60', label: '60 days' }, { value: '60', label: '60 days' },
]; ];
export const MaintenanceScheduleForm: React.FC = () => { interface MaintenanceScheduleFormProps {
vehicleId?: string;
}
export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = ({ vehicleId }) => {
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
const { createSchedule, isScheduleMutating } = useMaintenanceRecords(); const { createSchedule, isScheduleMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null); const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
@@ -113,7 +118,7 @@ export const MaintenanceScheduleForm: React.FC = () => {
resolver: zodResolver(schema), resolver: zodResolver(schema),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
vehicle_id: '', vehicle_id: vehicleId || '',
category: undefined as any, category: undefined as any,
subtypes: [], subtypes: [],
schedule_type: 'interval' as ScheduleType, 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 // Watch category and schedule type changes
const watchedCategory = watch('category'); const watchedCategory = watch('category');
const watchedScheduleType = watch('schedule_type'); const watchedScheduleType = watch('schedule_type');
@@ -197,30 +207,31 @@ export const MaintenanceScheduleForm: React.FC = () => {
<CardContent> <CardContent>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Vehicle Selection */} {/* Vehicle Selection (hidden when vehicleId prop is provided) */}
<Grid item xs={12}> {!vehicleId && (
<Controller <Grid item xs={12}>
name="vehicle_id" <Controller
control={control} name="vehicle_id"
render={({ field }) => ( control={control}
<FormControl fullWidth error={!!errors.vehicle_id}> render={({ field }) => (
<InputLabel id="vehicle-select-label">Vehicle *</InputLabel> <FormControl fullWidth error={!!errors.vehicle_id}>
<Select <InputLabel id="vehicle-select-label">Vehicle *</InputLabel>
{...field} <Select
labelId="vehicle-select-label" {...field}
label="Vehicle *" labelId="vehicle-select-label"
sx={{ minHeight: 56 }} label="Vehicle *"
> sx={{ minHeight: 56 }}
{vehicles && vehicles.length > 0 ? ( >
vehicles.map((vehicle) => ( {vehicles && vehicles.length > 0 ? (
<MenuItem key={vehicle.id} value={vehicle.id}> vehicles.map((vehicle) => (
{vehicle.year} {vehicle.make} {vehicle.model} <MenuItem key={vehicle.id} value={vehicle.id}>
</MenuItem> {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
)) </MenuItem>
) : ( ))
<MenuItem disabled>No vehicles available</MenuItem> ) : (
)} <MenuItem disabled>No vehicles available</MenuItem>
</Select> )}
</Select>
{errors.vehicle_id && ( {errors.vehicle_id && (
<FormHelperText>{errors.vehicle_id.message}</FormHelperText> <FormHelperText>{errors.vehicle_id.message}</FormHelperText>
)} )}
@@ -228,6 +239,7 @@ export const MaintenanceScheduleForm: React.FC = () => {
)} )}
/> />
</Grid> </Grid>
)}
{/* Category Selection */} {/* Category Selection */}
<Grid item xs={12}> <Grid item xs={12}>

View File

@@ -191,9 +191,11 @@ export const MaintenanceSchedulesList: React.FC<MaintenanceSchedulesListProps> =
}} }}
> >
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
<Typography variant="h6"> <Typography variant="h6">
{categoryDisplay} {schedule.subtypes && schedule.subtypes.length > 0
? `${schedule.subtypes.join(', ')} \u2014 ${categoryDisplay}`
: categoryDisplay}
</Typography> </Typography>
<Chip <Chip
label={status.label} label={status.label}
@@ -295,7 +297,9 @@ export const MaintenanceSchedulesList: React.FC<MaintenanceSchedulesListProps> =
</Typography> </Typography>
{scheduleToDelete && ( {scheduleToDelete && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)} {scheduleToDelete.subtypes && scheduleToDelete.subtypes.length > 0
? `${scheduleToDelete.subtypes.join(', ')} \u2014 ${getCategoryDisplayName(scheduleToDelete.category)}`
: getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)}
</Typography> </Typography>
)} )}
</DialogContent> </DialogContent>

View File

@@ -235,7 +235,9 @@ export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn {
setResult(null); setResult(null);
const imageToProcess = croppedFile || file; 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); setReceiptImageUrl(imageUrl);
try { try {
@@ -255,7 +257,7 @@ export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn {
console.error('Maintenance receipt OCR processing failed:', err); console.error('Maintenance receipt OCR processing failed:', err);
const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image'; const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image';
setError(message); setError(message);
URL.revokeObjectURL(imageUrl); if (imageUrl) URL.revokeObjectURL(imageUrl);
setReceiptImageUrl(null); setReceiptImageUrl(null);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);

View File

@@ -16,6 +16,7 @@ import { MaintenanceScheduleForm } from '../components/MaintenanceScheduleForm';
import { MaintenanceSchedulesList } from '../components/MaintenanceSchedulesList'; import { MaintenanceSchedulesList } from '../components/MaintenanceSchedulesList';
import { MaintenanceScheduleEditDialog } from '../components/MaintenanceScheduleEditDialog'; import { MaintenanceScheduleEditDialog } from '../components/MaintenanceScheduleEditDialog';
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types'; import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
export const MaintenanceMobileScreen: React.FC = () => { export const MaintenanceMobileScreen: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -125,7 +126,7 @@ export const MaintenanceMobileScreen: React.FC = () => {
{vehicles && vehicles.length > 0 ? ( {vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => ( vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id} sx={{ minHeight: 44 }}> <MenuItem key={vehicle.id} value={vehicle.id} sx={{ minHeight: 44 }}>
{vehicle.year} {vehicle.make} {vehicle.model} {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem> </MenuItem>
)) ))
) : ( ) : (
@@ -198,9 +199,9 @@ export const MaintenanceMobileScreen: React.FC = () => {
{activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'} {activeTab === 'records' ? 'New Maintenance Record' : 'New Maintenance Schedule'}
</h3> </h3>
{activeTab === 'records' ? ( {activeTab === 'records' ? (
<MaintenanceRecordForm /> <MaintenanceRecordForm vehicleId={selectedVehicleId} />
) : ( ) : (
<MaintenanceScheduleForm /> <MaintenanceScheduleForm vehicleId={selectedVehicleId} />
)} )}
</div> </div>
</GlassCard> </GlassCard>

View File

@@ -16,6 +16,7 @@ import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { FormSuspense } from '../../../components/SuspenseWrappers'; import { FormSuspense } from '../../../components/SuspenseWrappers';
import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types'; import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
export const MaintenancePage: React.FC = () => { export const MaintenancePage: React.FC = () => {
const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles();
@@ -141,6 +142,9 @@ export const MaintenancePage: React.FC = () => {
return ( return (
<FormSuspense> <FormSuspense>
{/* Page Title */}
<Typography variant="h5" sx={{ fontWeight: 600, mb: 3 }}>Maintenance</Typography>
{/* Vehicle Selector */} {/* Vehicle Selector */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<FormControl fullWidth> <FormControl fullWidth>
@@ -156,7 +160,7 @@ export const MaintenancePage: React.FC = () => {
{vehicles && vehicles.length > 0 ? ( {vehicles && vehicles.length > 0 ? (
vehicles.map((vehicle) => ( vehicles.map((vehicle) => (
<MenuItem key={vehicle.id} value={vehicle.id}> <MenuItem key={vehicle.id} value={vehicle.id}>
{vehicle.year} {vehicle.make} {vehicle.model} {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</MenuItem> </MenuItem>
)) ))
) : ( ) : (
@@ -181,7 +185,7 @@ export const MaintenancePage: React.FC = () => {
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Top: Form */} {/* Top: Form */}
<Grid item xs={12}> <Grid item xs={12}>
<MaintenanceRecordForm /> <MaintenanceRecordForm vehicleId={selectedVehicleId} />
</Grid> </Grid>
{/* Bottom: Records List */} {/* Bottom: Records List */}
@@ -202,7 +206,7 @@ export const MaintenancePage: React.FC = () => {
<Grid container spacing={3}> <Grid container spacing={3}>
{/* Top: Form */} {/* Top: Form */}
<Grid item xs={12}> <Grid item xs={12}>
<MaintenanceScheduleForm /> <MaintenanceScheduleForm vehicleId={selectedVehicleId} />
</Grid> </Grid>
{/* Bottom: Schedules List */} {/* Bottom: Schedules List */}

View File

@@ -130,8 +130,12 @@ export const NotificationBell: React.FC = () => {
<CircularProgress size={24} /> <CircularProgress size={24} />
</Box> </Box>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}> <Box sx={{ p: 4, textAlign: 'center' }}>
<Typography color="text.secondary">No notifications</Typography> <NotificationsIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary" fontWeight={500}>No notifications</Typography>
<Typography variant="caption" color="text.disabled">
You're all caught up
</Typography>
</Box> </Box>
) : ( ) : (
<List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}> <List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}>

View File

@@ -7,6 +7,7 @@ import { useSettings } from '../hooks/useSettings';
import { useProfile, useUpdateProfile } from '../hooks/useProfile'; import { useProfile, useUpdateProfile } from '../hooks/useProfile';
import { useExportUserData } from '../hooks/useExportUserData'; import { useExportUserData } from '../hooks/useExportUserData';
import { useVehicles } from '../../vehicles/hooks/useVehicles'; import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
import { useSubscription } from '../../subscription/hooks/useSubscription'; import { useSubscription } from '../../subscription/hooks/useSubscription';
import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { useNavigationStore } from '../../../core/store'; 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" className="p-3 bg-slate-50 dark:bg-nero rounded-lg"
> >
<p className="font-medium text-slate-800 dark:text-avus"> <p className="font-medium text-slate-800 dark:text-avus">
{vehicle.year} {vehicle.make} {vehicle.model} {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
</p> </p>
{vehicle.nickname && ( {vehicle.nickname && (
<p className="text-sm text-slate-500 dark:text-titanio">{vehicle.nickname}</p> <p className="text-sm text-slate-500 dark:text-titanio">{vehicle.nickname}</p>

View File

@@ -13,6 +13,7 @@ import {
Box, Box,
} from '@mui/material'; } from '@mui/material';
import type { SubscriptionTier } from '../types/subscription.types'; import type { SubscriptionTier } from '../types/subscription.types';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface Vehicle { interface Vehicle {
id: string; id: string;
@@ -70,13 +71,6 @@ export const VehicleSelectionDialog = ({
onConfirm(selectedVehicleIds); 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; const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections;

View File

@@ -9,6 +9,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types'; import { Vehicle } from '../types/vehicles.types';
import { useUnits } from '../../../core/units/UnitsContext'; import { useUnits } from '../../../core/units/UnitsContext';
import { VehicleImage } from './VehicleImage'; import { VehicleImage } from './VehicleImage';
import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
interface VehicleCardProps { interface VehicleCardProps {
vehicle: Vehicle; vehicle: Vehicle;
@@ -24,8 +25,8 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
onSelect, onSelect,
}) => { }) => {
const { formatDistance } = useUnits(); const { formatDistance } = useUnits();
const displayName = vehicle.nickname || const displayName = getVehicleLabel(vehicle);
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' '); const subtitle = getVehicleSubtitle(vehicle);
return ( return (
<Card <Card
@@ -46,13 +47,21 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
<CardContent> <CardContent>
<VehicleImage vehicle={vehicle} height={96} /> <VehicleImage vehicle={vehicle} height={96} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}> <Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{displayName} {displayName}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}> {subtitle && (
VIN: {vehicle.vin} <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
</Typography> {subtitle}
</Typography>
)}
{vehicle.vin && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
VIN: {vehicle.vin}
</Typography>
)}
{vehicle.licensePlate && ( {vehicle.licensePlate && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>

View File

@@ -12,6 +12,7 @@ import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog'
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { VehicleImage } from '../components/VehicleImage'; import { VehicleImage } from '../components/VehicleImage';
import { OwnershipCostsList } from '../../ownership-costs'; import { OwnershipCostsList } from '../../ownership-costs';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface VehicleDetailMobileProps { interface VehicleDetailMobileProps {
vehicle: Vehicle; vehicle: Vehicle;
@@ -38,8 +39,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
onLogFuel, onLogFuel,
onEdit onEdit
}) => { }) => {
const displayName = vehicle.nickname || const displayName = getVehicleLabel(vehicle);
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const displayModel = vehicle.model || 'Unknown Model'; const displayModel = vehicle.model || 'Unknown Model';
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All'); const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');

View File

@@ -6,6 +6,7 @@ import React from 'react';
import { Card, CardActionArea, Box, Typography } from '@mui/material'; import { Card, CardActionArea, Box, Typography } from '@mui/material';
import { Vehicle } from '../types/vehicles.types'; import { Vehicle } from '../types/vehicles.types';
import { VehicleImage } from '../components/VehicleImage'; import { VehicleImage } from '../components/VehicleImage';
import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
interface VehicleMobileCardProps { interface VehicleMobileCardProps {
vehicle: Vehicle; vehicle: Vehicle;
@@ -18,9 +19,8 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
onClick, onClick,
compact = false compact = false
}) => { }) => {
const displayName = vehicle.nickname || const displayName = getVehicleLabel(vehicle);
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle'; const subtitle = getVehicleSubtitle(vehicle);
const displayModel = vehicle.model || 'Unknown Model';
return ( return (
<Card <Card
@@ -37,9 +37,11 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}> <Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{displayName} {displayName}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> {subtitle && (
{displayModel} <Typography variant="body2" color="text.secondary">
</Typography> {subtitle}
</Typography>
)}
{vehicle.licensePlate && ( {vehicle.licensePlate && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{vehicle.licensePlate} {vehicle.licensePlate}

View File

@@ -12,6 +12,7 @@ import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types'; import { Vehicle } from '../types/vehicles.types';
import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
import { vehiclesApi } from '../api/vehicles.api'; import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card'; import { Card } from '../../../shared-minimal/components/Card';
import { VehicleForm } from '../components/VehicleForm'; import { VehicleForm } from '../components/VehicleForm';
@@ -224,8 +225,7 @@ export const VehicleDetailPage: React.FC = () => {
); );
} }
const displayName = vehicle.nickname || const displayName = getVehicleLabel(vehicle);
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const handleRowClick = (recId: string, type: VehicleRecord['type']) => { const handleRowClick = (recId: string, type: VehicleRecord['type']) => {
if (type === 'Fuel Logs') { if (type === 'Fuel Logs') {
@@ -373,8 +373,7 @@ export const VehicleDetailPage: React.FC = () => {
Vehicle Details Vehicle Details
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{vehicle.year} {vehicle.make} {vehicle.model} {getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
{vehicle.trimLevel && ` ${vehicle.trimLevel}`}
</Typography> </Typography>
{vehicle.vin && ( {vehicle.vin && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>

View File

@@ -11,6 +11,7 @@ import { useAdminAccess } from '../core/auth/useAdminAccess';
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile'; import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
import { useExportUserData } from '../features/settings/hooks/useExportUserData'; import { useExportUserData } from '../features/settings/hooks/useExportUserData';
import { useVehicles } from '../features/vehicles/hooks/useVehicles'; import { useVehicles } from '../features/vehicles/hooks/useVehicles';
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
import { useSubscription } from '../features/subscription/hooks/useSubscription'; import { useSubscription } from '../features/subscription/hooks/useSubscription';
import { useTheme } from '../shared-minimal/theme/ThemeContext'; import { useTheme } from '../shared-minimal/theme/ThemeContext';
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog'; import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
@@ -375,7 +376,7 @@ export const SettingsPage: React.FC = () => {
{index > 0 && <Divider />} {index > 0 && <Divider />}
<ListItem sx={{ py: 1.5 }}> <ListItem sx={{ py: 1.5 }}>
<ListItemText <ListItemText
primary={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} primary={getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
secondary={vehicle.nickname || undefined} secondary={vehicle.nickname || undefined}
primaryTypographyProps={{ fontWeight: 500 }} primaryTypographyProps={{ fontWeight: 500 }}
/> />

View File

@@ -7,7 +7,7 @@ import React from 'react';
import { Box, IconButton, Typography, useTheme, SpeedDial, SpeedDialAction, Backdrop, SpeedDialIcon } from '@mui/material'; import { Box, IconButton, Typography, useTheme, SpeedDial, SpeedDialAction, Backdrop, SpeedDialIcon } from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -33,7 +33,7 @@ const leftNavItems: NavItem[] = [
]; ];
const rightNavItems: NavItem[] = [ const rightNavItems: NavItem[] = [
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> }, { screen: 'Stations', label: 'Stations', icon: <PlaceRoundedIcon /> },
]; ];
export const BottomNavigation: React.FC<BottomNavigationProps> = ({ export const BottomNavigation: React.FC<BottomNavigationProps> = ({

View File

@@ -18,6 +18,7 @@ import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import { MobileScreen } from '../../../core/store/navigation'; import { MobileScreen } from '../../../core/store/navigation';
@@ -41,7 +42,7 @@ interface MenuItem {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ screen: 'Settings', label: 'Settings', icon: <SettingsRoundedIcon /> }, { screen: 'Settings', label: 'Settings', icon: <SettingsRoundedIcon /> },
{ screen: 'Documents', label: 'Documents', icon: <DescriptionRoundedIcon /> }, { screen: 'Documents', label: 'Documents', icon: <DescriptionRoundedIcon /> },
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> }, { screen: 'Maintenance', label: 'Maintenance', icon: <BuildRoundedIcon /> },
{ screen: 'Log Fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> }, { screen: 'Log Fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> },
{ screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> }, { screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
{ screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> }, { screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },

View File

@@ -127,6 +127,7 @@ export const DEFAULT_ACCEPTED_FORMATS = [
'image/png', 'image/png',
'image/heic', 'image/heic',
'image/heif', 'image/heif',
'application/pdf',
]; ];
/** Default max file size (10MB) */ /** Default max file size (10MB) */

View File

@@ -18,8 +18,11 @@ from app.engines.base_engine import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum time (seconds) to wait for the cloud fallback # Maximum time (seconds) to wait for the cloud engine.
_CLOUD_TIMEOUT_SECONDS = 5.0 # 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 # Redis key prefix for monthly Vision API request counter
_VISION_COUNTER_PREFIX = "ocr:vision_requests" _VISION_COUNTER_PREFIX = "ocr:vision_requests"

View File

@@ -98,7 +98,7 @@ class MaintenanceReceiptExtractor:
"""Extract maintenance receipt fields from an image. """Extract maintenance receipt fields from an image.
Args: 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). content_type: MIME type (auto-detected if not provided).
Returns: Returns:

View File

@@ -1,4 +1,5 @@
"""Receipt-specific OCR extractor with field extraction.""" """Receipt-specific OCR extractor with field extraction."""
import io
import logging import logging
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -47,6 +48,7 @@ class ReceiptExtractor(BaseExtractor):
"image/png", "image/png",
"image/heic", "image/heic",
"image/heif", "image/heif",
"application/pdf",
} }
def __init__(self) -> None: def __init__(self) -> None:
@@ -63,7 +65,7 @@ class ReceiptExtractor(BaseExtractor):
Extract data from a receipt image. Extract data from a receipt image.
Args: 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) content_type: MIME type (auto-detected if not provided)
receipt_type: Hint for receipt type ("fuel" for specialized extraction) receipt_type: Hint for receipt type ("fuel" for specialized extraction)
@@ -85,6 +87,16 @@ class ReceiptExtractor(BaseExtractor):
) )
try: 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 # Apply receipt-optimized preprocessing
preprocessing_result = receipt_preprocessor.preprocess(image_bytes) preprocessing_result = receipt_preprocessor.preprocess(image_bytes)
preprocessed_bytes = preprocessing_result.image_bytes preprocessed_bytes = preprocessing_result.image_bytes
@@ -147,6 +159,26 @@ class ReceiptExtractor(BaseExtractor):
detected = mime.from_buffer(file_bytes) detected = mime.from_buffer(file_bytes)
return detected or "application/octet-stream" 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: def _perform_ocr(self, image_bytes: bytes) -> str:
""" """
Perform OCR on preprocessed image via engine abstraction. Perform OCR on preprocessed image via engine abstraction.

View File

@@ -281,9 +281,9 @@ async def extract_maintenance_receipt(
- Gemini semantic field extraction from OCR text - Gemini semantic field extraction from OCR text
- Regex cross-validation for dates, amounts, odometer - 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: Returns:
- **receiptType**: "maintenance" - **receiptType**: "maintenance"

View File

@@ -141,16 +141,17 @@ class OcrService:
def _extract_pdf_first_page(self, pdf_bytes: bytes) -> bytes: def _extract_pdf_first_page(self, pdf_bytes: bytes) -> bytes:
"""Extract first page of PDF as PNG image.""" """Extract first page of PDF as PNG image."""
try: try:
# Use pdf2image if available, otherwise return empty import fitz # PyMuPDF
from pdf2image import convert_from_bytes
images = convert_from_bytes(pdf_bytes, first_page=1, last_page=1, dpi=300) doc = fitz.open(stream=pdf_bytes, filetype="pdf")
if images: page = doc[0]
buffer = io.BytesIO() mat = fitz.Matrix(300 / 72, 300 / 72)
images[0].save(buffer, format="PNG") pix = page.get_pixmap(matrix=mat)
return buffer.getvalue() png_bytes = pix.tobytes("png")
doc.close()
return png_bytes
except ImportError: except ImportError:
logger.warning("pdf2image not available, PDF support limited") logger.warning("PyMuPDF not available, PDF support limited")
except Exception as e: except Exception as e:
logger.error(f"PDF extraction failed: {e}") logger.error(f"PDF extraction failed: {e}")