proposal-system/web/src/pages/dashboard/Dashboard.tsx
Adam Moussa 8ce2a5cd8f
feat(web): adopt SHOC design system and shell layout (ADR 0003) (#221)
Port shoc-frontend-new dev's design system with its CSS-variable
single-token-source mechanism:

- src/styles/theme.css: SHOC token file ported verbatim (Montserrat/
  DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/
  shadow/sidebar/header token layers); fonts self-hosted via @fontsource
- src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme
  adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows
  tuple, component overrides; MUI v9 slot renames expressed as class
  selectors); theme.ts is now a re-export
- Shell: SHOC composition (sidebar column + sticky gradient topbar +
  scrolling main); sidebar 244px/76px collapse with brand header row,
  grouped nav, SHOC active treatment (white card + 3px accent bar);
  topbar 100-degree gradient, surface hamburger, gradient avatar pill
- Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as
  sx; wordmark subtitle localized to PROPOSAL SYSTEM)
- Login: SHOC auth-card treatment (centered 384px card on #f9fafb)
- Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed;
  remaining hardcoded hexes replaced with tokens; lucide-react for
  shell/nav icons per SHOC convention

Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots
pixel-sampled against the extracted SHOC spec (all hard values exact,
no blocking deviations).
2026-07-13 19:06:51 -04:00

216 lines
8.6 KiB
TypeScript

import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Grid,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Button,
Skeleton,
Alert,
} from '@mui/material';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import DescriptionIcon from '@mui/icons-material/Description';
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SendIcon from '@mui/icons-material/Send';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import Tooltip from '@mui/material/Tooltip';
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
import { QUERY_KEYS } from '../../constants/queryKeys';
import { STATUS_CHIP_STYLES } from '../../constants';
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<Card sx={{ borderRadius: '8px', transition: 'border-color 120ms ease', '&:hover': { borderColor: '#9CA3AF' } }}>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: 'var(--primary)' }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}>
{label}
</Typography>
<Typography sx={{ fontSize: '30px', fontWeight: 700, color: 'var(--foreground)', letterSpacing: '-0.02em' }}>
{value}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
{subtitle}
</Typography>
)}
</CardContent>
</Card>
);
}
export default function Dashboard() {
useDocumentTitle('Dashboard');
const navigate = useNavigate();
const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
});
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery<ProposalStats>({
queryKey: [QUERY_KEYS.proposals, 'stats'],
queryFn: () => proposalsApi.getStats(),
});
const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0;
const counts = {
inReview: stats?.inReviewCount ?? 0,
approved: stats?.approvedCount ?? 0,
sent: stats?.sentCount ?? 0,
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">Dashboard</Typography>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={() => navigate('/proposals/new')}
>
New Proposal
</Button>
</Box>
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
<Grid container spacing={2} sx={{ mb: 3 }}>
{statsError ? (
<Grid size={{ xs: 12 }}>
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetchStats()}>
Retry
</Button>
}
>
Failed to load dashboard statistics. Please try again.
</Alert>
</Grid>
) : (
<>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
)}
</Grid>
</>
)}
</Grid>
<Card>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Recent Proposals</Typography>
<Button size="small" onClick={() => navigate('/proposals')}>
View All
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Proposal #</TableCell>
<TableCell>Customer</TableCell>
<TableCell>Category</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Bid Amount</TableCell>
<TableCell>Submitted</TableCell>
</TableRow>
</TableHead>
<TableBody>
{recentLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell>
))}
</TableRow>
))
: proposals.map((row) => (
<TableRow
key={row.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/proposals/${row.id}`)}
>
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
</TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>
<Tooltip title={formatDateFull(row.submittedAt)} arrow>
<span>{formatDate(row.submittedAt)}</span>
</Tooltip>
</TableCell>
</TableRow>
))}
{!recentLoading && recentError && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="error">
Failed to load proposals. Please try again.
</Typography>
</TableCell>
</TableRow>
)}
{!recentLoading && !recentError && proposals.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No proposals yet. Submit your first one!
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Box>
);
}