mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
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).
216 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|