init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { styled, useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import MuiAppBar from '@mui/material/AppBar';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import Stack from '@mui/material/Stack';
import { Link } from 'react-router';
import ThemeSwitcher from './ThemeSwitcher';
const AppBar = styled(MuiAppBar)(({ theme }) => ({
borderWidth: 0,
borderBottomWidth: 1,
borderStyle: 'solid',
borderColor: (theme.vars ?? theme).palette.divider,
boxShadow: 'none',
zIndex: theme.zIndex.drawer + 1,
}));
const LogoContainer = styled('div')({
position: 'relative',
height: 40,
display: 'flex',
alignItems: 'center',
'& img': {
maxHeight: 40,
},
});
function DashboardHeader({ logo, title, menuOpen, onToggleMenu }) {
const theme = useTheme();
const handleMenuOpen = React.useCallback(() => {
onToggleMenu(!menuOpen);
}, [menuOpen, onToggleMenu]);
const getMenuIcon = React.useCallback(
(isExpanded) => {
const expandMenuActionText = 'Expand';
const collapseMenuActionText = 'Collapse';
return (
<Tooltip
title={`${isExpanded ? collapseMenuActionText : expandMenuActionText} menu`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`${isExpanded ? collapseMenuActionText : expandMenuActionText} navigation menu`}
onClick={handleMenuOpen}
>
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
</IconButton>
</div>
</Tooltip>
);
},
[handleMenuOpen],
);
return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: 'none' }}>
<Toolbar sx={{ backgroundColor: 'inherit', mx: { xs: -0.75, sm: -1 } }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
flexWrap: 'wrap',
width: '100%',
}}
>
<Stack direction="row" alignItems="center">
<Box sx={{ mr: 1 }}>{getMenuIcon(menuOpen)}</Box>
<Link to="/" style={{ textDecoration: 'none' }}>
<Stack direction="row" alignItems="center">
{logo ? <LogoContainer>{logo}</LogoContainer> : null}
{title ? (
<Typography
variant="h6"
sx={{
color: (theme.vars ?? theme).palette.primary.main,
fontWeight: '700',
ml: 1,
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{title}
</Typography>
) : null}
</Stack>
</Link>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ marginLeft: 'auto' }}
>
<Stack direction="row" alignItems="center">
<ThemeSwitcher />
</Stack>
</Stack>
</Stack>
</Toolbar>
</AppBar>
);
}
DashboardHeader.propTypes = {
logo: PropTypes.node,
menuOpen: PropTypes.bool.isRequired,
onToggleMenu: PropTypes.func.isRequired,
title: PropTypes.string,
};
export default DashboardHeader;

View File

@@ -0,0 +1,126 @@
import * as React from 'react';
import { styled, useTheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import MuiAppBar from '@mui/material/AppBar';
import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import Stack from '@mui/material/Stack';
import { Link } from 'react-router';
import ThemeSwitcher from './ThemeSwitcher';
const AppBar = styled(MuiAppBar)(({ theme }) => ({
borderWidth: 0,
borderBottomWidth: 1,
borderStyle: 'solid',
borderColor: (theme.vars ?? theme).palette.divider,
boxShadow: 'none',
zIndex: theme.zIndex.drawer + 1,
}));
const LogoContainer = styled('div')({
position: 'relative',
height: 40,
display: 'flex',
alignItems: 'center',
'& img': {
maxHeight: 40,
},
});
export interface DashboardHeaderProps {
logo?: React.ReactNode;
title?: string;
menuOpen: boolean;
onToggleMenu: (open: boolean) => void;
}
export default function DashboardHeader({
logo,
title,
menuOpen,
onToggleMenu,
}: DashboardHeaderProps) {
const theme = useTheme();
const handleMenuOpen = React.useCallback(() => {
onToggleMenu(!menuOpen);
}, [menuOpen, onToggleMenu]);
const getMenuIcon = React.useCallback(
(isExpanded: boolean) => {
const expandMenuActionText = 'Expand';
const collapseMenuActionText = 'Collapse';
return (
<Tooltip
title={`${isExpanded ? collapseMenuActionText : expandMenuActionText} menu`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`${isExpanded ? collapseMenuActionText : expandMenuActionText} navigation menu`}
onClick={handleMenuOpen}
>
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
</IconButton>
</div>
</Tooltip>
);
},
[handleMenuOpen],
);
return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: 'none' }}>
<Toolbar sx={{ backgroundColor: 'inherit', mx: { xs: -0.75, sm: -1 } }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
flexWrap: 'wrap',
width: '100%',
}}
>
<Stack direction="row" alignItems="center">
<Box sx={{ mr: 1 }}>{getMenuIcon(menuOpen)}</Box>
<Link to="/" style={{ textDecoration: 'none' }}>
<Stack direction="row" alignItems="center">
{logo ? <LogoContainer>{logo}</LogoContainer> : null}
{title ? (
<Typography
variant="h6"
sx={{
color: (theme.vars ?? theme).palette.primary.main,
fontWeight: '700',
ml: 1,
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{title}
</Typography>
) : null}
</Stack>
</Link>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ marginLeft: 'auto' }}
>
<Stack direction="row" alignItems="center">
<ThemeSwitcher />
</Stack>
</Stack>
</Stack>
</Toolbar>
</AppBar>
);
}

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import { Outlet } from 'react-router';
import DashboardHeader from './DashboardHeader';
import DashboardSidebar from './DashboardSidebar';
import SitemarkIcon from './SitemarkIcon';
export default function DashboardLayout() {
const theme = useTheme();
const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
React.useState(true);
const [isMobileNavigationExpanded, setIsMobileNavigationExpanded] =
React.useState(false);
const isOverMdViewport = useMediaQuery(theme.breakpoints.up('md'));
const isNavigationExpanded = isOverMdViewport
? isDesktopNavigationExpanded
: isMobileNavigationExpanded;
const setIsNavigationExpanded = React.useCallback(
(newExpanded) => {
if (isOverMdViewport) {
setIsDesktopNavigationExpanded(newExpanded);
} else {
setIsMobileNavigationExpanded(newExpanded);
}
},
[
isOverMdViewport,
setIsDesktopNavigationExpanded,
setIsMobileNavigationExpanded,
],
);
const handleToggleHeaderMenu = React.useCallback(
(isExpanded) => {
setIsNavigationExpanded(isExpanded);
},
[setIsNavigationExpanded],
);
const layoutRef = React.useRef(null);
return (
<Box
ref={layoutRef}
sx={{
position: 'relative',
display: 'flex',
overflow: 'hidden',
height: '100%',
width: '100%',
}}
>
<DashboardHeader
logo={<SitemarkIcon />}
title=""
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar
expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded}
container={layoutRef?.current ?? undefined}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
minWidth: 0,
}}
>
<Toolbar sx={{ displayPrint: 'none' }} />
<Box
component="main"
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'auto',
}}
>
<Outlet />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import { Outlet } from 'react-router';
import DashboardHeader from './DashboardHeader';
import DashboardSidebar from './DashboardSidebar';
import SitemarkIcon from './SitemarkIcon';
export default function DashboardLayout() {
const theme = useTheme();
const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
React.useState(true);
const [isMobileNavigationExpanded, setIsMobileNavigationExpanded] =
React.useState(false);
const isOverMdViewport = useMediaQuery(theme.breakpoints.up('md'));
const isNavigationExpanded = isOverMdViewport
? isDesktopNavigationExpanded
: isMobileNavigationExpanded;
const setIsNavigationExpanded = React.useCallback(
(newExpanded: boolean) => {
if (isOverMdViewport) {
setIsDesktopNavigationExpanded(newExpanded);
} else {
setIsMobileNavigationExpanded(newExpanded);
}
},
[
isOverMdViewport,
setIsDesktopNavigationExpanded,
setIsMobileNavigationExpanded,
],
);
const handleToggleHeaderMenu = React.useCallback(
(isExpanded: boolean) => {
setIsNavigationExpanded(isExpanded);
},
[setIsNavigationExpanded],
);
const layoutRef = React.useRef<HTMLDivElement>(null);
return (
<Box
ref={layoutRef}
sx={{
position: 'relative',
display: 'flex',
overflow: 'hidden',
height: '100%',
width: '100%',
}}
>
<DashboardHeader
logo={<SitemarkIcon />}
title=""
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar
expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded}
container={layoutRef?.current ?? undefined}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
minWidth: 0,
}}
>
<Toolbar sx={{ displayPrint: 'none' }} />
<Box
component="main"
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'auto',
}}
>
<Outlet />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,288 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import Toolbar from '@mui/material/Toolbar';
import PersonIcon from '@mui/icons-material/Person';
import BarChartIcon from '@mui/icons-material/BarChart';
import DescriptionIcon from '@mui/icons-material/Description';
import LayersIcon from '@mui/icons-material/Layers';
import { matchPath, useLocation } from 'react-router';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from '../constants';
import DashboardSidebarPageItem from './DashboardSidebarPageItem';
import DashboardSidebarHeaderItem from './DashboardSidebarHeaderItem';
import DashboardSidebarDividerItem from './DashboardSidebarDividerItem';
import {
getDrawerSxTransitionMixin,
getDrawerWidthTransitionMixin,
} from '../mixins';
function DashboardSidebar({
expanded = true,
setExpanded,
disableCollapsibleSidebar = false,
container,
}) {
const theme = useTheme();
const { pathname } = useLocation();
const [expandedItemIds, setExpandedItemIds] = React.useState([]);
const isOverSmViewport = useMediaQuery(theme.breakpoints.up('sm'));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up('md'));
const [isFullyExpanded, setIsFullyExpanded] = React.useState(expanded);
const [isFullyCollapsed, setIsFullyCollapsed] = React.useState(!expanded);
React.useEffect(() => {
if (expanded) {
const drawerWidthTransitionTimeout = setTimeout(() => {
setIsFullyExpanded(true);
}, theme.transitions.duration.enteringScreen);
return () => clearTimeout(drawerWidthTransitionTimeout);
}
setIsFullyExpanded(false);
return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]);
React.useEffect(() => {
if (!expanded) {
const drawerWidthTransitionTimeout = setTimeout(() => {
setIsFullyCollapsed(true);
}, theme.transitions.duration.leavingScreen);
return () => clearTimeout(drawerWidthTransitionTimeout);
}
setIsFullyCollapsed(false);
return () => {};
}, [expanded, theme.transitions.duration.leavingScreen]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback(
(newExpanded) => () => {
setExpanded(newExpanded);
},
[setExpanded],
);
const handlePageItemClick = React.useCallback(
(itemId, hasNestedNavigation) => {
if (hasNestedNavigation && !mini) {
setExpandedItemIds((previousValue) =>
previousValue.includes(itemId)
? previousValue.filter(
(previousValueItemId) => previousValueItemId !== itemId,
)
: [...previousValue, itemId],
);
} else if (!isOverSmViewport && !hasNestedNavigation) {
setExpanded(false);
}
},
[mini, setExpanded, isOverSmViewport],
);
const hasDrawerTransitions =
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const getDrawerContent = React.useCallback(
(viewport) => (
<React.Fragment>
<Toolbar />
<Box
component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'auto',
scrollbarGutter: mini ? 'stable' : 'auto',
overflowX: 'hidden',
pt: !mini ? 0 : 2,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, 'padding')
: {}),
}}
>
<List
dense
sx={{
padding: mini ? 0 : 0.5,
mb: 4,
width: mini ? MINI_DRAWER_WIDTH : 'auto',
}}
>
<DashboardSidebarHeaderItem>Main items</DashboardSidebarHeaderItem>
<DashboardSidebarPageItem
id="employees"
title="Employees"
icon={<PersonIcon />}
href="/employees"
selected={!!matchPath('/employees/*', pathname) || pathname === '/'}
/>
<DashboardSidebarDividerItem />
<DashboardSidebarHeaderItem>Example items</DashboardSidebarHeaderItem>
<DashboardSidebarPageItem
id="reports"
title="Reports"
icon={<BarChartIcon />}
href="/reports"
selected={!!matchPath('/reports', pathname)}
defaultExpanded={!!matchPath('/reports', pathname)}
expanded={expandedItemIds.includes('reports')}
nestedNavigation={
<List
dense
sx={{
padding: 0,
my: 1,
pl: mini ? 0 : 1,
minWidth: 240,
}}
>
<DashboardSidebarPageItem
id="sales"
title="Sales"
icon={<DescriptionIcon />}
href="/reports/sales"
selected={!!matchPath('/reports/sales', pathname)}
/>
<DashboardSidebarPageItem
id="traffic"
title="Traffic"
icon={<DescriptionIcon />}
href="/reports/traffic"
selected={!!matchPath('/reports/traffic', pathname)}
/>
</List>
}
/>
<DashboardSidebarPageItem
id="integrations"
title="Integrations"
icon={<LayersIcon />}
href="/integrations"
selected={!!matchPath('/integrations', pathname)}
/>
</List>
</Box>
</React.Fragment>
),
[mini, hasDrawerTransitions, isFullyExpanded, expandedItemIds, pathname],
);
const getDrawerSharedSx = React.useCallback(
(isTemporary) => {
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
return {
displayPrint: 'none',
width: drawerWidth,
flexShrink: 0,
...getDrawerWidthTransitionMixin(expanded),
...(isTemporary ? { position: 'absolute' } : {}),
[`& .MuiDrawer-paper`]: {
position: 'absolute',
width: drawerWidth,
boxSizing: 'border-box',
backgroundImage: 'none',
...getDrawerWidthTransitionMixin(expanded),
},
};
},
[expanded, mini],
);
const sidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: handlePageItemClick,
mini,
fullyExpanded: isFullyExpanded,
fullyCollapsed: isFullyCollapsed,
hasDrawerTransitions,
};
}, [
handlePageItemClick,
mini,
isFullyExpanded,
isFullyCollapsed,
hasDrawerTransitions,
]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
<Drawer
container={container}
variant="temporary"
open={expanded}
onClose={handleSetSidebarExpanded(false)}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: {
xs: 'block',
sm: disableCollapsibleSidebar ? 'block' : 'none',
md: 'none',
},
...getDrawerSharedSx(true),
}}
>
{getDrawerContent('phone')}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: {
xs: 'none',
sm: disableCollapsibleSidebar ? 'none' : 'block',
md: 'none',
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent('tablet')}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
...getDrawerSharedSx(false),
}}
>
{getDrawerContent('desktop')}
</Drawer>
</DashboardSidebarContext.Provider>
);
}
DashboardSidebar.propTypes = {
container: (props, propName) => {
if (props[propName] == null) {
return null;
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(`Expected prop '${propName}' to be of type Element`);
}
return null;
},
disableCollapsibleSidebar: PropTypes.bool,
expanded: PropTypes.bool,
setExpanded: PropTypes.func.isRequired,
};
export default DashboardSidebar;

View File

@@ -0,0 +1,277 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import Toolbar from '@mui/material/Toolbar';
import type {} from '@mui/material/themeCssVarsAugmentation';
import PersonIcon from '@mui/icons-material/Person';
import BarChartIcon from '@mui/icons-material/BarChart';
import DescriptionIcon from '@mui/icons-material/Description';
import LayersIcon from '@mui/icons-material/Layers';
import { matchPath, useLocation } from 'react-router';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from '../constants';
import DashboardSidebarPageItem from './DashboardSidebarPageItem';
import DashboardSidebarHeaderItem from './DashboardSidebarHeaderItem';
import DashboardSidebarDividerItem from './DashboardSidebarDividerItem';
import {
getDrawerSxTransitionMixin,
getDrawerWidthTransitionMixin,
} from '../mixins';
export interface DashboardSidebarProps {
expanded?: boolean;
setExpanded: (expanded: boolean) => void;
disableCollapsibleSidebar?: boolean;
container?: Element;
}
export default function DashboardSidebar({
expanded = true,
setExpanded,
disableCollapsibleSidebar = false,
container,
}: DashboardSidebarProps) {
const theme = useTheme();
const { pathname } = useLocation();
const [expandedItemIds, setExpandedItemIds] = React.useState<string[]>([]);
const isOverSmViewport = useMediaQuery(theme.breakpoints.up('sm'));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up('md'));
const [isFullyExpanded, setIsFullyExpanded] = React.useState(expanded);
const [isFullyCollapsed, setIsFullyCollapsed] = React.useState(!expanded);
React.useEffect(() => {
if (expanded) {
const drawerWidthTransitionTimeout = setTimeout(() => {
setIsFullyExpanded(true);
}, theme.transitions.duration.enteringScreen);
return () => clearTimeout(drawerWidthTransitionTimeout);
}
setIsFullyExpanded(false);
return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]);
React.useEffect(() => {
if (!expanded) {
const drawerWidthTransitionTimeout = setTimeout(() => {
setIsFullyCollapsed(true);
}, theme.transitions.duration.leavingScreen);
return () => clearTimeout(drawerWidthTransitionTimeout);
}
setIsFullyCollapsed(false);
return () => {};
}, [expanded, theme.transitions.duration.leavingScreen]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => {
setExpanded(newExpanded);
},
[setExpanded],
);
const handlePageItemClick = React.useCallback(
(itemId: string, hasNestedNavigation: boolean) => {
if (hasNestedNavigation && !mini) {
setExpandedItemIds((previousValue) =>
previousValue.includes(itemId)
? previousValue.filter(
(previousValueItemId) => previousValueItemId !== itemId,
)
: [...previousValue, itemId],
);
} else if (!isOverSmViewport && !hasNestedNavigation) {
setExpanded(false);
}
},
[mini, setExpanded, isOverSmViewport],
);
const hasDrawerTransitions =
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const getDrawerContent = React.useCallback(
(viewport: 'phone' | 'tablet' | 'desktop') => (
<React.Fragment>
<Toolbar />
<Box
component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflow: 'auto',
scrollbarGutter: mini ? 'stable' : 'auto',
overflowX: 'hidden',
pt: !mini ? 0 : 2,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, 'padding')
: {}),
}}
>
<List
dense
sx={{
padding: mini ? 0 : 0.5,
mb: 4,
width: mini ? MINI_DRAWER_WIDTH : 'auto',
}}
>
<DashboardSidebarHeaderItem>Main items</DashboardSidebarHeaderItem>
<DashboardSidebarPageItem
id="employees"
title="Employees"
icon={<PersonIcon />}
href="/employees"
selected={!!matchPath('/employees/*', pathname) || pathname === '/'}
/>
<DashboardSidebarDividerItem />
<DashboardSidebarHeaderItem>Example items</DashboardSidebarHeaderItem>
<DashboardSidebarPageItem
id="reports"
title="Reports"
icon={<BarChartIcon />}
href="/reports"
selected={!!matchPath('/reports', pathname)}
defaultExpanded={!!matchPath('/reports', pathname)}
expanded={expandedItemIds.includes('reports')}
nestedNavigation={
<List
dense
sx={{
padding: 0,
my: 1,
pl: mini ? 0 : 1,
minWidth: 240,
}}
>
<DashboardSidebarPageItem
id="sales"
title="Sales"
icon={<DescriptionIcon />}
href="/reports/sales"
selected={!!matchPath('/reports/sales', pathname)}
/>
<DashboardSidebarPageItem
id="traffic"
title="Traffic"
icon={<DescriptionIcon />}
href="/reports/traffic"
selected={!!matchPath('/reports/traffic', pathname)}
/>
</List>
}
/>
<DashboardSidebarPageItem
id="integrations"
title="Integrations"
icon={<LayersIcon />}
href="/integrations"
selected={!!matchPath('/integrations', pathname)}
/>
</List>
</Box>
</React.Fragment>
),
[mini, hasDrawerTransitions, isFullyExpanded, expandedItemIds, pathname],
);
const getDrawerSharedSx = React.useCallback(
(isTemporary: boolean) => {
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
return {
displayPrint: 'none',
width: drawerWidth,
flexShrink: 0,
...getDrawerWidthTransitionMixin(expanded),
...(isTemporary ? { position: 'absolute' } : {}),
[`& .MuiDrawer-paper`]: {
position: 'absolute',
width: drawerWidth,
boxSizing: 'border-box',
backgroundImage: 'none',
...getDrawerWidthTransitionMixin(expanded),
},
};
},
[expanded, mini],
);
const sidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: handlePageItemClick,
mini,
fullyExpanded: isFullyExpanded,
fullyCollapsed: isFullyCollapsed,
hasDrawerTransitions,
};
}, [
handlePageItemClick,
mini,
isFullyExpanded,
isFullyCollapsed,
hasDrawerTransitions,
]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
<Drawer
container={container}
variant="temporary"
open={expanded}
onClose={handleSetSidebarExpanded(false)}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: {
xs: 'block',
sm: disableCollapsibleSidebar ? 'block' : 'none',
md: 'none',
},
...getDrawerSharedSx(true),
}}
>
{getDrawerContent('phone')}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: {
xs: 'none',
sm: disableCollapsibleSidebar ? 'none' : 'block',
md: 'none',
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent('tablet')}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
...getDrawerSharedSx(false),
}}
>
{getDrawerContent('desktop')}
</Drawer>
</DashboardSidebarContext.Provider>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import Divider from '@mui/material/Divider';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { getDrawerSxTransitionMixin } from '../mixins';
export default function DashboardSidebarDividerItem() {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
return (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, 'margin')
: {}),
}}
/>
</li>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import Divider from '@mui/material/Divider';
import type {} from '@mui/material/themeCssVarsAugmentation';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { getDrawerSxTransitionMixin } from '../mixins';
export default function DashboardSidebarDividerItem() {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
return (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, 'margin')
: {}),
}}
/>
</li>
);
}

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import ListSubheader from '@mui/material/ListSubheader';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { DRAWER_WIDTH } from '../constants';
import { getDrawerSxTransitionMixin } from '../mixins';
function DashboardSidebarHeaderItem({ children }) {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const {
mini = false,
fullyExpanded = true,
hasDrawerTransitions,
} = sidebarContext;
return (
<ListSubheader
sx={{
fontSize: 12,
fontWeight: '600',
height: mini ? 0 : 36,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, 'height')
: {}),
px: 1.5,
py: 0,
minWidth: DRAWER_WIDTH,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
zIndex: 2,
}}
>
{children}
</ListSubheader>
);
}
DashboardSidebarHeaderItem.propTypes = {
children: PropTypes.node,
};
export default DashboardSidebarHeaderItem;

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import ListSubheader from '@mui/material/ListSubheader';
import type {} from '@mui/material/themeCssVarsAugmentation';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { DRAWER_WIDTH } from '../constants';
import { getDrawerSxTransitionMixin } from '../mixins';
export interface DashboardSidebarHeaderItemProps {
children?: React.ReactNode;
}
export default function DashboardSidebarHeaderItem({
children,
}: DashboardSidebarHeaderItemProps) {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const {
mini = false,
fullyExpanded = true,
hasDrawerTransitions,
} = sidebarContext;
return (
<ListSubheader
sx={{
fontSize: 12,
fontWeight: '600',
height: mini ? 0 : 36,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, 'height')
: {}),
px: 1.5,
py: 0,
minWidth: DRAWER_WIDTH,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
zIndex: 2,
}}
>
{children}
</ListSubheader>
);
}

View File

@@ -0,0 +1,256 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import Grow from '@mui/material/Grow';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Link } from 'react-router';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { MINI_DRAWER_WIDTH } from '../constants';
function DashboardSidebarPageItem({
id,
title,
icon,
href,
action,
defaultExpanded = false,
expanded = defaultExpanded,
selected = false,
disabled = false,
nestedNavigation,
}) {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const {
onPageItemClick,
mini = false,
fullyExpanded = true,
fullyCollapsed = false,
} = sidebarContext;
const [isHovered, setIsHovered] = React.useState(false);
const handleClick = React.useCallback(() => {
if (onPageItemClick) {
onPageItemClick(id, !!nestedNavigation);
}
}, [onPageItemClick, id, nestedNavigation]);
let nestedNavigationCollapseSx = { display: 'none' };
if (mini && fullyCollapsed) {
nestedNavigationCollapseSx = {
fontSize: 18,
position: 'absolute',
top: '41.5%',
right: '2px',
transform: 'translateY(-50%) rotate(-90deg)',
};
} else if (!mini && fullyExpanded) {
nestedNavigationCollapseSx = {
ml: 0.5,
fontSize: 20,
transform: `rotate(${expanded ? 0 : -90}deg)`,
transition: (theme) =>
theme.transitions.create('transform', {
easing: theme.transitions.easing.sharp,
duration: 100,
}),
};
}
const hasExternalHref = href
? href.startsWith('http://') || href.startsWith('https://')
: false;
const LinkComponent = hasExternalHref ? 'a' : Link;
const miniNestedNavigationSidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: onPageItemClick ?? (() => {}),
mini: false,
fullyExpanded: true,
fullyCollapsed: false,
hasDrawerTransitions: false,
};
}, [onPageItemClick]);
return (
<React.Fragment>
<ListItem
disablePadding
{...(nestedNavigation && mini
? {
onMouseEnter: () => {
setIsHovered(true);
},
onMouseLeave: () => {
setIsHovered(false);
},
}
: {})}
sx={{
display: 'block',
py: 0,
px: 1,
overflowX: 'hidden',
}}
>
<ListItemButton
selected={selected}
disabled={disabled}
sx={{
height: mini ? 50 : 'auto',
}}
{...(nestedNavigation && !mini
? {
onClick: handleClick,
}
: {})}
{...(!nestedNavigation
? {
LinkComponent,
...(hasExternalHref
? {
target: '_blank',
rel: 'noopener noreferrer',
}
: {}),
to: href,
onClick: handleClick,
}
: {})}
>
{icon || mini ? (
<Box
sx={
mini
? {
position: 'absolute',
left: '50%',
top: 'calc(50% - 6px)',
transform: 'translate(-50%, -50%)',
}
: {}
}
>
<ListItemIcon
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: mini ? 'center' : 'auto',
}}
>
{icon ?? null}
{!icon && mini ? (
<Avatar
sx={{
fontSize: 10,
height: 16,
width: 16,
}}
>
{title
.split(' ')
.slice(0, 2)
.map((titleWord) => titleWord.charAt(0).toUpperCase())}
</Avatar>
) : null}
</ListItemIcon>
{mini ? (
<Typography
variant="caption"
sx={{
position: 'absolute',
bottom: -18,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 10,
fontWeight: 500,
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: MINI_DRAWER_WIDTH - 28,
}}
>
{title}
</Typography>
) : null}
</Box>
) : null}
{!mini ? (
<ListItemText
primary={title}
sx={{
whiteSpace: 'nowrap',
zIndex: 1,
}}
/>
) : null}
{action && !mini && fullyExpanded ? action : null}
{nestedNavigation ? (
<ExpandMoreIcon sx={nestedNavigationCollapseSx} />
) : null}
</ListItemButton>
{nestedNavigation && mini ? (
<Grow in={isHovered}>
<Box
sx={{
position: 'fixed',
left: MINI_DRAWER_WIDTH - 2,
pl: '6px',
}}
>
<Paper
elevation={8}
sx={{
pt: 0.2,
pb: 0.2,
transform: 'translateY(-50px)',
}}
>
<DashboardSidebarContext.Provider
value={miniNestedNavigationSidebarContextValue}
>
{nestedNavigation}
</DashboardSidebarContext.Provider>
</Paper>
</Box>
</Grow>
) : null}
</ListItem>
{nestedNavigation && !mini ? (
<Collapse in={expanded} timeout="auto" unmountOnExit>
{nestedNavigation}
</Collapse>
) : null}
</React.Fragment>
);
}
DashboardSidebarPageItem.propTypes = {
action: PropTypes.node,
defaultExpanded: PropTypes.bool,
disabled: PropTypes.bool,
expanded: PropTypes.bool,
href: PropTypes.string.isRequired,
icon: PropTypes.node,
id: PropTypes.string.isRequired,
nestedNavigation: PropTypes.node,
selected: PropTypes.bool,
title: PropTypes.string.isRequired,
};
export default DashboardSidebarPageItem;

View File

@@ -0,0 +1,253 @@
import * as React from 'react';
import { type Theme, SxProps } from '@mui/material/styles';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import Grow from '@mui/material/Grow';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import type {} from '@mui/material/themeCssVarsAugmentation';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Link } from 'react-router';
import DashboardSidebarContext from '../context/DashboardSidebarContext';
import { MINI_DRAWER_WIDTH } from '../constants';
export interface DashboardSidebarPageItemProps {
id: string;
title: string;
icon?: React.ReactNode;
href: string;
action?: React.ReactNode;
defaultExpanded?: boolean;
expanded?: boolean;
selected?: boolean;
disabled?: boolean;
nestedNavigation?: React.ReactNode;
}
export default function DashboardSidebarPageItem({
id,
title,
icon,
href,
action,
defaultExpanded = false,
expanded = defaultExpanded,
selected = false,
disabled = false,
nestedNavigation,
}: DashboardSidebarPageItemProps) {
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error('Sidebar context was used without a provider.');
}
const {
onPageItemClick,
mini = false,
fullyExpanded = true,
fullyCollapsed = false,
} = sidebarContext;
const [isHovered, setIsHovered] = React.useState(false);
const handleClick = React.useCallback(() => {
if (onPageItemClick) {
onPageItemClick(id, !!nestedNavigation);
}
}, [onPageItemClick, id, nestedNavigation]);
let nestedNavigationCollapseSx: SxProps<Theme> = { display: 'none' };
if (mini && fullyCollapsed) {
nestedNavigationCollapseSx = {
fontSize: 18,
position: 'absolute',
top: '41.5%',
right: '2px',
transform: 'translateY(-50%) rotate(-90deg)',
};
} else if (!mini && fullyExpanded) {
nestedNavigationCollapseSx = {
ml: 0.5,
fontSize: 20,
transform: `rotate(${expanded ? 0 : -90}deg)`,
transition: (theme: Theme) =>
theme.transitions.create('transform', {
easing: theme.transitions.easing.sharp,
duration: 100,
}),
};
}
const hasExternalHref = href
? href.startsWith('http://') || href.startsWith('https://')
: false;
const LinkComponent = hasExternalHref ? 'a' : Link;
const miniNestedNavigationSidebarContextValue = React.useMemo(() => {
return {
onPageItemClick: onPageItemClick ?? (() => {}),
mini: false,
fullyExpanded: true,
fullyCollapsed: false,
hasDrawerTransitions: false,
};
}, [onPageItemClick]);
return (
<React.Fragment>
<ListItem
disablePadding
{...(nestedNavigation && mini
? {
onMouseEnter: () => {
setIsHovered(true);
},
onMouseLeave: () => {
setIsHovered(false);
},
}
: {})}
sx={{
display: 'block',
py: 0,
px: 1,
overflowX: 'hidden',
}}
>
<ListItemButton
selected={selected}
disabled={disabled}
sx={{
height: mini ? 50 : 'auto',
}}
{...(nestedNavigation && !mini
? {
onClick: handleClick,
}
: {})}
{...(!nestedNavigation
? {
LinkComponent,
...(hasExternalHref
? {
target: '_blank',
rel: 'noopener noreferrer',
}
: {}),
to: href,
onClick: handleClick,
}
: {})}
>
{icon || mini ? (
<Box
sx={
mini
? {
position: 'absolute',
left: '50%',
top: 'calc(50% - 6px)',
transform: 'translate(-50%, -50%)',
}
: {}
}
>
<ListItemIcon
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: mini ? 'center' : 'auto',
}}
>
{icon ?? null}
{!icon && mini ? (
<Avatar
sx={{
fontSize: 10,
height: 16,
width: 16,
}}
>
{title
.split(' ')
.slice(0, 2)
.map((titleWord) => titleWord.charAt(0).toUpperCase())}
</Avatar>
) : null}
</ListItemIcon>
{mini ? (
<Typography
variant="caption"
sx={{
position: 'absolute',
bottom: -18,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 10,
fontWeight: 500,
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: MINI_DRAWER_WIDTH - 28,
}}
>
{title}
</Typography>
) : null}
</Box>
) : null}
{!mini ? (
<ListItemText
primary={title}
sx={{
whiteSpace: 'nowrap',
zIndex: 1,
}}
/>
) : null}
{action && !mini && fullyExpanded ? action : null}
{nestedNavigation ? (
<ExpandMoreIcon sx={nestedNavigationCollapseSx} />
) : null}
</ListItemButton>
{nestedNavigation && mini ? (
<Grow in={isHovered}>
<Box
sx={{
position: 'fixed',
left: MINI_DRAWER_WIDTH - 2,
pl: '6px',
}}
>
<Paper
elevation={8}
sx={{
pt: 0.2,
pb: 0.2,
transform: 'translateY(-50px)',
}}
>
<DashboardSidebarContext.Provider
value={miniNestedNavigationSidebarContextValue}
>
{nestedNavigation}
</DashboardSidebarContext.Provider>
</Paper>
</Box>
</Grow>
) : null}
</ListItem>
{nestedNavigation && !mini ? (
<Collapse in={expanded} timeout="auto" unmountOnExit>
{nestedNavigation}
</Collapse>
) : null}
</React.Fragment>
);
}

View File

@@ -0,0 +1,108 @@
import * as React from 'react';
import { useNavigate } from 'react-router';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
createOne as createEmployee,
validate as validateEmployee,
} from '../data/employees';
import EmployeeForm from './EmployeeForm';
import PageContainer from './PageContainer';
const INITIAL_FORM_VALUES = {
role: 'Market',
isFullTime: true,
};
export default function EmployeeCreate() {
const navigate = useNavigate();
const notifications = useNotifications();
const [formState, setFormState] = React.useState(() => ({
values: INITIAL_FORM_VALUES,
errors: {},
}));
const formValues = formState.values;
const formErrors = formState.errors;
const setFormValues = React.useCallback((newFormValues) => {
setFormState((previousState) => ({
...previousState,
values: newFormValues,
}));
}, []);
const setFormErrors = React.useCallback((newFormErrors) => {
setFormState((previousState) => ({
...previousState,
errors: newFormErrors,
}));
}, []);
const handleFormFieldChange = React.useCallback(
(name, value) => {
const validateField = async (values) => {
const { issues } = validateEmployee(values);
setFormErrors({
...formErrors,
[name]: issues?.find((issue) => issue.path?.[0] === name)?.message,
});
};
const newFormValues = { ...formValues, [name]: value };
setFormValues(newFormValues);
validateField(newFormValues);
},
[formValues, formErrors, setFormErrors, setFormValues],
);
const handleFormReset = React.useCallback(() => {
setFormValues(INITIAL_FORM_VALUES);
}, [setFormValues]);
const handleFormSubmit = React.useCallback(async () => {
const { issues } = validateEmployee(formValues);
if (issues && issues.length > 0) {
setFormErrors(
Object.fromEntries(issues.map((issue) => [issue.path?.[0], issue.message])),
);
return;
}
setFormErrors({});
try {
await createEmployee(formValues);
notifications.show('Employee created successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
navigate('/employees');
} catch (createError) {
notifications.show(
`Failed to create employee. Reason: ${createError.message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
throw createError;
}
}, [formValues, navigate, notifications, setFormErrors]);
return (
<PageContainer
title="New Employee"
breadcrumbs={[{ title: 'Employees', path: '/employees' }, { title: 'New' }]}
>
<EmployeeForm
formState={formState}
onFieldChange={handleFormFieldChange}
onSubmit={handleFormSubmit}
onReset={handleFormReset}
submitButtonLabel="Create"
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,118 @@
import * as React from 'react';
import { useNavigate } from 'react-router';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
createOne as createEmployee,
validate as validateEmployee,
type Employee,
} from '../data/employees';
import EmployeeForm, {
type FormFieldValue,
type EmployeeFormState,
} from './EmployeeForm';
import PageContainer from './PageContainer';
const INITIAL_FORM_VALUES: Partial<EmployeeFormState['values']> = {
role: 'Market',
isFullTime: true,
};
export default function EmployeeCreate() {
const navigate = useNavigate();
const notifications = useNotifications();
const [formState, setFormState] = React.useState<EmployeeFormState>(() => ({
values: INITIAL_FORM_VALUES,
errors: {},
}));
const formValues = formState.values;
const formErrors = formState.errors;
const setFormValues = React.useCallback(
(newFormValues: Partial<EmployeeFormState['values']>) => {
setFormState((previousState) => ({
...previousState,
values: newFormValues,
}));
},
[],
);
const setFormErrors = React.useCallback(
(newFormErrors: Partial<EmployeeFormState['errors']>) => {
setFormState((previousState) => ({
...previousState,
errors: newFormErrors,
}));
},
[],
);
const handleFormFieldChange = React.useCallback(
(name: keyof EmployeeFormState['values'], value: FormFieldValue) => {
const validateField = async (values: Partial<EmployeeFormState['values']>) => {
const { issues } = validateEmployee(values);
setFormErrors({
...formErrors,
[name]: issues?.find((issue) => issue.path?.[0] === name)?.message,
});
};
const newFormValues = { ...formValues, [name]: value };
setFormValues(newFormValues);
validateField(newFormValues);
},
[formValues, formErrors, setFormErrors, setFormValues],
);
const handleFormReset = React.useCallback(() => {
setFormValues(INITIAL_FORM_VALUES);
}, [setFormValues]);
const handleFormSubmit = React.useCallback(async () => {
const { issues } = validateEmployee(formValues);
if (issues && issues.length > 0) {
setFormErrors(
Object.fromEntries(issues.map((issue) => [issue.path?.[0], issue.message])),
);
return;
}
setFormErrors({});
try {
await createEmployee(formValues as Omit<Employee, 'id'>);
notifications.show('Employee created successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
navigate('/employees');
} catch (createError) {
notifications.show(
`Failed to create employee. Reason: ${(createError as Error).message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
throw createError;
}
}, [formValues, navigate, notifications, setFormErrors]);
return (
<PageContainer
title="New Employee"
breadcrumbs={[{ title: 'Employees', path: '/employees' }, { title: 'New' }]}
>
<EmployeeForm
formState={formState}
onFieldChange={handleFormFieldChange}
onSubmit={handleFormSubmit}
onReset={handleFormReset}
submitButtonLabel="Create"
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,191 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { useNavigate, useParams } from 'react-router';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
getOne as getEmployee,
updateOne as updateEmployee,
validate as validateEmployee,
} from '../data/employees';
import EmployeeForm from './EmployeeForm';
import PageContainer from './PageContainer';
function EmployeeEditForm({ initialValues, onSubmit }) {
const { employeeId } = useParams();
const navigate = useNavigate();
const notifications = useNotifications();
const [formState, setFormState] = React.useState(() => ({
values: initialValues,
errors: {},
}));
const formValues = formState.values;
const formErrors = formState.errors;
const setFormValues = React.useCallback((newFormValues) => {
setFormState((previousState) => ({
...previousState,
values: newFormValues,
}));
}, []);
const setFormErrors = React.useCallback((newFormErrors) => {
setFormState((previousState) => ({
...previousState,
errors: newFormErrors,
}));
}, []);
const handleFormFieldChange = React.useCallback(
(name, value) => {
const validateField = async (values) => {
const { issues } = validateEmployee(values);
setFormErrors({
...formErrors,
[name]: issues?.find((issue) => issue.path?.[0] === name)?.message,
});
};
const newFormValues = { ...formValues, [name]: value };
setFormValues(newFormValues);
validateField(newFormValues);
},
[formValues, formErrors, setFormErrors, setFormValues],
);
const handleFormReset = React.useCallback(() => {
setFormValues(initialValues);
}, [initialValues, setFormValues]);
const handleFormSubmit = React.useCallback(async () => {
const { issues } = validateEmployee(formValues);
if (issues && issues.length > 0) {
setFormErrors(
Object.fromEntries(issues.map((issue) => [issue.path?.[0], issue.message])),
);
return;
}
setFormErrors({});
try {
await onSubmit(formValues);
notifications.show('Employee edited successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
navigate('/employees');
} catch (editError) {
notifications.show(`Failed to edit employee. Reason: ${editError.message}`, {
severity: 'error',
autoHideDuration: 3000,
});
throw editError;
}
}, [formValues, navigate, notifications, onSubmit, setFormErrors]);
return (
<EmployeeForm
formState={formState}
onFieldChange={handleFormFieldChange}
onSubmit={handleFormSubmit}
onReset={handleFormReset}
submitButtonLabel="Save"
backButtonPath={`/employees/${employeeId}`}
/>
);
}
EmployeeEditForm.propTypes = {
initialValues: PropTypes.shape({
age: PropTypes.number,
isFullTime: PropTypes.bool,
joinDate: PropTypes.string,
name: PropTypes.string,
role: PropTypes.oneOf(['Development', 'Finance', 'Market']),
}).isRequired,
onSubmit: PropTypes.func.isRequired,
};
export default function EmployeeEdit() {
const { employeeId } = useParams();
const [employee, setEmployee] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const showData = await getEmployee(Number(employeeId));
setEmployee(showData);
} catch (showDataError) {
setError(showDataError);
}
setIsLoading(false);
}, [employeeId]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleSubmit = React.useCallback(
async (formValues) => {
const updatedData = await updateEmployee(Number(employeeId), formValues);
setEmployee(updatedData);
},
[employeeId],
);
const renderEdit = React.useMemo(() => {
if (isLoading) {
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
m: 1,
}}
>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
);
}
return employee ? (
<EmployeeEditForm initialValues={employee} onSubmit={handleSubmit} />
) : null;
}, [isLoading, error, employee, handleSubmit]);
return (
<PageContainer
title={`Edit Employee ${employeeId}`}
breadcrumbs={[
{ title: 'Employees', path: '/employees' },
{ title: `Employee ${employeeId}`, path: `/employees/${employeeId}` },
{ title: 'Edit' },
]}
>
<Box sx={{ display: 'flex', flex: 1 }}>{renderEdit}</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,198 @@
import * as React from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { useNavigate, useParams } from 'react-router';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
getOne as getEmployee,
updateOne as updateEmployee,
validate as validateEmployee,
type Employee,
} from '../data/employees';
import EmployeeForm, {
type FormFieldValue,
type EmployeeFormState,
} from './EmployeeForm';
import PageContainer from './PageContainer';
function EmployeeEditForm({
initialValues,
onSubmit,
}: {
initialValues: Partial<EmployeeFormState['values']>;
onSubmit: (formValues: Partial<EmployeeFormState['values']>) => Promise<void>;
}) {
const { employeeId } = useParams();
const navigate = useNavigate();
const notifications = useNotifications();
const [formState, setFormState] = React.useState<EmployeeFormState>(() => ({
values: initialValues,
errors: {},
}));
const formValues = formState.values;
const formErrors = formState.errors;
const setFormValues = React.useCallback(
(newFormValues: Partial<EmployeeFormState['values']>) => {
setFormState((previousState) => ({
...previousState,
values: newFormValues,
}));
},
[],
);
const setFormErrors = React.useCallback(
(newFormErrors: Partial<EmployeeFormState['errors']>) => {
setFormState((previousState) => ({
...previousState,
errors: newFormErrors,
}));
},
[],
);
const handleFormFieldChange = React.useCallback(
(name: keyof EmployeeFormState['values'], value: FormFieldValue) => {
const validateField = async (values: Partial<EmployeeFormState['values']>) => {
const { issues } = validateEmployee(values);
setFormErrors({
...formErrors,
[name]: issues?.find((issue) => issue.path?.[0] === name)?.message,
});
};
const newFormValues = { ...formValues, [name]: value };
setFormValues(newFormValues);
validateField(newFormValues);
},
[formValues, formErrors, setFormErrors, setFormValues],
);
const handleFormReset = React.useCallback(() => {
setFormValues(initialValues);
}, [initialValues, setFormValues]);
const handleFormSubmit = React.useCallback(async () => {
const { issues } = validateEmployee(formValues);
if (issues && issues.length > 0) {
setFormErrors(
Object.fromEntries(issues.map((issue) => [issue.path?.[0], issue.message])),
);
return;
}
setFormErrors({});
try {
await onSubmit(formValues);
notifications.show('Employee edited successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
navigate('/employees');
} catch (editError) {
notifications.show(
`Failed to edit employee. Reason: ${(editError as Error).message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
throw editError;
}
}, [formValues, navigate, notifications, onSubmit, setFormErrors]);
return (
<EmployeeForm
formState={formState}
onFieldChange={handleFormFieldChange}
onSubmit={handleFormSubmit}
onReset={handleFormReset}
submitButtonLabel="Save"
backButtonPath={`/employees/${employeeId}`}
/>
);
}
export default function EmployeeEdit() {
const { employeeId } = useParams();
const [employee, setEmployee] = React.useState<Employee | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const showData = await getEmployee(Number(employeeId));
setEmployee(showData);
} catch (showDataError) {
setError(showDataError as Error);
}
setIsLoading(false);
}, [employeeId]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleSubmit = React.useCallback(
async (formValues: Partial<EmployeeFormState['values']>) => {
const updatedData = await updateEmployee(Number(employeeId), formValues);
setEmployee(updatedData);
},
[employeeId],
);
const renderEdit = React.useMemo(() => {
if (isLoading) {
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
m: 1,
}}
>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
);
}
return employee ? (
<EmployeeEditForm initialValues={employee} onSubmit={handleSubmit} />
) : null;
}, [isLoading, error, employee, handleSubmit]);
return (
<PageContainer
title={`Edit Employee ${employeeId}`}
breadcrumbs={[
{ title: 'Employees', path: '/employees' },
{ title: `Employee ${employeeId}`, path: `/employees/${employeeId}` },
{ title: 'Edit' },
]}
>
<Box sx={{ display: 'flex', flex: 1 }}>{renderEdit}</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,238 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import FormHelperText from '@mui/material/FormHelperText';
import Grid from '@mui/material/Grid';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router';
import dayjs from 'dayjs';
function EmployeeForm(props) {
const {
formState,
onFieldChange,
onSubmit,
onReset,
submitButtonLabel,
backButtonPath,
} = props;
const formValues = formState.values;
const formErrors = formState.errors;
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const handleSubmit = React.useCallback(
async (event) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formValues);
} finally {
setIsSubmitting(false);
}
},
[formValues, onSubmit],
);
const handleTextFieldChange = React.useCallback(
(event) => {
onFieldChange(event.target.name, event.target.value);
},
[onFieldChange],
);
const handleNumberFieldChange = React.useCallback(
(event) => {
onFieldChange(event.target.name, Number(event.target.value));
},
[onFieldChange],
);
const handleCheckboxFieldChange = React.useCallback(
(event, checked) => {
onFieldChange(event.target.name, checked);
},
[onFieldChange],
);
const handleDateFieldChange = React.useCallback(
(fieldName) => (value) => {
if (value?.isValid()) {
onFieldChange(fieldName, value.toISOString() ?? null);
} else if (formValues[fieldName]) {
onFieldChange(fieldName, null);
}
},
[formValues, onFieldChange],
);
const handleSelectFieldChange = React.useCallback(
(event) => {
onFieldChange(event.target.name, event.target.value);
},
[onFieldChange],
);
const handleReset = React.useCallback(() => {
if (onReset) {
onReset(formValues);
}
}, [formValues, onReset]);
const handleBack = React.useCallback(() => {
navigate(backButtonPath ?? '/employees');
}, [navigate, backButtonPath]);
return (
<Box
component="form"
onSubmit={handleSubmit}
noValidate
autoComplete="off"
onReset={handleReset}
sx={{ width: '100%' }}
>
<FormGroup>
<Grid container spacing={2} sx={{ mb: 2, width: '100%' }}>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<TextField
value={formValues.name ?? ''}
onChange={handleTextFieldChange}
name="name"
label="Name"
error={!!formErrors.name}
helperText={formErrors.name ?? ' '}
fullWidth
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<TextField
type="number"
value={formValues.age ?? ''}
onChange={handleNumberFieldChange}
name="age"
label="Age"
error={!!formErrors.age}
helperText={formErrors.age ?? ' '}
fullWidth
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
value={formValues.joinDate ? dayjs(formValues.joinDate) : null}
onChange={handleDateFieldChange('joinDate')}
name="joinDate"
label="Join date"
slotProps={{
textField: {
error: !!formErrors.joinDate,
helperText: formErrors.joinDate ?? ' ',
fullWidth: true,
},
}}
/>
</LocalizationProvider>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<FormControl error={!!formErrors.role} fullWidth>
<InputLabel id="employee-role-label">Department</InputLabel>
<Select
value={formValues.role ?? ''}
onChange={handleSelectFieldChange}
labelId="employee-role-label"
name="role"
label="Department"
defaultValue=""
fullWidth
>
<MenuItem value="Market">Market</MenuItem>
<MenuItem value="Finance">Finance</MenuItem>
<MenuItem value="Development">Development</MenuItem>
</Select>
<FormHelperText>{formErrors.role ?? ' '}</FormHelperText>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<FormControl>
<FormControlLabel
name="isFullTime"
control={
<Checkbox
size="large"
checked={formValues.isFullTime ?? false}
onChange={handleCheckboxFieldChange}
/>
}
label="Full-time"
/>
<FormHelperText error={!!formErrors.isFullTime}>
{formErrors.isFullTime ?? ' '}
</FormHelperText>
</FormControl>
</Grid>
</Grid>
</FormGroup>
<Stack direction="row" spacing={2} justifyContent="space-between">
<Button
variant="contained"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
>
Back
</Button>
<Button
type="submit"
variant="contained"
size="large"
loading={isSubmitting}
>
{submitButtonLabel}
</Button>
</Stack>
</Box>
);
}
EmployeeForm.propTypes = {
backButtonPath: PropTypes.string,
formState: PropTypes.shape({
errors: PropTypes.shape({
age: PropTypes.string,
isFullTime: PropTypes.string,
joinDate: PropTypes.string,
name: PropTypes.string,
role: PropTypes.string,
}).isRequired,
values: PropTypes.shape({
age: PropTypes.number,
isFullTime: PropTypes.bool,
joinDate: PropTypes.string,
name: PropTypes.string,
role: PropTypes.oneOf(['Development', 'Finance', 'Market']),
}).isRequired,
}).isRequired,
onFieldChange: PropTypes.func.isRequired,
onReset: PropTypes.func,
onSubmit: PropTypes.func.isRequired,
submitButtonLabel: PropTypes.string.isRequired,
};
export default EmployeeForm;

View File

@@ -0,0 +1,240 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
import FormHelperText from '@mui/material/FormHelperText';
import Grid from '@mui/material/Grid';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectChangeEvent, SelectProps } from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router';
import dayjs, { Dayjs } from 'dayjs';
import type { Employee } from '../data/employees';
export interface EmployeeFormState {
values: Partial<Omit<Employee, 'id'>>;
errors: Partial<Record<keyof EmployeeFormState['values'], string>>;
}
export type FormFieldValue = string | string[] | number | boolean | File | null;
export interface EmployeeFormProps {
formState: EmployeeFormState;
onFieldChange: (
name: keyof EmployeeFormState['values'],
value: FormFieldValue,
) => void;
onSubmit: (formValues: Partial<EmployeeFormState['values']>) => Promise<void>;
onReset?: (formValues: Partial<EmployeeFormState['values']>) => void;
submitButtonLabel: string;
backButtonPath?: string;
}
export default function EmployeeForm(props: EmployeeFormProps) {
const {
formState,
onFieldChange,
onSubmit,
onReset,
submitButtonLabel,
backButtonPath,
} = props;
const formValues = formState.values;
const formErrors = formState.errors;
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const handleSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formValues);
} finally {
setIsSubmitting(false);
}
},
[formValues, onSubmit],
);
const handleTextFieldChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onFieldChange(
event.target.name as keyof EmployeeFormState['values'],
event.target.value,
);
},
[onFieldChange],
);
const handleNumberFieldChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onFieldChange(
event.target.name as keyof EmployeeFormState['values'],
Number(event.target.value),
);
},
[onFieldChange],
);
const handleCheckboxFieldChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>, checked: boolean) => {
onFieldChange(event.target.name as keyof EmployeeFormState['values'], checked);
},
[onFieldChange],
);
const handleDateFieldChange = React.useCallback(
(fieldName: keyof EmployeeFormState['values']) => (value: Dayjs | null) => {
if (value?.isValid()) {
onFieldChange(fieldName, value.toISOString() ?? null);
} else if (formValues[fieldName]) {
onFieldChange(fieldName, null);
}
},
[formValues, onFieldChange],
);
const handleSelectFieldChange = React.useCallback(
(event: SelectChangeEvent) => {
onFieldChange(
event.target.name as keyof EmployeeFormState['values'],
event.target.value,
);
},
[onFieldChange],
);
const handleReset = React.useCallback(() => {
if (onReset) {
onReset(formValues);
}
}, [formValues, onReset]);
const handleBack = React.useCallback(() => {
navigate(backButtonPath ?? '/employees');
}, [navigate, backButtonPath]);
return (
<Box
component="form"
onSubmit={handleSubmit}
noValidate
autoComplete="off"
onReset={handleReset}
sx={{ width: '100%' }}
>
<FormGroup>
<Grid container spacing={2} sx={{ mb: 2, width: '100%' }}>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<TextField
value={formValues.name ?? ''}
onChange={handleTextFieldChange}
name="name"
label="Name"
error={!!formErrors.name}
helperText={formErrors.name ?? ' '}
fullWidth
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<TextField
type="number"
value={formValues.age ?? ''}
onChange={handleNumberFieldChange}
name="age"
label="Age"
error={!!formErrors.age}
helperText={formErrors.age ?? ' '}
fullWidth
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
value={formValues.joinDate ? dayjs(formValues.joinDate) : null}
onChange={handleDateFieldChange('joinDate')}
name="joinDate"
label="Join date"
slotProps={{
textField: {
error: !!formErrors.joinDate,
helperText: formErrors.joinDate ?? ' ',
fullWidth: true,
},
}}
/>
</LocalizationProvider>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<FormControl error={!!formErrors.role} fullWidth>
<InputLabel id="employee-role-label">Department</InputLabel>
<Select
value={formValues.role ?? ''}
onChange={handleSelectFieldChange as SelectProps['onChange']}
labelId="employee-role-label"
name="role"
label="Department"
defaultValue=""
fullWidth
>
<MenuItem value="Market">Market</MenuItem>
<MenuItem value="Finance">Finance</MenuItem>
<MenuItem value="Development">Development</MenuItem>
</Select>
<FormHelperText>{formErrors.role ?? ' '}</FormHelperText>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6 }} sx={{ display: 'flex' }}>
<FormControl>
<FormControlLabel
name="isFullTime"
control={
<Checkbox
size="large"
checked={formValues.isFullTime ?? false}
onChange={handleCheckboxFieldChange}
/>
}
label="Full-time"
/>
<FormHelperText error={!!formErrors.isFullTime}>
{formErrors.isFullTime ?? ' '}
</FormHelperText>
</FormControl>
</Grid>
</Grid>
</FormGroup>
<Stack direction="row" spacing={2} justifyContent="space-between">
<Button
variant="contained"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
>
Back
</Button>
<Button
type="submit"
variant="contained"
size="large"
loading={isSubmitting}
>
{submitButtonLabel}
</Button>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,327 @@
import * as React from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { DataGrid, GridActionsCellItem, gridClasses } from '@mui/x-data-grid';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useDialogs } from '../hooks/useDialogs/useDialogs';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
deleteOne as deleteEmployee,
getMany as getEmployees,
} from '../data/employees';
import PageContainer from './PageContainer';
const INITIAL_PAGE_SIZE = 10;
export default function EmployeeList() {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const dialogs = useDialogs();
const notifications = useNotifications();
const [paginationModel, setPaginationModel] = React.useState({
page: searchParams.get('page') ? Number(searchParams.get('page')) : 0,
pageSize: searchParams.get('pageSize')
? Number(searchParams.get('pageSize'))
: INITIAL_PAGE_SIZE,
});
const [filterModel, setFilterModel] = React.useState(
searchParams.get('filter')
? JSON.parse(searchParams.get('filter') ?? '')
: { items: [] },
);
const [sortModel, setSortModel] = React.useState(
searchParams.get('sort') ? JSON.parse(searchParams.get('sort') ?? '') : [],
);
const [rowsState, setRowsState] = React.useState({
rows: [],
rowCount: 0,
});
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const handlePaginationModelChange = React.useCallback(
(model) => {
setPaginationModel(model);
searchParams.set('page', String(model.page));
searchParams.set('pageSize', String(model.pageSize));
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const handleFilterModelChange = React.useCallback(
(model) => {
setFilterModel(model);
if (
model.items.length > 0 ||
(model.quickFilterValues && model.quickFilterValues.length > 0)
) {
searchParams.set('filter', JSON.stringify(model));
} else {
searchParams.delete('filter');
}
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const handleSortModelChange = React.useCallback(
(model) => {
setSortModel(model);
if (model.length > 0) {
searchParams.set('sort', JSON.stringify(model));
} else {
searchParams.delete('sort');
}
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const listData = await getEmployees({
paginationModel,
sortModel,
filterModel,
});
setRowsState({
rows: listData.items,
rowCount: listData.itemCount,
});
} catch (listDataError) {
setError(listDataError);
}
setIsLoading(false);
}, [paginationModel, sortModel, filterModel]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleRefresh = React.useCallback(() => {
if (!isLoading) {
loadData();
}
}, [isLoading, loadData]);
const handleRowClick = React.useCallback(
({ row }) => {
navigate(`/employees/${row.id}`);
},
[navigate],
);
const handleCreateClick = React.useCallback(() => {
navigate('/employees/new');
}, [navigate]);
const handleRowEdit = React.useCallback(
(employee) => () => {
navigate(`/employees/${employee.id}/edit`);
},
[navigate],
);
const handleRowDelete = React.useCallback(
(employee) => async () => {
const confirmed = await dialogs.confirm(
`Do you wish to delete ${employee.name}?`,
{
title: `Delete employee?`,
severity: 'error',
okText: 'Delete',
cancelText: 'Cancel',
},
);
if (confirmed) {
setIsLoading(true);
try {
await deleteEmployee(Number(employee.id));
notifications.show('Employee deleted successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
loadData();
} catch (deleteError) {
notifications.show(
`Failed to delete employee. Reason:' ${deleteError.message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
}
setIsLoading(false);
}
},
[dialogs, notifications, loadData],
);
const initialState = React.useMemo(
() => ({
pagination: { paginationModel: { pageSize: INITIAL_PAGE_SIZE } },
}),
[],
);
const columns = React.useMemo(
() => [
{ field: 'id', headerName: 'ID' },
{ field: 'name', headerName: 'Name', width: 140 },
{ field: 'age', headerName: 'Age', type: 'number' },
{
field: 'joinDate',
headerName: 'Join date',
type: 'date',
valueGetter: (value) => value && new Date(value),
width: 140,
},
{
field: 'role',
headerName: 'Department',
type: 'singleSelect',
valueOptions: ['Market', 'Finance', 'Development'],
width: 160,
},
{ field: 'isFullTime', headerName: 'Full-time', type: 'boolean' },
{
field: 'actions',
type: 'actions',
flex: 1,
align: 'right',
getActions: ({ row }) => [
<GridActionsCellItem
key="edit-item"
icon={<EditIcon />}
label="Edit"
onClick={handleRowEdit(row)}
/>,
<GridActionsCellItem
key="delete-item"
icon={<DeleteIcon />}
label="Delete"
onClick={handleRowDelete(row)}
/>,
],
},
],
[handleRowEdit, handleRowDelete],
);
const pageTitle = 'Employees';
return (
<PageContainer
title={pageTitle}
breadcrumbs={[{ title: pageTitle }]}
actions={
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Reload data" placement="right" enterDelay={1000}>
<div>
<IconButton size="small" aria-label="refresh" onClick={handleRefresh}>
<RefreshIcon />
</IconButton>
</div>
</Tooltip>
<Button
variant="contained"
onClick={handleCreateClick}
startIcon={<AddIcon />}
>
Create
</Button>
</Stack>
}
>
<Box sx={{ flex: 1, width: '100%' }}>
{error ? (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
) : (
<DataGrid
rows={rowsState.rows}
rowCount={rowsState.rowCount}
columns={columns}
pagination
sortingMode="server"
filterMode="server"
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationModelChange}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
filterModel={filterModel}
onFilterModelChange={handleFilterModelChange}
disableRowSelectionOnClick
onRowClick={handleRowClick}
loading={isLoading}
initialState={initialState}
showToolbar
pageSizeOptions={[5, INITIAL_PAGE_SIZE, 25]}
sx={{
[`& .${gridClasses.columnHeader}, & .${gridClasses.cell}`]: {
outline: 'transparent',
},
[`& .${gridClasses.columnHeader}:focus-within, & .${gridClasses.cell}:focus-within`]:
{
outline: 'none',
},
[`& .${gridClasses.row}:hover`]: {
cursor: 'pointer',
},
}}
slotProps={{
loadingOverlay: {
variant: 'circular-progress',
noRowsVariant: 'circular-progress',
},
baseIconButton: {
size: 'small',
},
}}
/>
)}
</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,340 @@
import * as React from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import {
DataGrid,
GridActionsCellItem,
GridColDef,
GridFilterModel,
GridPaginationModel,
GridSortModel,
GridEventListener,
gridClasses,
} from '@mui/x-data-grid';
import AddIcon from '@mui/icons-material/Add';
import RefreshIcon from '@mui/icons-material/Refresh';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useDialogs } from '../hooks/useDialogs/useDialogs';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
deleteOne as deleteEmployee,
getMany as getEmployees,
type Employee,
} from '../data/employees';
import PageContainer from './PageContainer';
const INITIAL_PAGE_SIZE = 10;
export default function EmployeeList() {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const dialogs = useDialogs();
const notifications = useNotifications();
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: searchParams.get('page') ? Number(searchParams.get('page')) : 0,
pageSize: searchParams.get('pageSize')
? Number(searchParams.get('pageSize'))
: INITIAL_PAGE_SIZE,
});
const [filterModel, setFilterModel] = React.useState<GridFilterModel>(
searchParams.get('filter')
? JSON.parse(searchParams.get('filter') ?? '')
: { items: [] },
);
const [sortModel, setSortModel] = React.useState<GridSortModel>(
searchParams.get('sort') ? JSON.parse(searchParams.get('sort') ?? '') : [],
);
const [rowsState, setRowsState] = React.useState<{
rows: Employee[];
rowCount: number;
}>({
rows: [],
rowCount: 0,
});
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const handlePaginationModelChange = React.useCallback(
(model: GridPaginationModel) => {
setPaginationModel(model);
searchParams.set('page', String(model.page));
searchParams.set('pageSize', String(model.pageSize));
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const handleFilterModelChange = React.useCallback(
(model: GridFilterModel) => {
setFilterModel(model);
if (
model.items.length > 0 ||
(model.quickFilterValues && model.quickFilterValues.length > 0)
) {
searchParams.set('filter', JSON.stringify(model));
} else {
searchParams.delete('filter');
}
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const handleSortModelChange = React.useCallback(
(model: GridSortModel) => {
setSortModel(model);
if (model.length > 0) {
searchParams.set('sort', JSON.stringify(model));
} else {
searchParams.delete('sort');
}
const newSearchParamsString = searchParams.toString();
navigate(
`${pathname}${newSearchParamsString ? '?' : ''}${newSearchParamsString}`,
);
},
[navigate, pathname, searchParams],
);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const listData = await getEmployees({
paginationModel,
sortModel,
filterModel,
});
setRowsState({
rows: listData.items,
rowCount: listData.itemCount,
});
} catch (listDataError) {
setError(listDataError as Error);
}
setIsLoading(false);
}, [paginationModel, sortModel, filterModel]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleRefresh = React.useCallback(() => {
if (!isLoading) {
loadData();
}
}, [isLoading, loadData]);
const handleRowClick = React.useCallback<GridEventListener<'rowClick'>>(
({ row }) => {
navigate(`/employees/${row.id}`);
},
[navigate],
);
const handleCreateClick = React.useCallback(() => {
navigate('/employees/new');
}, [navigate]);
const handleRowEdit = React.useCallback(
(employee: Employee) => () => {
navigate(`/employees/${employee.id}/edit`);
},
[navigate],
);
const handleRowDelete = React.useCallback(
(employee: Employee) => async () => {
const confirmed = await dialogs.confirm(
`Do you wish to delete ${employee.name}?`,
{
title: `Delete employee?`,
severity: 'error',
okText: 'Delete',
cancelText: 'Cancel',
},
);
if (confirmed) {
setIsLoading(true);
try {
await deleteEmployee(Number(employee.id));
notifications.show('Employee deleted successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
loadData();
} catch (deleteError) {
notifications.show(
`Failed to delete employee. Reason:' ${(deleteError as Error).message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
}
setIsLoading(false);
}
},
[dialogs, notifications, loadData],
);
const initialState = React.useMemo(
() => ({
pagination: { paginationModel: { pageSize: INITIAL_PAGE_SIZE } },
}),
[],
);
const columns = React.useMemo<GridColDef[]>(
() => [
{ field: 'id', headerName: 'ID' },
{ field: 'name', headerName: 'Name', width: 140 },
{ field: 'age', headerName: 'Age', type: 'number' },
{
field: 'joinDate',
headerName: 'Join date',
type: 'date',
valueGetter: (value) => value && new Date(value),
width: 140,
},
{
field: 'role',
headerName: 'Department',
type: 'singleSelect',
valueOptions: ['Market', 'Finance', 'Development'],
width: 160,
},
{ field: 'isFullTime', headerName: 'Full-time', type: 'boolean' },
{
field: 'actions',
type: 'actions',
flex: 1,
align: 'right',
getActions: ({ row }) => [
<GridActionsCellItem
key="edit-item"
icon={<EditIcon />}
label="Edit"
onClick={handleRowEdit(row)}
/>,
<GridActionsCellItem
key="delete-item"
icon={<DeleteIcon />}
label="Delete"
onClick={handleRowDelete(row)}
/>,
],
},
],
[handleRowEdit, handleRowDelete],
);
const pageTitle = 'Employees';
return (
<PageContainer
title={pageTitle}
breadcrumbs={[{ title: pageTitle }]}
actions={
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Reload data" placement="right" enterDelay={1000}>
<div>
<IconButton size="small" aria-label="refresh" onClick={handleRefresh}>
<RefreshIcon />
</IconButton>
</div>
</Tooltip>
<Button
variant="contained"
onClick={handleCreateClick}
startIcon={<AddIcon />}
>
Create
</Button>
</Stack>
}
>
<Box sx={{ flex: 1, width: '100%' }}>
{error ? (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
) : (
<DataGrid
rows={rowsState.rows}
rowCount={rowsState.rowCount}
columns={columns}
pagination
sortingMode="server"
filterMode="server"
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationModelChange}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
filterModel={filterModel}
onFilterModelChange={handleFilterModelChange}
disableRowSelectionOnClick
onRowClick={handleRowClick}
loading={isLoading}
initialState={initialState}
showToolbar
pageSizeOptions={[5, INITIAL_PAGE_SIZE, 25]}
sx={{
[`& .${gridClasses.columnHeader}, & .${gridClasses.cell}`]: {
outline: 'transparent',
},
[`& .${gridClasses.columnHeader}:focus-within, & .${gridClasses.cell}:focus-within`]:
{
outline: 'none',
},
[`& .${gridClasses.row}:hover`]: {
cursor: 'pointer',
},
}}
slotProps={{
loadingOverlay: {
variant: 'circular-progress',
noRowsVariant: 'circular-progress',
},
baseIconButton: {
size: 'small',
},
}}
/>
)}
</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,221 @@
import * as React from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate, useParams } from 'react-router';
import dayjs from 'dayjs';
import { useDialogs } from '../hooks/useDialogs/useDialogs';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
deleteOne as deleteEmployee,
getOne as getEmployee,
} from '../data/employees';
import PageContainer from './PageContainer';
export default function EmployeeShow() {
const { employeeId } = useParams();
const navigate = useNavigate();
const dialogs = useDialogs();
const notifications = useNotifications();
const [employee, setEmployee] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const showData = await getEmployee(Number(employeeId));
setEmployee(showData);
} catch (showDataError) {
setError(showDataError);
}
setIsLoading(false);
}, [employeeId]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleEmployeeEdit = React.useCallback(() => {
navigate(`/employees/${employeeId}/edit`);
}, [navigate, employeeId]);
const handleEmployeeDelete = React.useCallback(async () => {
if (!employee) {
return;
}
const confirmed = await dialogs.confirm(
`Do you wish to delete ${employee.name}?`,
{
title: `Delete employee?`,
severity: 'error',
okText: 'Delete',
cancelText: 'Cancel',
},
);
if (confirmed) {
setIsLoading(true);
try {
await deleteEmployee(Number(employeeId));
navigate('/employees');
notifications.show('Employee deleted successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
} catch (deleteError) {
notifications.show(
`Failed to delete employee. Reason:' ${deleteError.message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
}
setIsLoading(false);
}
}, [employee, dialogs, employeeId, navigate, notifications]);
const handleBack = React.useCallback(() => {
navigate('/employees');
}, [navigate]);
const renderShow = React.useMemo(() => {
if (isLoading) {
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
m: 1,
}}
>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
);
}
return employee ? (
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Grid container spacing={2} sx={{ width: '100%' }}>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Name</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.name}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Age</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.age}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Join date</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{dayjs(employee.joinDate).format('MMMM D, YYYY')}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Department</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.role}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Full-time</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.isFullTime ? 'Yes' : 'No'}
</Typography>
</Paper>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Stack direction="row" spacing={2} justifyContent="space-between">
<Button
variant="contained"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
>
Back
</Button>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
startIcon={<EditIcon />}
onClick={handleEmployeeEdit}
>
Edit
</Button>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleEmployeeDelete}
>
Delete
</Button>
</Stack>
</Stack>
</Box>
) : null;
}, [
isLoading,
error,
employee,
handleBack,
handleEmployeeEdit,
handleEmployeeDelete,
]);
const pageTitle = `Employee ${employeeId}`;
return (
<PageContainer
title={pageTitle}
breadcrumbs={[
{ title: 'Employees', path: '/employees' },
{ title: pageTitle },
]}
>
<Box sx={{ display: 'flex', flex: 1, width: '100%' }}>{renderShow}</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,222 @@
import * as React from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate, useParams } from 'react-router';
import dayjs from 'dayjs';
import { useDialogs } from '../hooks/useDialogs/useDialogs';
import useNotifications from '../hooks/useNotifications/useNotifications';
import {
deleteOne as deleteEmployee,
getOne as getEmployee,
type Employee,
} from '../data/employees';
import PageContainer from './PageContainer';
export default function EmployeeShow() {
const { employeeId } = useParams();
const navigate = useNavigate();
const dialogs = useDialogs();
const notifications = useNotifications();
const [employee, setEmployee] = React.useState<Employee | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const loadData = React.useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const showData = await getEmployee(Number(employeeId));
setEmployee(showData);
} catch (showDataError) {
setError(showDataError as Error);
}
setIsLoading(false);
}, [employeeId]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleEmployeeEdit = React.useCallback(() => {
navigate(`/employees/${employeeId}/edit`);
}, [navigate, employeeId]);
const handleEmployeeDelete = React.useCallback(async () => {
if (!employee) {
return;
}
const confirmed = await dialogs.confirm(
`Do you wish to delete ${employee.name}?`,
{
title: `Delete employee?`,
severity: 'error',
okText: 'Delete',
cancelText: 'Cancel',
},
);
if (confirmed) {
setIsLoading(true);
try {
await deleteEmployee(Number(employeeId));
navigate('/employees');
notifications.show('Employee deleted successfully.', {
severity: 'success',
autoHideDuration: 3000,
});
} catch (deleteError) {
notifications.show(
`Failed to delete employee. Reason:' ${(deleteError as Error).message}`,
{
severity: 'error',
autoHideDuration: 3000,
},
);
}
setIsLoading(false);
}
}, [employee, dialogs, employeeId, navigate, notifications]);
const handleBack = React.useCallback(() => {
navigate('/employees');
}, [navigate]);
const renderShow = React.useMemo(() => {
if (isLoading) {
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
m: 1,
}}
>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ flexGrow: 1 }}>
<Alert severity="error">{error.message}</Alert>
</Box>
);
}
return employee ? (
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Grid container spacing={2} sx={{ width: '100%' }}>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Name</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.name}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Age</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.age}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Join date</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{dayjs(employee.joinDate).format('MMMM D, YYYY')}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Department</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.role}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ px: 2, py: 1 }}>
<Typography variant="overline">Full-time</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
{employee.isFullTime ? 'Yes' : 'No'}
</Typography>
</Paper>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Stack direction="row" spacing={2} justifyContent="space-between">
<Button
variant="contained"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
>
Back
</Button>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
startIcon={<EditIcon />}
onClick={handleEmployeeEdit}
>
Edit
</Button>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleEmployeeDelete}
>
Delete
</Button>
</Stack>
</Stack>
</Box>
) : null;
}, [
isLoading,
error,
employee,
handleBack,
handleEmployeeEdit,
handleEmployeeDelete,
]);
const pageTitle = `Employee ${employeeId}`;
return (
<PageContainer
title={pageTitle}
breadcrumbs={[
{ title: 'Employees', path: '/employees' },
{ title: pageTitle },
]}
>
<Box sx={{ display: 'flex', flex: 1, width: '100%' }}>{renderShow}</Box>
</PageContainer>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Breadcrumbs, { breadcrumbsClasses } from '@mui/material/Breadcrumbs';
import Container from '@mui/material/Container';
import MuiLink from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
import { Link } from 'react-router';
const PageContentHeader = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
gap: theme.spacing(2),
}));
const PageHeaderBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({
margin: theme.spacing(1, 0),
[`& .${breadcrumbsClasses.separator}`]: {
color: (theme.vars || theme).palette.action.disabled,
margin: 1,
},
[`& .${breadcrumbsClasses.ol}`]: {
alignItems: 'center',
},
}));
const PageHeaderToolbar = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
// Ensure the toolbar is always on the right side, even after wrapping
marginLeft: 'auto',
}));
function PageContainer(props) {
const { children, breadcrumbs, title, actions = null } = props;
return (
<Container sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Stack sx={{ flex: 1, my: 2 }} spacing={2}>
<Stack>
<PageHeaderBreadcrumbs
aria-label="breadcrumb"
separator={<NavigateNextRoundedIcon fontSize="small" />}
>
{breadcrumbs
? breadcrumbs.map((breadcrumb, index) => {
return breadcrumb.path ? (
<MuiLink
key={index}
component={Link}
underline="hover"
color="inherit"
to={breadcrumb.path}
>
{breadcrumb.title}
</MuiLink>
) : (
<Typography
key={index}
sx={{ color: 'text.primary', fontWeight: 600 }}
>
{breadcrumb.title}
</Typography>
);
})
: null}
</PageHeaderBreadcrumbs>
<PageContentHeader>
{title ? <Typography variant="h4">{title}</Typography> : null}
<PageHeaderToolbar>{actions}</PageHeaderToolbar>
</PageContentHeader>
</Stack>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</Box>
</Stack>
</Container>
);
}
PageContainer.propTypes = {
actions: PropTypes.node,
breadcrumbs: PropTypes.arrayOf(
PropTypes.shape({
path: PropTypes.string,
title: PropTypes.string.isRequired,
}),
),
children: PropTypes.node,
title: PropTypes.string,
};
export default PageContainer;

View File

@@ -0,0 +1,95 @@
'use client';
import * as React from 'react';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Breadcrumbs, { breadcrumbsClasses } from '@mui/material/Breadcrumbs';
import Container, { ContainerProps } from '@mui/material/Container';
import MuiLink from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
import { Link } from 'react-router';
const PageContentHeader = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
gap: theme.spacing(2),
}));
const PageHeaderBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({
margin: theme.spacing(1, 0),
[`& .${breadcrumbsClasses.separator}`]: {
color: (theme.vars || theme).palette.action.disabled,
margin: 1,
},
[`& .${breadcrumbsClasses.ol}`]: {
alignItems: 'center',
},
}));
const PageHeaderToolbar = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
// Ensure the toolbar is always on the right side, even after wrapping
marginLeft: 'auto',
}));
export interface Breadcrumb {
title: string;
path?: string;
}
export interface PageContainerProps extends ContainerProps {
children?: React.ReactNode;
title?: string;
breadcrumbs?: Breadcrumb[];
actions?: React.ReactNode;
}
export default function PageContainer(props: PageContainerProps) {
const { children, breadcrumbs, title, actions = null } = props;
return (
<Container sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Stack sx={{ flex: 1, my: 2 }} spacing={2}>
<Stack>
<PageHeaderBreadcrumbs
aria-label="breadcrumb"
separator={<NavigateNextRoundedIcon fontSize="small" />}
>
{breadcrumbs
? breadcrumbs.map((breadcrumb, index) => {
return breadcrumb.path ? (
<MuiLink
key={index}
component={Link}
underline="hover"
color="inherit"
to={breadcrumb.path}
>
{breadcrumb.title}
</MuiLink>
) : (
<Typography
key={index}
sx={{ color: 'text.primary', fontWeight: 600 }}
>
{breadcrumb.title}
</Typography>
);
})
: null}
</PageHeaderBreadcrumbs>
<PageContentHeader>
{title ? <Typography variant="h4">{title}</Typography> : null}
<PageHeaderToolbar>{actions}</PageHeaderToolbar>
</PageContentHeader>
</Stack>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</Box>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,52 @@
import SvgIcon from '@mui/material/SvgIcon';
export default function SitemarkIcon() {
return (
<SvgIcon sx={{ height: 21, width: 100, mr: 2 }}>
<svg
width={86}
height={19}
viewBox="0 0 86 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#B4C0D3"
d="m.787 12.567 6.055-2.675 3.485 2.006.704 6.583-4.295-.035.634-4.577-.74-.422-3.625 2.817-2.218-3.697Z"
/>
<path
fill="#00D3AB"
d="m10.714 11.616 5.352 3.908 2.112-3.767-4.295-1.725v-.845l4.295-1.76-2.112-3.732-5.352 3.908v4.013Z"
/>
<path
fill="#4876EF"
d="m10.327 7.286.704-6.583-4.295.07.634 4.577-.74.422-3.66-2.816L.786 6.617l6.055 2.676 3.485-2.007Z"
/>
<path
fill="#4876EE"
d="M32.507 8.804v6.167h2.312v-7.86h-3.366v1.693h1.054ZM32.435 6.006c.212.22.535.33.968.33.434 0 .751-.11.953-.33.213-.23.318-.516.318-.86 0-.354-.105-.641-.318-.86-.202-.23-.52-.345-.953-.345-.433 0-.756.115-.968.344-.202.22-.303.507-.303.86 0 .345.101.632.303.861ZM24.46 14.799c.655.296 1.46.444 2.413.444.896 0 1.667-.139 2.312-.416.645-.277 1.141-.664 1.488-1.162.357-.506.535-1.094.535-1.764 0-.65-.169-1.2-.506-1.649-.328-.459-.785-.818-1.373-1.076-.587-.267-1.266-.435-2.037-.502l-.809-.071c-.481-.039-.828-.168-1.04-.388a1.08 1.08 0 0 1-.318-.774c0-.23.058-.44.173-.631.116-.201.29-.359.52-.474.241-.114.535-.172.882-.172.366 0 .67.067.91.201.053.029.104.059.15.09l.012.009.052.037c.146.111.263.243.35.395.125.21.188.444.188.703h2.311c0-.689-.159-1.286-.476-1.793-.318-.516-.776-.913-1.373-1.19-.588-.287-1.296-.43-2.124-.43-.79 0-1.474.133-2.052.4a3.131 3.131 0 0 0-1.358 1.12c-.318.487-.477 1.066-.477 1.735 0 .927.314 1.673.94 2.237.626.564 1.464.89 2.514.976l.794.071c.645.058 1.113.187 1.401.388a.899.899 0 0 1 .434.788 1.181 1.181 0 0 1-.231.717c-.154.201-.38.36-.68.474-.298.115-.669.172-1.112.172-.49 0-.89-.067-1.199-.2-.308-.144-.539-.33-.694-.56a1.375 1.375 0 0 1-.216-.746h-2.297c0 .679.168 1.281.505 1.807.337.517.834.928 1.489 1.234ZM39.977 15.07c-.8 0-1.445-.095-1.936-.286a2.03 2.03 0 0 1-1.084-.99c-.221-.469-.332-1.1-.332-1.893V8.789h-1.2V7.11h1.2V4.988h2.153V7.11h2.312V8.79h-2.312v3.198c0 .373.096.66.289.86.202.192.486.287.852.287h1.17v1.937h-1.112Z"
/>
<path
fill="#4876EE"
fillRule="evenodd"
d="M43.873 14.899c.52.23 1.117.344 1.791.344.665 0 1.252-.115 1.763-.344.51-.23.934-.55 1.271-.96.337-.412.564-.88.679-1.407h-2.124c-.096.24-.279.44-.549.603-.27.162-.616.244-1.04.244-.262 0-.497-.031-.704-.093a1.572 1.572 0 0 1-.423-.194 1.662 1.662 0 0 1-.636-.803 3.159 3.159 0 0 1-.163-.645h5.784v-.775a4.28 4.28 0 0 0-.463-1.98 3.686 3.686 0 0 0-1.343-1.477c-.578-.382-1.291-.574-2.139-.574-.645 0-1.223.115-1.733.345-.501.22-.92.52-1.257.903a4.178 4.178 0 0 0-.78 1.305c-.174.478-.26.98-.26 1.506v.287c0 .507.086 1.004.26 1.492.183.478.443.913.78 1.305.347.382.775.688 1.286.918Zm-.094-4.674.02-.09a2.507 2.507 0 0 1 .117-.356c.145-.354.356-.622.636-.804.104-.067.217-.123.339-.165.204-.071.433-.107.686-.107.395 0 .723.09.983.272.27.173.472.426.607.76a2.487 2.487 0 0 1 .16.603h-3.57c.006-.038.013-.076.022-.113Z"
clipRule="evenodd"
/>
<path
fill="#4876EE"
d="M50.476 14.97V7.112h1.835v1.98a4.54 4.54 0 0 1 .173-.603c.202-.536.506-.937.91-1.205.405-.277.9-.416 1.488-.416h.101c.598 0 1.094.139 1.489.416.404.268.707.67.91 1.205l.016.04.013.037.028-.077c.212-.536.52-.937.925-1.205.405-.277.901-.416 1.489-.416h.1c.598 0 1.098.139 1.503.416.414.268.727.67.94 1.205.211.535.317 1.205.317 2.008v4.475h-2.312v-4.604c0-.43-.115-.78-.346-1.047-.222-.268-.54-.402-.954-.402-.414 0-.742.139-.982.416-.241.268-.362.626-.362 1.076v4.56h-2.326v-4.603c0-.43-.115-.78-.346-1.047-.222-.268-.535-.402-.94-.402-.423 0-.756.139-.996.416-.241.268-.362.626-.362 1.076v4.56h-2.311Z"
/>
<path
fill="#4876EE"
fillRule="evenodd"
d="M68.888 13.456v1.515h1.834v-4.82c0-.726-.144-1.319-.433-1.778-.289-.468-.712-.817-1.271-1.047-.549-.23-1.228-.344-2.037-.344a27.76 27.76 0 0 0-.896.014c-.318.01-.626.024-.924.043l-.229.016a36.79 36.79 0 0 0-.552.042v1.936a81.998 81.998 0 0 1 1.733-.09 37.806 37.806 0 0 1 1.171-.025c.424 0 .732.1.925.301.193.201.289.502.289.904v.029h-1.43c-.704 0-1.325.09-1.864.272-.54.172-.959.445-1.257.818-.299.363-.448.832-.448 1.405 0 .526.12.98.361 1.363.24.373.573.66.997.86.433.201.934.302 1.502.302.55 0 1.012-.1 1.388-.302.385-.2.683-.487.895-.86a2.443 2.443 0 0 0 .228-.498l.018-.056Zm-.39-1.397v-.63h-1.445c-.405 0-.718.1-.939.3-.212.192-.318.455-.318.79 0 .157.026.3.08.429a.99.99 0 0 0 .238.345c.221.191.534.287.939.287a2.125 2.125 0 0 0 .394-.038c.106-.021.206-.052.3-.092.212-.095.385-.253.52-.473.135-.22.212-.526.23-.918Z"
clipRule="evenodd"
/>
<path
fill="#4876EE"
d="M72.106 14.97V7.11h1.835v2.595c.088-.74.31-1.338.665-1.791.481-.603 1.174-.904 2.08-.904h.303v1.98h-.578c-.635 0-1.127.172-1.473.516-.347.334-.52.822-.52 1.463v4.001h-2.312ZM79.92 11.298h.767l2.499 3.672h2.6l-3.169-4.51 2.606-3.35h-2.427l-2.875 3.737V4.5h-2.312v10.47h2.312v-3.672Z"
/>
</svg>
</SvgIcon>
);
}

View File

@@ -0,0 +1,52 @@
import SvgIcon from '@mui/material/SvgIcon';
export default function SitemarkIcon() {
return (
<SvgIcon sx={{ height: 21, width: 100, mr: 2 }}>
<svg
width={86}
height={19}
viewBox="0 0 86 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#B4C0D3"
d="m.787 12.567 6.055-2.675 3.485 2.006.704 6.583-4.295-.035.634-4.577-.74-.422-3.625 2.817-2.218-3.697Z"
/>
<path
fill="#00D3AB"
d="m10.714 11.616 5.352 3.908 2.112-3.767-4.295-1.725v-.845l4.295-1.76-2.112-3.732-5.352 3.908v4.013Z"
/>
<path
fill="#4876EF"
d="m10.327 7.286.704-6.583-4.295.07.634 4.577-.74.422-3.66-2.816L.786 6.617l6.055 2.676 3.485-2.007Z"
/>
<path
fill="#4876EE"
d="M32.507 8.804v6.167h2.312v-7.86h-3.366v1.693h1.054ZM32.435 6.006c.212.22.535.33.968.33.434 0 .751-.11.953-.33.213-.23.318-.516.318-.86 0-.354-.105-.641-.318-.86-.202-.23-.52-.345-.953-.345-.433 0-.756.115-.968.344-.202.22-.303.507-.303.86 0 .345.101.632.303.861ZM24.46 14.799c.655.296 1.46.444 2.413.444.896 0 1.667-.139 2.312-.416.645-.277 1.141-.664 1.488-1.162.357-.506.535-1.094.535-1.764 0-.65-.169-1.2-.506-1.649-.328-.459-.785-.818-1.373-1.076-.587-.267-1.266-.435-2.037-.502l-.809-.071c-.481-.039-.828-.168-1.04-.388a1.08 1.08 0 0 1-.318-.774c0-.23.058-.44.173-.631.116-.201.29-.359.52-.474.241-.114.535-.172.882-.172.366 0 .67.067.91.201.053.029.104.059.15.09l.012.009.052.037c.146.111.263.243.35.395.125.21.188.444.188.703h2.311c0-.689-.159-1.286-.476-1.793-.318-.516-.776-.913-1.373-1.19-.588-.287-1.296-.43-2.124-.43-.79 0-1.474.133-2.052.4a3.131 3.131 0 0 0-1.358 1.12c-.318.487-.477 1.066-.477 1.735 0 .927.314 1.673.94 2.237.626.564 1.464.89 2.514.976l.794.071c.645.058 1.113.187 1.401.388a.899.899 0 0 1 .434.788 1.181 1.181 0 0 1-.231.717c-.154.201-.38.36-.68.474-.298.115-.669.172-1.112.172-.49 0-.89-.067-1.199-.2-.308-.144-.539-.33-.694-.56a1.375 1.375 0 0 1-.216-.746h-2.297c0 .679.168 1.281.505 1.807.337.517.834.928 1.489 1.234ZM39.977 15.07c-.8 0-1.445-.095-1.936-.286a2.03 2.03 0 0 1-1.084-.99c-.221-.469-.332-1.1-.332-1.893V8.789h-1.2V7.11h1.2V4.988h2.153V7.11h2.312V8.79h-2.312v3.198c0 .373.096.66.289.86.202.192.486.287.852.287h1.17v1.937h-1.112Z"
/>
<path
fill="#4876EE"
fillRule="evenodd"
d="M43.873 14.899c.52.23 1.117.344 1.791.344.665 0 1.252-.115 1.763-.344.51-.23.934-.55 1.271-.96.337-.412.564-.88.679-1.407h-2.124c-.096.24-.279.44-.549.603-.27.162-.616.244-1.04.244-.262 0-.497-.031-.704-.093a1.572 1.572 0 0 1-.423-.194 1.662 1.662 0 0 1-.636-.803 3.159 3.159 0 0 1-.163-.645h5.784v-.775a4.28 4.28 0 0 0-.463-1.98 3.686 3.686 0 0 0-1.343-1.477c-.578-.382-1.291-.574-2.139-.574-.645 0-1.223.115-1.733.345-.501.22-.92.52-1.257.903a4.178 4.178 0 0 0-.78 1.305c-.174.478-.26.98-.26 1.506v.287c0 .507.086 1.004.26 1.492.183.478.443.913.78 1.305.347.382.775.688 1.286.918Zm-.094-4.674.02-.09a2.507 2.507 0 0 1 .117-.356c.145-.354.356-.622.636-.804.104-.067.217-.123.339-.165.204-.071.433-.107.686-.107.395 0 .723.09.983.272.27.173.472.426.607.76a2.487 2.487 0 0 1 .16.603h-3.57c.006-.038.013-.076.022-.113Z"
clipRule="evenodd"
/>
<path
fill="#4876EE"
d="M50.476 14.97V7.112h1.835v1.98a4.54 4.54 0 0 1 .173-.603c.202-.536.506-.937.91-1.205.405-.277.9-.416 1.488-.416h.101c.598 0 1.094.139 1.489.416.404.268.707.67.91 1.205l.016.04.013.037.028-.077c.212-.536.52-.937.925-1.205.405-.277.901-.416 1.489-.416h.1c.598 0 1.098.139 1.503.416.414.268.727.67.94 1.205.211.535.317 1.205.317 2.008v4.475h-2.312v-4.604c0-.43-.115-.78-.346-1.047-.222-.268-.54-.402-.954-.402-.414 0-.742.139-.982.416-.241.268-.362.626-.362 1.076v4.56h-2.326v-4.603c0-.43-.115-.78-.346-1.047-.222-.268-.535-.402-.94-.402-.423 0-.756.139-.996.416-.241.268-.362.626-.362 1.076v4.56h-2.311Z"
/>
<path
fill="#4876EE"
fillRule="evenodd"
d="M68.888 13.456v1.515h1.834v-4.82c0-.726-.144-1.319-.433-1.778-.289-.468-.712-.817-1.271-1.047-.549-.23-1.228-.344-2.037-.344a27.76 27.76 0 0 0-.896.014c-.318.01-.626.024-.924.043l-.229.016a36.79 36.79 0 0 0-.552.042v1.936a81.998 81.998 0 0 1 1.733-.09 37.806 37.806 0 0 1 1.171-.025c.424 0 .732.1.925.301.193.201.289.502.289.904v.029h-1.43c-.704 0-1.325.09-1.864.272-.54.172-.959.445-1.257.818-.299.363-.448.832-.448 1.405 0 .526.12.98.361 1.363.24.373.573.66.997.86.433.201.934.302 1.502.302.55 0 1.012-.1 1.388-.302.385-.2.683-.487.895-.86a2.443 2.443 0 0 0 .228-.498l.018-.056Zm-.39-1.397v-.63h-1.445c-.405 0-.718.1-.939.3-.212.192-.318.455-.318.79 0 .157.026.3.08.429a.99.99 0 0 0 .238.345c.221.191.534.287.939.287a2.125 2.125 0 0 0 .394-.038c.106-.021.206-.052.3-.092.212-.095.385-.253.52-.473.135-.22.212-.526.23-.918Z"
clipRule="evenodd"
/>
<path
fill="#4876EE"
d="M72.106 14.97V7.11h1.835v2.595c.088-.74.31-1.338.665-1.791.481-.603 1.174-.904 2.08-.904h.303v1.98h-.578c-.635 0-1.127.172-1.473.516-.347.334-.52.822-.52 1.463v4.001h-2.312ZM79.92 11.298h.767l2.499 3.672h2.6l-3.169-4.51 2.606-3.35h-2.427l-2.875 3.737V4.5h-2.312v10.47h2.312v-3.672Z"
/>
</svg>
</SvgIcon>
);
}

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { useTheme, useColorScheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
export default function ThemeSwitcher() {
const theme = useTheme();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const preferredMode = prefersDarkMode ? 'dark' : 'light';
const { mode, setMode } = useColorScheme();
const paletteMode = !mode || mode === 'system' ? preferredMode : mode;
const toggleMode = React.useCallback(() => {
setMode(paletteMode === 'dark' ? 'light' : 'dark');
}, [setMode, paletteMode]);
return (
<Tooltip
title={`${paletteMode === 'dark' ? 'Light' : 'Dark'} mode`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`Switch to ${paletteMode === 'dark' ? 'light' : 'dark'} mode`}
onClick={toggleMode}
>
{theme.getColorSchemeSelector ? (
<React.Fragment>
<LightModeIcon
sx={{
display: 'inline',
[theme.getColorSchemeSelector('dark')]: {
display: 'none',
},
}}
/>
<DarkModeIcon
sx={{
display: 'none',
[theme.getColorSchemeSelector('dark')]: {
display: 'inline',
},
}}
/>
</React.Fragment>
) : null}
</IconButton>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { useTheme, useColorScheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
export default function ThemeSwitcher() {
const theme = useTheme();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const preferredMode = prefersDarkMode ? 'dark' : 'light';
const { mode, setMode } = useColorScheme();
const paletteMode = !mode || mode === 'system' ? preferredMode : mode;
const toggleMode = React.useCallback(() => {
setMode(paletteMode === 'dark' ? 'light' : 'dark');
}, [setMode, paletteMode]);
return (
<Tooltip
title={`${paletteMode === 'dark' ? 'Light' : 'Dark'} mode`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`Switch to ${paletteMode === 'dark' ? 'light' : 'dark'} mode`}
onClick={toggleMode}
>
{theme.getColorSchemeSelector ? (
<React.Fragment>
<LightModeIcon
sx={{
display: 'inline',
[theme.getColorSchemeSelector('dark')]: {
display: 'none',
},
}}
/>
<DarkModeIcon
sx={{
display: 'none',
[theme.getColorSchemeSelector('dark')]: {
display: 'inline',
},
}}
/>
</React.Fragment>
) : null}
</IconButton>
</div>
</Tooltip>
);
}