chore: UX design audit cleanup and receipt flow improvements #186
@@ -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`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
27
frontend/src/core/utils/vehicleDisplay.ts
Normal file
27
frontend/src/core/utils/vehicleDisplay.ts
Normal 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(' ') : '';
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
118
frontend/src/features/dashboard/components/RecentActivity.tsx
Normal file
118
frontend/src/features/dashboard/components/RecentActivity.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }>();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) + '...';
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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> = ({
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user