proposal-system/web/src/pages/admin/dashboard/AdminDashboard.tsx
Adam Moussa 36fc1120bf
build(deps): upgrade MUI to v9 (material + icons) and group @mui/* in Dependabot (#168)
Bump @mui/material and @mui/icons-material 7.3.1 -> ^9.1.1 together in
/web. They must move in lockstep (icons peer-depends on material), so
Dependabot's separate per-package PRs (#145/#146) could never go green
individually (ERESOLVE). Adds a 'mui' Dependabot group so future @mui/*
updates — including majors — are raised as one PR.

MUI v9 migration fixes (v7->v9 spans two majors):
- ListItemText: primaryTypographyProps -> slotProps.primary; fontSize
  moved into sx (Typography system props removed in v9).
- Autocomplete renderInput: params.InputProps -> params.slotProps.input;
  spread ...params.slotProps to retain inputLabel/htmlInput slots.
- Icons: @mui/icons-material/ErrorOutline -> ErrorOutlined (the plain
  ErrorOutline export was removed in v9).
- vitest: inline @mui/material + react-transition-group so Vite resolves
  v9's Transition.mjs directory import (native ESM loader can't).

Verified locally on Node 24: npm ci, npm run build, and npm test
(26 tests, 3 suites) all pass.
2026-06-22 16:02:43 -04:00

428 lines
16 KiB
TypeScript

import { useMemo, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Grid,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Chip,
TextField,
InputAdornment,
MenuItem,
Button,
Skeleton,
Tabs,
Tab,
Collapse,
IconButton,
Tooltip,
Alert,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import PendingActionsIcon from '@mui/icons-material/PendingActions';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import TimerIcon from '@mui/icons-material/Timer';
import DescriptionIcon from '@mui/icons-material/Description';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import ReportIcon from '@mui/icons-material/Report';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
import { QUERY_KEYS } from '../../../constants/queryKeys';
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const COL_COUNT = 11;
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNode; label: string; value: string; color: 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={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{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: '#111827', letterSpacing: '-0.02em' }}>
{value}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
{subtitle}
</Typography>
)}
</CardContent>
</Card>
);
}
export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
const navigate = useNavigate();
const showTabs = !defaultStatus;
// Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery<DashboardStats>({
queryKey: [QUERY_KEYS.dashboard],
queryFn: adminApi.getDashboard,
});
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
const [categoryFilter, setCategoryFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, {
...(statusFilter && { status: statusFilter }),
...(categoryFilter && { serviceCategory: categoryFilter }),
...(priorityFilter && { priority: priorityFilter }),
});
const grouped = useMemo(() => groupRevisions(rows), [rows]);
const toggleExpand = (baseNumber: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(baseNumber)) next.delete(baseNumber);
else next.add(baseNumber);
return next;
});
};
const tabIndex = TAB_STATUSES.indexOf(statusFilter as typeof TAB_STATUSES[number]);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setStatusFilter(TAB_STATUSES[newValue] ?? '');
setPage(1);
};
const hasActiveFilters = statusFilter || categoryFilter || priorityFilter || search;
const clearFilters = () => {
setStatusFilter(defaultStatus ?? '');
setCategoryFilter('');
setPriorityFilter('');
setSearch('');
};
const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => (
<TableRow
key={row.id}
hover
sx={{
cursor: 'pointer',
...PRIORITY_ROW_SX[row.priority],
...(indent && { bgcolor: 'action.hover' }),
}}
onClick={() => navigate(`/admin/proposals/${row.id}`)}
>
<TableCell sx={{ width: 40, px: 1 }}>
{showExpandIcon && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleExpand(baseNumber);
}}
>
{isExpanded ? <KeyboardArrowDownIcon fontSize="small" /> : <KeyboardArrowRightIcon fontSize="small" />}
</IconButton>
)}
</TableCell>
<TableCell sx={{ fontWeight: 600, ...(indent && { pl: 5 }) }}>{row.proposalNumber}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.workOrderNumber}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>
<Chip
label={PRIORITY_LABELS[row.priority] || row.priority}
size="small"
sx={{ ...PRIORITY_CHIP_STYLES[row.priority], '& .MuiChip-icon': { color: 'inherit' } }}
icon={row.priority === 'Emergency' ? <ReportIcon /> : row.priority === 'Urgent' ? <WarningAmberIcon /> : undefined}
/>
</TableCell>
<TableCell>
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
</TableCell>
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
<TableCell>
<Tooltip title={formatDateFull(row.submittedAt)} arrow>
<span>{formatDate(row.submittedAt)}</span>
</Tooltip>
</TableCell>
<TableCell>{row.submittedByName || '-'}</TableCell>
<TableCell sx={{ color: getAgeColor(row.submittedAt), fontWeight: getAgeColor(row.submittedAt) ? 600 : undefined }}>
{formatAge(row.submittedAt)}
</TableCell>
</TableRow>
);
return (
<Box>
<Typography variant="h5" sx={{ mb: 3 }}>{defaultStatus ? 'Admin Queue' : 'All Proposals'}</Typography>
{/* 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: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<PendingActionsIcon fontSize="large" />}
label="Pending Review"
value={String(stats?.pendingCount ?? 0)}
color="#1565c0"
subtitle="Awaiting action"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<CheckCircleIcon fontSize="large" />}
label="Approved This Week"
value={String(stats?.approvedThisWeek ?? 0)}
color="#2e7d32"
subtitle={`${stats?.approvedThisWeek ?? 0} approved`}
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<TimerIcon fontSize="large" />}
label="Avg Turnaround"
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
color="#f57c00"
subtitle="Target: <24h"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<DescriptionIcon fontSize="large" />}
label="Total Proposals"
value={String(stats?.totalProposals ?? 0)}
color="#5e35b1"
subtitle="All time"
/>
)}
</Grid>
</>
)}
</Grid>
<Card>
<CardContent sx={{ pb: 0 }}>
{showTabs && (
<Tabs
value={tabIndex >= 0 ? tabIndex : 0}
onChange={handleTabChange}
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
>
<Tab label="All" />
{TAB_STATUSES.slice(1).map((s) => (
<Tab key={s} label={STATUS_LABELS[s] ?? s} />
))}
</Tabs>
)}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField
size="small"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ width: 240 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
<TextField
size="small"
select
label="Category"
value={categoryFilter}
sx={{ width: 160 }}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{SERVICE_CATEGORIES.map((c) => (
<MenuItem key={c} value={c}>{c}</MenuItem>
))}
</TextField>
<TextField
size="small"
select
label="Priority"
value={priorityFilter}
sx={{ width: 200 }}
onChange={(e) => setPriorityFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
))}
</TextField>
{!showTabs && (
<TextField
size="small"
select
label="Status"
value={statusFilter}
sx={{ width: 140 }}
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
))}
</TextField>
)}
{hasActiveFilters && (
<Button size="small" onClick={clearFilters}>
Clear Filters
</Button>
)}
</Box>
{err && (
<Typography color="error" sx={{ mb: 2 }}>{err}</Typography>
)}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 40, px: 1 }} />
<TableCell>Proposal #</TableCell>
<TableCell>Customer</TableCell>
<TableCell>WO #</TableCell>
<TableCell>Category</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Bid Amount</TableCell>
<TableCell>Submitted</TableCell>
<TableCell>Submitted By</TableCell>
<TableCell>Age</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COL_COUNT }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell>
))}
</TableRow>
))
: grouped.map((group) => {
const isExpanded = expanded.has(group.baseNumber);
return (
<Fragment key={group.baseNumber}>
{renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)}
{group.hasRevisions && (
<TableRow>
<TableCell colSpan={COL_COUNT} sx={{ p: 0, border: 0 }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small">
<TableBody>
{group.revisions.map((rev) =>
renderRow(rev, true, false, false, group.baseNumber)
)}
</TableBody>
</Table>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary" sx={{ mb: 1 }}>
{hasActiveFilters
? 'No proposals match your current filters.'
: 'No proposals in queue.'}
</Typography>
{hasActiveFilters && (
<Button size="small" onClick={clearFilters}>
Clear Filters
</Button>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent>
</Card>
</Box>
);
}