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

View file

@ -1,5 +1,5 @@
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 LogoutIcon from '@mui/icons-material/Logout';
import { toggleSidebar } from '../app/slices/uiSlice';
@ -9,9 +9,13 @@ export default function Topbar() {
const dispatch = useDispatch();
const { user, logout } = useAuth();
const initials = user?.displayName
? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
: '';
return (
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, height: 64 }}>
<Toolbar sx={{ height: 64, minHeight: 64 }}>
<IconButton
color="inherit"
edge="start"
@ -20,15 +24,32 @@ export default function Topbar() {
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
<Typography variant="h6" noWrap sx={{ flexGrow: 1, fontWeight: 600 }}>
Proposal System
</Typography>
{user && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">{user.displayName}</Typography>
<Button color="inherit" onClick={logout} startIcon={<LogoutIcon />} size="small">
Logout
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#1fa8de', fontSize: 12, fontWeight: 600 }}>
{initials}
</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>
)}
</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;
},
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> => {
const res = await apiClient.get('/users/me');
return res.data;

View file

@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import { store } from './app/store';
import { queryClient } from './lib/queryClient';
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 }) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
<Box sx={{ color, display: 'flex' }}>{icon}</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>{value}</Typography>
<Typography variant="body2" color="text.secondary">{label}</Typography>
</Box>
<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={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
{label}
</Typography>
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
{value}
</Typography>
</CardContent>
</Card>
);

View file

@ -1,10 +1,16 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
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 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 { 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 REDIRECT_URI = `${window.location.origin}/callback`;
@ -21,7 +27,11 @@ function buildLoginUrl(): string {
export default function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { isAuthenticated } = useAuth();
const [loading, setLoading] = useState(false);
const isDevMode = !CLIENT_ID;
useEffect(() => {
if (isAuthenticated) {
@ -29,36 +39,131 @@ export default function LoginPage() {
}
}, [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 (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
justifyContent: 'flex-start',
pt: '7%',
bgcolor: '#333',
}}
>
<Card sx={{ maxWidth: 400, width: '100%', mx: 2 }}>
<CardContent sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Proposal System
</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 }}
<Box sx={{ width: '100%', maxWidth: 400, mx: 2 }}>
<Card sx={{ borderRadius: 0, boxShadow: '0 0 10px rgba(0,0,0,0.3)', border: 'none' }}>
<Box
sx={{
height: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#fff',
borderBottom: '1px solid #b8b5b5',
px: 3,
}}
>
Sign in with Google
</Button>
</CardContent>
</Card>
<Typography sx={{ fontSize: '24px', fontWeight: 600, color: '#003A5D' }}>
Sea Haven Industries
</Typography>
</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>
);
}

View file

@ -3,28 +3,36 @@ import { createTheme } from '@mui/material/styles';
export const theme = createTheme({
palette: {
primary: {
main: '#1565c0',
light: '#5e92f3',
dark: '#003c8f',
main: '#0c4f6f',
light: '#1fa8de',
dark: '#083a52',
},
secondary: {
main: '#f57c00',
light: '#ffad42',
dark: '#bb4d00',
main: '#007bff',
light: '#4da3ff',
dark: '#0056b3',
},
background: {
default: '#f5f5f5',
default: '#f4f6f7',
paper: '#ffffff',
},
text: {
primary: '#333333',
secondary: '#666666',
},
divider: '#d9dde0',
},
typography: {
fontFamily: '"Nunito Sans", "Helvetica", "Arial", sans-serif',
h4: { fontWeight: 700 },
h5: { fontWeight: 700 },
h6: { fontWeight: 600 },
fontFamily: '"Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSize: 13,
h4: { fontWeight: 600, fontSize: '28px' },
h5: { fontWeight: 600, fontSize: '22px' },
h6: { fontWeight: 600, fontSize: '16px' },
body1: { fontSize: '14px' },
body2: { fontSize: '13px' },
},
shape: {
borderRadius: 8,
borderRadius: 4,
},
components: {
MuiButton: {
@ -32,12 +40,19 @@ export const theme = createTheme({
root: {
textTransform: 'none',
fontWeight: 600,
borderRadius: 4,
},
},
},
MuiAppBar: {
defaultProps: {
elevation: 1,
elevation: 0,
},
styleOverrides: {
root: {
background: 'linear-gradient(#0c4f6f, #083a52)',
borderBottom: '1px solid #062c3e',
},
},
},
MuiCard: {
@ -46,8 +61,102 @@ export const theme = createTheme({
},
styleOverrides: {
root: {
border: '1px solid',
borderColor: '#e0e0e0',
border: '1px solid #ddd',
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,
},
},
},