Mobile UX fixes

This commit is contained in:
Eric Gullickson
2025-12-17 21:46:44 -06:00
parent b611b56336
commit c13e17f0eb
14 changed files with 671 additions and 99 deletions

22
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,22 @@
stages:
- deploy
deploy_prod:
stage: deploy
only:
- main
script:
- echo ">>> Pulling latest code"
- git pull origin main
- echo ">>> Pulling updated images"
- docker compose pull
- echo ">>> Rebuilding local images if needed"
- docker compose build
- echo ">>> Starting/Updating services"
- docker compose up -d
- echo ">>> Removing old containers"
- docker image prune -f

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -10,11 +10,6 @@ import { useIsAuthInitialized } from './core/auth/auth-gate';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import { md3Theme } from './shared-minimal/theme/md3Theme'; import { md3Theme } from './shared-minimal/theme/md3Theme';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
import { UnitsProvider } from './core/units/UnitsContext'; import { UnitsProvider } from './core/units/UnitsContext';
@@ -43,7 +38,9 @@ const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminU
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen }))); const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen }))); const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation'; import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
import { HamburgerDrawer } from './shared-minimal/components/mobile/HamburgerDrawer';
import { GlassCard } from './shared-minimal/components/mobile/GlassCard'; import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
import { RouteSuspense } from './components/SuspenseWrappers'; import { RouteSuspense } from './components/SuspenseWrappers';
import { Vehicle } from './features/vehicles/types/vehicles.types'; import { Vehicle } from './features/vehicles/types/vehicles.types';
@@ -362,15 +359,29 @@ function App() {
return undefined; return undefined;
}, [goBack, canGoBack, mobileMode]); }, [goBack, canGoBack, mobileMode]);
// Mobile navigation items // Menu state
const mobileNavItems: NavigationItem[] = [ const [hamburgerOpen, setHamburgerOpen] = useState(false);
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> }, // Quick action handler
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> }, const handleQuickAction = useCallback((action: QuickAction) => {
{ key: "Stations", label: "Stations", icon: <LocalGasStationRoundedIcon /> }, switch (action) {
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> }, case 'log-fuel':
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> }, navigateToScreen('Log Fuel', { source: 'quick-action' });
]; break;
case 'add-vehicle':
setShowAddVehicle(true);
navigateToScreen('Vehicles', { source: 'quick-action' });
navigateToVehicleSubScreen('add', undefined, { source: 'quick-action' });
break;
case 'add-document':
navigateToScreen('Documents', { source: 'quick-action' });
break;
case 'add-maintenance':
// Navigate to maintenance or open form (future implementation)
navigateToScreen('Vehicles', { source: 'quick-action' });
break;
}
}, [navigateToScreen, navigateToVehicleSubScreen]);
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent }); console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
@@ -689,9 +700,8 @@ function App() {
</Layout> </Layout>
<BottomNavigation <BottomNavigation
items={mobileNavItems} activeScreen={activeScreen}
activeItem={activeScreen} onNavigate={(screen) => startTransition(() => {
onItemSelect={(screen) => startTransition(() => {
// Prefetch data for the target screen // Prefetch data for the target screen
prefetchForNavigation(screen); prefetchForNavigation(screen);
@@ -704,8 +714,29 @@ function App() {
} }
// Navigate after state cleanup // Navigate after state cleanup
navigateToScreen(screen as any, { source: 'bottom-navigation' }); navigateToScreen(screen, { source: 'bottom-navigation' });
})} })}
onQuickAction={handleQuickAction}
onHamburgerPress={() => setHamburgerOpen(true)}
/>
<HamburgerDrawer
open={hamburgerOpen}
onClose={() => setHamburgerOpen(false)}
onNavigate={(screen) => {
setHamburgerOpen(false);
startTransition(() => {
prefetchForNavigation(screen);
if (screen !== 'Vehicles') {
setSelectedVehicle(null);
}
if (screen !== 'Vehicles' || vehicleSubScreen !== 'add') {
setShowAddVehicle(false);
}
navigateToScreen(screen, { source: 'hamburger-menu' });
});
}}
activeScreen={activeScreen}
/> />
</UnitsProvider> </UnitsProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useTransition, useEffect } from 'react'; import React, { useTransition, useEffect } from 'react';
import { Box, Typography, Grid, Fab } from '@mui/material'; import { Box, Typography, Grid, Button } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { useVehicles } from '../hooks/useVehicles'; import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles'; import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
@@ -82,38 +82,33 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
if (!optimisticVehicles.length) { if (!optimisticVehicles.length) {
return ( return (
<Box sx={{ pb: 10, position: 'relative' }}> <Box sx={{ pb: 10 }}>
<Section title="Vehicles"> <Section title="Vehicles">
<Box sx={{ textAlign: 'center', py: 12 }}> <Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary" sx={{ mb: 2 }}> <Typography color="text.secondary" sx={{ mb: 2 }}>
No vehicles added yet No vehicles added yet
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
Add your first vehicle to get started Add your first vehicle to get started
</Typography> </Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => onAddVehicle?.()}
sx={{ minWidth: 160 }}
>
Add Vehicle
</Button>
</Box> </Box>
</Section> </Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box> </Box>
); );
} }
return ( return (
<MobileVehiclesSuspense> <MobileVehiclesSuspense>
<Box sx={{ pb: 10, position: 'relative' }}> <Box sx={{ pb: 10 }}>
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}> <Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
<Grid container spacing={2}> <Grid container spacing={2}>
{filteredVehicles.map((vehicle) => ( {filteredVehicles.map((vehicle) => (
@@ -126,20 +121,6 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
))} ))}
</Grid> </Grid>
</Section> </Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box> </Box>
</MobileVehiclesSuspense> </MobileVehiclesSuspense>
); );

View File

@@ -1,58 +1,314 @@
/** /**
* @ai-summary Bottom navigation component with Material Design 3 * @ai-summary Bottom navigation component with center FAB and hamburger menu
* @ai-context Layout: Dashboard | Vehicles | + FAB | Stations | Hamburger
*/ */
import React from 'react'; import React from 'react';
import { BottomNavigation as MuiBottomNavigation, BottomNavigationAction } from '@mui/material'; import { Box, IconButton, Typography, useTheme, SpeedDial, SpeedDialAction, Backdrop, SpeedDialIcon } from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import { MobileScreen } from '../../../core/store/navigation';
import { quickActions, QuickAction } from './quickActions';
export interface NavigationItem { interface BottomNavigationProps {
key: string; activeScreen: MobileScreen;
onNavigate: (screen: MobileScreen) => void;
onQuickAction: (action: QuickAction) => void;
onHamburgerPress: () => void;
}
interface NavItem {
screen: MobileScreen;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
} }
interface BottomNavigationProps { const leftNavItems: NavItem[] = [
items: NavigationItem[]; { screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },
activeItem: string; { screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
onItemSelect: (key: string) => void; ];
}
const rightNavItems: NavItem[] = [
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> },
];
export const BottomNavigation: React.FC<BottomNavigationProps> = ({ export const BottomNavigation: React.FC<BottomNavigationProps> = ({
items, activeScreen,
activeItem, onNavigate,
onItemSelect onQuickAction,
onHamburgerPress
}) => { }) => {
const activeIndex = items.findIndex(item => item.key === activeItem); const theme = useTheme();
const [speedDialOpen, setSpeedDialOpen] = React.useState(false);
const closeSpeedDial = () => setSpeedDialOpen(false);
const openSpeedDial = () => setSpeedDialOpen(true);
const NavButton: React.FC<{
item: NavItem;
isActive: boolean;
onClick: () => void;
}> = ({ item, isActive, onClick }) => (
<Box
onClick={() => {
closeSpeedDial();
onClick();
}}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
py: 1,
cursor: 'pointer',
color: isActive ? theme.palette.primary.main : theme.palette.text.secondary,
transition: 'color 0.2s ease',
minWidth: 64,
'&:active': {
opacity: 0.7
}
}}
>
<IconButton
size="small"
sx={{
color: 'inherit',
p: 0.5
}}
disableRipple
>
{item.icon}
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
fontWeight: isActive ? 600 : 400,
color: 'inherit',
mt: 0.25
}}
>
{item.label}
</Typography>
</Box>
);
return ( return (
<MuiBottomNavigation <Box
showLabels
value={activeIndex}
onChange={(_, newValue) => onItemSelect(items[newValue].key)}
sx={{ sx={{
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
zIndex: 1000, zIndex: 1000,
width: '100%' backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-around',
height: 64,
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
overflow: 'visible'
}} }}
> >
{items.map(({ key, label, icon }) => ( {/* Left nav items */}
<BottomNavigationAction {leftNavItems.map((item) => (
key={key} <NavButton
label={label} key={item.screen}
icon={icon} item={item}
sx={{ isActive={activeScreen === item.screen}
'& .MuiBottomNavigationAction-label': { onClick={() => onNavigate(item.screen)}
fontSize: '0.75rem',
'&.Mui-selected': {
fontSize: '0.75rem'
}
}
}}
/> />
))} ))}
</MuiBottomNavigation>
{/* Center quick actions */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'stretch',
flex: 1,
position: 'relative',
height: '100%'
}}
>
<Backdrop
open={speedDialOpen}
onClick={closeSpeedDial}
sx={{
zIndex: theme.zIndex.speedDial - 1,
backgroundColor: 'rgba(0, 0, 0, 0.15)'
}}
/>
<SpeedDial
ariaLabel="Quick actions"
icon={<SpeedDialIcon icon={<AddIcon />} openIcon={<CloseIcon />} />}
open={speedDialOpen}
onOpen={openSpeedDial}
onClose={closeSpeedDial}
direction="up"
FabProps={{
color: 'primary',
sx: {
boxShadow: '0 4px 12px rgba(122, 33, 42, 0.35)',
width: 52,
height: 52,
'&:hover': {
boxShadow: '0 6px 16px rgba(122, 33, 42, 0.45)'
}
},
onClick: () => setSpeedDialOpen((prev) => !prev)
}}
sx={{
position: 'fixed',
bottom: 'calc(64px + env(safe-area-inset-bottom, 0px) - 26px)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.appBar + 1,
'& .MuiSpeedDial-fab': {
width: 52,
height: 52
},
// Position actions container as a centered point above FAB - use !important to override MUI defaults
'& .MuiSpeedDial-actions': {
position: 'absolute !important',
bottom: '56px !important',
left: '50% !important',
transform: 'translateX(-50%) !important',
width: '0 !important',
height: '0 !important',
padding: '0 !important',
margin: '0 !important',
flexDirection: 'row !important'
}
}}
>
{quickActions.map(({ action, label, icon }, index) => {
// Calculate semi-circle positions
// 4 items spread across 120 degrees (-60 to +60 from vertical)
const totalItems = quickActions.length;
const spreadAngle = 120; // degrees - reduced for tighter fit
const startAngle = 90 + spreadAngle / 2; // Start from left
const angleStep = spreadAngle / (totalItems - 1);
const angle = (startAngle - index * angleStep) * (Math.PI / 180);
const radius = 80; // Distance from center
const actionButtonOffset = 28; // Half of 56px wrapper width - centers the button instead of left edge
const x = Math.cos(angle) * radius - actionButtonOffset;
const y = -Math.sin(angle) * radius; // Negative because Y goes down in CSS
// Dynamic tooltip placement - left side items get left labels, right side get right labels
const tooltipPlacement = angle > Math.PI / 2 ? 'left' : 'right';
return (
<SpeedDialAction
key={action}
icon={icon}
tooltipTitle={label}
tooltipOpen
tooltipPlacement={tooltipPlacement}
onClick={() => {
onQuickAction(action);
closeSpeedDial();
}}
FabProps={{
sx: {
bgcolor: theme.palette.background.paper,
color: theme.palette.primary.main,
boxShadow: theme.shadows[4],
'&:hover': {
bgcolor: theme.palette.action.hover
}
}
}}
sx={{
position: 'absolute !important',
left: '0 !important',
right: 'auto !important',
top: '0 !important',
bottom: 'auto !important',
margin: '0 !important',
padding: '0 !important',
transform: `translate(${x}px, ${y}px) !important`,
transition: 'transform 0.2s ease, opacity 0.2s ease',
'& .MuiSpeedDialAction-staticTooltipLabel': {
whiteSpace: 'nowrap',
fontSize: '0.75rem',
padding: '4px 8px'
}
}}
/>
);
})}
</SpeedDial>
</Box>
{/* Right nav items */}
{rightNavItems.map((item) => (
<NavButton
key={item.screen}
item={item}
isActive={activeScreen === item.screen}
onClick={() => onNavigate(item.screen)}
/>
))}
{/* Hamburger menu button */}
<Box
onClick={() => {
closeSpeedDial();
onHamburgerPress();
}}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
py: 1,
cursor: 'pointer',
color: theme.palette.text.secondary,
transition: 'color 0.2s ease',
minWidth: 64,
'&:active': {
opacity: 0.7
}
}}
>
<IconButton
size="small"
sx={{
color: 'inherit',
p: 0.5
}}
disableRipple
>
<MenuRoundedIcon />
</IconButton>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
fontWeight: 400,
color: 'inherit',
mt: 0.25
}}
>
More
</Typography>
</Box>
</Box>
); );
}; };
// Re-export NavigationItem for backward compatibility if needed
export interface NavigationItem {
key: string;
label: string;
icon: React.ReactNode;
}

View File

@@ -0,0 +1,158 @@
/**
* @ai-summary Hamburger menu drawer for mobile navigation
* @ai-context Bottom slide-up drawer with all navigation options
*/
import React from 'react';
import {
Box,
SwipeableDrawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
useTheme
} from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import { MobileScreen } from '../../../core/store/navigation';
// iOS swipeable drawer configuration
const iOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
interface HamburgerDrawerProps {
open: boolean;
onClose: () => void;
onNavigate: (screen: MobileScreen) => void;
activeScreen: MobileScreen;
}
interface MenuItem {
screen: MobileScreen;
label: string;
icon: React.ReactNode;
}
// Menu items from bottom to top (reversed order in array for rendering)
const menuItems: MenuItem[] = [
{ screen: 'Settings', label: 'Settings', icon: <SettingsRoundedIcon /> },
{ screen: 'Documents', label: 'Documents', icon: <DescriptionRoundedIcon /> },
{ screen: 'Stations', label: 'Stations', icon: <LocalGasStationRoundedIcon /> },
{ screen: 'Log Fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> },
{ screen: 'Vehicles', label: 'Vehicles', icon: <DirectionsCarRoundedIcon /> },
{ screen: 'Dashboard', label: 'Dashboard', icon: <HomeRoundedIcon /> },
];
export const HamburgerDrawer: React.FC<HamburgerDrawerProps> = ({
open,
onClose,
onNavigate,
activeScreen
}) => {
const theme = useTheme();
const handleNavigate = (screen: MobileScreen) => {
onNavigate(screen);
onClose();
};
return (
<SwipeableDrawer
anchor="bottom"
open={open}
onClose={onClose}
onOpen={() => {}}
disableSwipeToOpen
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
sx={{
'& .MuiDrawer-paper': {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60vh',
overflow: 'visible'
}
}}
>
<Box sx={{ pb: 4 }}>
{/* Drawer handle */}
<Box
sx={{
width: 40,
height: 4,
backgroundColor: theme.palette.divider,
borderRadius: 2,
margin: '12px auto 8px'
}}
/>
{/* Header */}
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
px: 2,
py: 1.5,
color: theme.palette.text.secondary
}}
>
Menu
</Typography>
{/* Menu Items */}
<List disablePadding>
{menuItems.map(({ screen, label, icon }) => {
const isActive = activeScreen === screen;
return (
<ListItemButton
key={screen}
onClick={() => handleNavigate(screen)}
sx={{
py: 1.5,
px: 2,
minHeight: 56,
backgroundColor: isActive
? theme.palette.primary.main + '14'
: 'transparent',
borderLeft: isActive
? `3px solid ${theme.palette.primary.main}`
: '3px solid transparent',
'&:hover': {
backgroundColor: isActive
? theme.palette.primary.main + '20'
: theme.palette.action.hover
}
}}
>
<ListItemIcon
sx={{
minWidth: 40,
color: isActive
? theme.palette.primary.main
: theme.palette.text.secondary
}}
>
{icon}
</ListItemIcon>
<ListItemText
primary={label}
primaryTypographyProps={{
fontWeight: isActive ? 600 : 500,
fontSize: '0.95rem',
color: isActive
? theme.palette.primary.main
: theme.palette.text.primary
}}
/>
</ListItemButton>
);
})}
</List>
</Box>
</SwipeableDrawer>
);
};

View File

@@ -0,0 +1,110 @@
/**
* @ai-summary Quick action menu popup for center FAB button
* @ai-context Opens above bottom navigation with quick shortcuts
* @ai-note Uses Portal to escape transformed parent containers
*/
import React from 'react';
import { createPortal } from 'react-dom';
import {
Box,
Paper,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Backdrop,
useTheme,
Fade
} from '@mui/material';
import { quickActions, QuickAction } from './quickActions';
interface QuickActionMenuProps {
open: boolean;
onClose: () => void;
onAction: (action: QuickAction) => void;
}
export const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
open,
onClose,
onAction
}) => {
const theme = useTheme();
const handleAction = (action: QuickAction) => {
onAction(action);
onClose();
};
// Use portal to render at document body level, escaping any transformed parents
return createPortal(
<>
<Backdrop
open={open}
onClick={onClose}
sx={{
zIndex: theme.zIndex.modal - 1,
backgroundColor: 'rgba(0, 0, 0, 0.3)'
}}
/>
<Box
sx={{
position: 'fixed',
bottom: 120,
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.modal,
pointerEvents: open ? 'auto' : 'none',
paddingBottom: 'env(safe-area-inset-bottom, 0px)'
}}
>
<Fade in={open}>
<Paper
elevation={8}
sx={{
borderRadius: 3,
overflow: 'hidden',
minWidth: 200,
backgroundColor: theme.palette.background.paper
}}
>
<List disablePadding>
{quickActions.map(({ action, label, icon }) => (
<ListItemButton
key={action}
onClick={() => handleAction(action)}
sx={{
py: 1.5,
px: 2,
minHeight: 56,
'&:hover': {
backgroundColor: theme.palette.action.hover
}
}}
>
<ListItemIcon
sx={{
minWidth: 40,
color: theme.palette.primary.main
}}
>
{icon}
</ListItemIcon>
<ListItemText
primary={label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '0.95rem'
}}
/>
</ListItemButton>
))}
</List>
</Paper>
</Fade>
</Box>
</>,
document.body
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
export type QuickAction = 'log-fuel' | 'add-vehicle' | 'add-document' | 'add-maintenance';
export const quickActions: { action: QuickAction; label: string; icon: React.ReactNode }[] = [
{ action: 'log-fuel', label: 'Log Fuel', icon: <LocalGasStationRoundedIcon /> },
{ action: 'add-vehicle', label: 'Vehicle', icon: <DirectionsCarRoundedIcon /> },
{ action: 'add-document', label: 'Document', icon: <DescriptionRoundedIcon /> },
{ action: 'add-maintenance', label: 'Maintenance', icon: <BuildRoundedIcon /> }
];