Apply SHOC design system styling across frontend

- Rewrite theme with SHOC palette (#0c4f6f primary, Nunito font, 4px radius)
- Add global CSS with Google Fonts import for Nunito
- Redesign Topbar with avatar initials, role subtitle, gradient header
- Redesign Sidebar with 220px width, section headers, active state border
- Restyle LoginPage with SHOC branded card and dev-mode role selector
- Update AdminDashboard KPI cards to centered SHOC style
- Add devLogin API method for local development auth flow
This commit is contained in:
Adam Moussa 2026-05-16 22:10:52 -04:00
parent fc8def2f47
commit 3e9623b6fc
8 changed files with 384 additions and 78 deletions

View file

@ -8,7 +8,6 @@ import {
ListItemText, ListItemText,
Toolbar, Toolbar,
Box, Box,
Divider,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
@ -21,23 +20,34 @@ import { selectSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice'; import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store'; import type { RootState } from '../app/store';
const DRAWER_WIDTH = 240; const DRAWER_WIDTH = 220;
const dispatcherNav = [ const dispatcherNav = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon /> }, { label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },
{ label: 'My Proposals', path: '/proposals', icon: <DescriptionIcon /> }, { label: 'My Proposals', path: '/proposals', icon: <DescriptionIcon fontSize="small" /> },
{ label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon /> }, { label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon fontSize="small" /> },
]; ];
const adminNav = [ const adminNav = [
{ label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon /> }, { label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon fontSize="small" /> },
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon /> }, { label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
]; ];
const sysadminNav = [ const sysadminNav = [
{ label: 'Users', path: '/admin/users', icon: <PeopleIcon /> }, { label: 'Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> },
]; ];
const ALL_NAV_PATHS = new Set(
[...dispatcherNav, ...adminNav, ...sysadminNav].map(n => n.path)
);
function isNavActive(pathname: string, itemPath: string): boolean {
if (pathname === itemPath) return true;
if (ALL_NAV_PATHS.has(pathname)) return false;
if (itemPath === '/' || itemPath === '/admin') return false;
return pathname.startsWith(itemPath + '/');
}
export default function Sidebar() { export default function Sidebar() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -61,46 +71,72 @@ export default function Sidebar() {
}, },
}} }}
> >
<Toolbar /> <Toolbar sx={{ minHeight: 64 }} />
<Box sx={{ overflow: 'auto', flexGrow: 1 }}> <Box sx={{ overflow: 'auto', flexGrow: 1, pt: 1 }}>
<List> <Typography
sx={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase',
color: '#9aa0a6',
px: 2.5,
mb: 0.5,
}}
>
Proposals
</Typography>
<List disablePadding>
{navItems.map((item) => ( {navItems.map((item) => (
<ListItemButton <ListItemButton
key={item.path} key={item.path}
selected={location.pathname === item.path} selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
sx={{ py: 1 }}
> >
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} /> <ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton> </ListItemButton>
))} ))}
</List> </List>
{isAdmin && ( {isAdmin && (
<> <>
<Divider /> <Typography
<Typography variant="overline" sx={{ px: 2, pt: 1, display: 'block', color: 'text.secondary' }}> sx={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase',
color: '#9aa0a6',
px: 2.5,
mt: 2,
mb: 0.5,
}}
>
Admin Admin
</Typography> </Typography>
<List> <List disablePadding>
{adminNav.map((item) => ( {adminNav.map((item) => (
<ListItemButton <ListItemButton
key={item.path} key={item.path}
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')} selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
sx={{ py: 1 }}
> >
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} /> <ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton> </ListItemButton>
))} ))}
{isSysAdmin && sysadminNav.map((item) => ( {isSysAdmin && sysadminNav.map((item) => (
<ListItemButton <ListItemButton
key={item.path} key={item.path}
selected={location.pathname === item.path} selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
sx={{ py: 1 }}
> >
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} /> <ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton> </ListItemButton>
))} ))}
</List> </List>
@ -108,9 +144,9 @@ export default function Sidebar() {
)} )}
</Box> </Box>
{user && ( {user && (
<Box sx={{ p: 2, mt: 'auto', borderTop: 1, borderColor: 'divider' }}> <Box sx={{ p: 2, mt: 'auto', borderTop: '1px solid #d9dde0' }}>
<Box sx={{ fontSize: '0.85rem', fontWeight: 500 }}>{user.displayName}</Box> <Box sx={{ fontSize: '13px', fontWeight: 500 }}>{user.displayName}</Box>
<Box sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{user.role}</Box> <Box sx={{ fontSize: '11px', color: '#9aa0a6' }}>{user.role}</Box>
</Box> </Box>
)} )}
</Drawer> </Drawer>

View file

@ -1,5 +1,5 @@
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { AppBar, Toolbar, IconButton, Typography, Button, Box } from '@mui/material'; import { AppBar, Toolbar, IconButton, Typography, Box, Avatar } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import { toggleSidebar } from '../app/slices/uiSlice'; import { toggleSidebar } from '../app/slices/uiSlice';
@ -9,9 +9,13 @@ export default function Topbar() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const initials = user?.displayName
? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
: '';
return ( return (
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}> <AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, height: 64 }}>
<Toolbar> <Toolbar sx={{ height: 64, minHeight: 64 }}>
<IconButton <IconButton
color="inherit" color="inherit"
edge="start" edge="start"
@ -20,15 +24,32 @@ export default function Topbar() {
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}> <Typography variant="h6" noWrap sx={{ flexGrow: 1, fontWeight: 600 }}>
Proposal System Proposal System
</Typography> </Typography>
{user && ( {user && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="body2">{user.displayName}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button color="inherit" onClick={logout} startIcon={<LogoutIcon />} size="small"> <Avatar sx={{ width: 32, height: 32, bgcolor: '#1fa8de', fontSize: 12, fontWeight: 600 }}>
Logout {initials}
</Button> </Avatar>
<Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: '#fff' }}>
{user.displayName}
</Typography>
<Typography sx={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>
{user.role}
</Typography>
</Box>
</Box>
<Box sx={{ width: '1px', height: 24, bgcolor: 'rgba(255,255,255,0.15)', mx: 0.5 }} />
<IconButton
onClick={logout}
size="small"
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
>
<LogoutIcon fontSize="small" />
</IconButton>
</Box> </Box>
)} )}
</Toolbar> </Toolbar>

27
web/src/index.css Normal file
View file

@ -0,0 +1,27 @@
@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;1,400&display=swap");
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
margin: 0;
font-family: "Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
background: #f4f6f7;
color: #333;
line-height: 1.5;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View file

@ -21,6 +21,11 @@ export const authApi = {
return res.data; return res.data;
}, },
devLogin: async (email: string, displayName: string, role: string): Promise<AuthUser> => {
const res = await apiClient.post('/auth/dev-login', { email, displayName, role });
return res.data;
},
getMe: async (): Promise<UserProfile> => { getMe: async (): Promise<UserProfile> => {
const res = await apiClient.get('/users/me'); const res = await apiClient.get('/users/me');
return res.data; return res.data;

View file

@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, CssBaseline } from '@mui/material'; import { ThemeProvider, CssBaseline } from '@mui/material';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import { store } from './app/store'; import { store } from './app/store';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { theme } from './theme'; import { theme } from './theme';

View file

@ -54,13 +54,15 @@ function formatDate(iso: string): string {
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) { function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
return ( return (
<Card> <Card sx={{ borderRadius: '8px', transition: 'transform 0.2s, box-shadow 0.2s', '&:hover': { transform: 'translateY(-2px)', boxShadow: '0 4px 8px rgba(0,0,0,0.15)' } }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}> <CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ color, display: 'flex' }}>{icon}</Box> <Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
<Box> <Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>{value}</Typography> {label}
<Typography variant="body2" color="text.secondary">{label}</Typography> </Typography>
</Box> <Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
{value}
</Typography>
</CardContent> </CardContent>
</Card> </Card>
); );

View file

@ -1,10 +1,16 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, CardContent, Typography } from '@mui/material'; import { useDispatch } from 'react-redux';
import { Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google'; import GoogleIcon from '@mui/icons-material/Google';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import EngineeringIcon from '@mui/icons-material/Engineering';
import PersonIcon from '@mui/icons-material/Person';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { authApi } from '../../lib/api/auth';
import { setUser, setError } from '../../app/slices/authSlice';
const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || 'proposal-system-seahaven.auth.us-east-1.amazoncognito.com'; const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || '';
const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || ''; const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || '';
const REDIRECT_URI = `${window.location.origin}/callback`; const REDIRECT_URI = `${window.location.origin}/callback`;
@ -21,7 +27,11 @@ function buildLoginUrl(): string {
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [loading, setLoading] = useState(false);
const isDevMode = !CLIENT_ID;
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
@ -29,36 +39,131 @@ export default function LoginPage() {
} }
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
const handleDevLogin = async (role: string) => {
setLoading(true);
try {
const user = await authApi.devLogin('adam@seahavenind.com', 'Adam Moussa', role);
dispatch(setUser(user));
navigate('/', { replace: true });
} catch (err) {
dispatch(setError(err instanceof Error ? err.message : 'Dev login failed'));
} finally {
setLoading(false);
}
};
return ( return (
<Box <Box
sx={{ sx={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'flex-start',
bgcolor: 'background.default', pt: '7%',
bgcolor: '#333',
}} }}
> >
<Card sx={{ maxWidth: 400, width: '100%', mx: 2 }}> <Box sx={{ width: '100%', maxWidth: 400, mx: 2 }}>
<CardContent sx={{ p: 4, textAlign: 'center' }}> <Card sx={{ borderRadius: 0, boxShadow: '0 0 10px rgba(0,0,0,0.3)', border: 'none' }}>
<Typography variant="h5" gutterBottom> <Box
Proposal System sx={{
</Typography> height: 100,
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}> display: 'flex',
Sign in with your Sea Haven Google account to continue. alignItems: 'center',
</Typography> justifyContent: 'center',
<Button bgcolor: '#fff',
variant="contained" borderBottom: '1px solid #b8b5b5',
size="large" px: 3,
fullWidth }}
startIcon={<GoogleIcon />}
href={buildLoginUrl()}
sx={{ py: 1.5 }}
> >
Sign in with Google <Typography sx={{ fontSize: '24px', fontWeight: 600, color: '#003A5D' }}>
</Button> Sea Haven Industries
</CardContent> </Typography>
</Card> </Box>
<Box
sx={{
bgcolor: '#003A5D',
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #b8b5b5',
}}
>
<Typography sx={{ fontSize: '14px', fontWeight: 700, color: '#fff' }}>
Proposal System
</Typography>
</Box>
<CardContent sx={{ p: '30px', textAlign: 'center' }}>
{isDevMode ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Development Mode — Select a role to sign in as:
</Typography>
<Stack spacing={2}>
<Button
variant="contained"
size="large"
fullWidth
startIcon={<AdminPanelSettingsIcon />}
onClick={() => handleDevLogin('SysAdmin')}
disabled={loading}
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
>
Sign in as SysAdmin
</Button>
<Button
variant="contained"
size="large"
fullWidth
startIcon={<EngineeringIcon />}
onClick={() => handleDevLogin('Admin')}
disabled={loading}
sx={{ py: 1.5, bgcolor: '#0c4f6f', '&:hover': { bgcolor: '#083a52' } }}
>
Sign in as Admin
</Button>
<Button
variant="outlined"
size="large"
fullWidth
startIcon={<PersonIcon />}
onClick={() => handleDevLogin('Dispatcher')}
disabled={loading}
sx={{ py: 1.5 }}
>
Sign in as Dispatcher
</Button>
</Stack>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Dev login is only available when VITE_COGNITO_CLIENT_ID is not set
</Typography>
</>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Sign in with your Sea Haven Google account to continue.
</Typography>
<Button
variant="contained"
size="large"
fullWidth
startIcon={<GoogleIcon />}
href={buildLoginUrl()}
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
>
Sign in with Google
</Button>
</>
)}
</CardContent>
</Card>
<Typography sx={{ textAlign: 'center', color: '#fff', fontSize: '12px', mt: 2, opacity: 0.7 }}>
Sea Haven Industries &copy; {new Date().getFullYear()}
</Typography>
</Box>
</Box> </Box>
); );
} }

View file

@ -3,28 +3,36 @@ import { createTheme } from '@mui/material/styles';
export const theme = createTheme({ export const theme = createTheme({
palette: { palette: {
primary: { primary: {
main: '#1565c0', main: '#0c4f6f',
light: '#5e92f3', light: '#1fa8de',
dark: '#003c8f', dark: '#083a52',
}, },
secondary: { secondary: {
main: '#f57c00', main: '#007bff',
light: '#ffad42', light: '#4da3ff',
dark: '#bb4d00', dark: '#0056b3',
}, },
background: { background: {
default: '#f5f5f5', default: '#f4f6f7',
paper: '#ffffff', paper: '#ffffff',
}, },
text: {
primary: '#333333',
secondary: '#666666',
},
divider: '#d9dde0',
}, },
typography: { typography: {
fontFamily: '"Nunito Sans", "Helvetica", "Arial", sans-serif', fontFamily: '"Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
h4: { fontWeight: 700 }, fontSize: 13,
h5: { fontWeight: 700 }, h4: { fontWeight: 600, fontSize: '28px' },
h6: { fontWeight: 600 }, h5: { fontWeight: 600, fontSize: '22px' },
h6: { fontWeight: 600, fontSize: '16px' },
body1: { fontSize: '14px' },
body2: { fontSize: '13px' },
}, },
shape: { shape: {
borderRadius: 8, borderRadius: 4,
}, },
components: { components: {
MuiButton: { MuiButton: {
@ -32,12 +40,19 @@ export const theme = createTheme({
root: { root: {
textTransform: 'none', textTransform: 'none',
fontWeight: 600, fontWeight: 600,
borderRadius: 4,
}, },
}, },
}, },
MuiAppBar: { MuiAppBar: {
defaultProps: { defaultProps: {
elevation: 1, elevation: 0,
},
styleOverrides: {
root: {
background: 'linear-gradient(#0c4f6f, #083a52)',
borderBottom: '1px solid #062c3e',
},
}, },
}, },
MuiCard: { MuiCard: {
@ -46,8 +61,102 @@ export const theme = createTheme({
}, },
styleOverrides: { styleOverrides: {
root: { root: {
border: '1px solid', border: '1px solid #ddd',
borderColor: '#e0e0e0', borderRadius: 4,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
},
},
},
MuiPaper: {
defaultProps: {
elevation: 0,
},
styleOverrides: {
root: {
borderRadius: 4,
},
},
},
MuiTableHead: {
styleOverrides: {
root: {
'& .MuiTableCell-head': {
fontWeight: 600,
backgroundColor: '#f9f9f9',
borderBottom: '2px solid #ddd',
},
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
fontSize: '14px',
padding: '12px',
borderBottom: '1px solid #eee',
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
'&:hover': {
backgroundColor: '#f9f9f9',
},
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 4,
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 4,
fontWeight: 600,
fontSize: '12px',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
borderRight: '1px solid #d9dde0',
backgroundColor: '#fbfbfb',
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 6,
marginLeft: 8,
marginRight: 8,
marginBottom: 4,
'&.Mui-selected': {
backgroundColor: '#e6f1f7',
borderLeft: '3px solid #0b80b5',
'&:hover': {
backgroundColor: '#dbedf5',
},
},
'&:hover': {
backgroundColor: '#eef2f4',
},
},
},
},
MuiListItemIcon: {
styleOverrides: {
root: {
color: '#8c949a',
minWidth: 36,
}, },
}, },
}, },