mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 08:53:15 +00:00
dashboard: audit KPI styling, secondary context lines, page title consistency
This commit is contained in:
parent
2e20e7ad99
commit
a6091344da
3 changed files with 212 additions and 57 deletions
|
|
@ -7,6 +7,16 @@ export function formatBidAmount(amount: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(iso: string): string {
|
export function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const isCurrentYear = d.getFullYear() === new Date().getFullYear();
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(isCurrentYear ? {} : { year: 'numeric' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateFull(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useMemo, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,32 +18,50 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Button,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import PendingActionsIcon from '@mui/icons-material/PendingActions';
|
import PendingActionsIcon from '@mui/icons-material/PendingActions';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import TimerIcon from '@mui/icons-material/Timer';
|
import TimerIcon from '@mui/icons-material/Timer';
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import ReportIcon from '@mui/icons-material/Report';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
||||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||||||
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
||||||
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, PRIORITY_CHIP_STYLES } from '../../../constants';
|
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants';
|
||||||
import { formatCurrency, formatDate } from '../../../lib/format';
|
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
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 (
|
return (
|
||||||
<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)' } }}>
|
<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 }}>
|
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
|
||||||
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
|
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
|
||||||
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
|
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
|
<Typography sx={{ fontSize: '36px', fontWeight: 800, color: '#073B4C', letterSpacing: '-0.03em' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -52,6 +70,7 @@ function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label:
|
||||||
export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
|
export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
|
||||||
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
|
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const showTabs = !defaultStatus;
|
||||||
|
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||||||
queryKey: [QUERY_KEYS.dashboard],
|
queryKey: [QUERY_KEYS.dashboard],
|
||||||
|
|
@ -61,6 +80,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('');
|
const [priorityFilter, setPriorityFilter] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -79,9 +99,88 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
...(priorityFilter && { priority: priorityFilter }),
|
...(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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 3 }}>Admin Dashboard</Typography>
|
<Typography variant="h5" sx={{ mb: 3 }}>{defaultStatus ? 'Admin Queue' : 'All Proposals'}</Typography>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
|
@ -93,6 +192,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Pending Review"
|
label="Pending Review"
|
||||||
value={String(stats?.pendingCount ?? 0)}
|
value={String(stats?.pendingCount ?? 0)}
|
||||||
color="#1565c0"
|
color="#1565c0"
|
||||||
|
subtitle="Awaiting action"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -105,6 +205,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Approved This Week"
|
label="Approved This Week"
|
||||||
value={String(stats?.approvedThisWeek ?? 0)}
|
value={String(stats?.approvedThisWeek ?? 0)}
|
||||||
color="#2e7d32"
|
color="#2e7d32"
|
||||||
|
subtitle={`${stats?.approvedThisWeek ?? 0} approved`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -117,6 +218,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Avg Turnaround"
|
label="Avg Turnaround"
|
||||||
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
|
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
|
||||||
color="#f57c00"
|
color="#f57c00"
|
||||||
|
subtitle="Target: <24h"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -129,6 +231,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Total Proposals"
|
label="Total Proposals"
|
||||||
value={String(stats?.totalProposals ?? 0)}
|
value={String(stats?.totalProposals ?? 0)}
|
||||||
color="#5e35b1"
|
color="#5e35b1"
|
||||||
|
subtitle="All time"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -136,6 +239,19 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ pb: 0 }}>
|
<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' }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -171,27 +287,34 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
select
|
select
|
||||||
label="Priority"
|
label="Priority"
|
||||||
value={priorityFilter}
|
value={priorityFilter}
|
||||||
sx={{ width: 140 }}
|
sx={{ width: 200 }}
|
||||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{PRIORITIES.map((p) => (
|
{PRIORITIES.map((p) => (
|
||||||
<MenuItem key={p} value={p}>{p}</MenuItem>
|
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<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}>{s}</MenuItem>
|
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
|
|
@ -202,6 +325,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 40, px: 1 }} />
|
||||||
<TableCell>Proposal #</TableCell>
|
<TableCell>Proposal #</TableCell>
|
||||||
<TableCell>Customer</TableCell>
|
<TableCell>Customer</TableCell>
|
||||||
<TableCell>WO #</TableCell>
|
<TableCell>WO #</TableCell>
|
||||||
|
|
@ -211,43 +335,54 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
<TableCell align="right">Bid Amount</TableCell>
|
<TableCell align="right">Bid Amount</TableCell>
|
||||||
<TableCell>Submitted</TableCell>
|
<TableCell>Submitted</TableCell>
|
||||||
<TableCell>Submitted By</TableCell>
|
<TableCell>Submitted By</TableCell>
|
||||||
|
<TableCell>Age</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading
|
{loading
|
||||||
? Array.from({ length: 5 }).map((_, i) => (
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
{Array.from({ length: 9 }).map((_, j) => (
|
{Array.from({ length: COL_COUNT }).map((_, j) => (
|
||||||
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
: rows.map((row) => (
|
: grouped.map((group) => {
|
||||||
<TableRow
|
const isExpanded = expanded.has(group.baseNumber);
|
||||||
key={row.id}
|
return (
|
||||||
hover
|
<Fragment key={group.baseNumber}>
|
||||||
sx={{ cursor: 'pointer' }}
|
{renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)}
|
||||||
onClick={() => navigate(`/admin/proposals/${row.id}`)}
|
{group.hasRevisions && (
|
||||||
>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
<TableCell colSpan={COL_COUNT} sx={{ p: 0, border: 0 }}>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
<TableCell>{row.workOrderNumber}</TableCell>
|
<Table size="small">
|
||||||
<TableCell>{row.serviceCategory}</TableCell>
|
<TableBody>
|
||||||
<TableCell>
|
{group.revisions.map((rev) =>
|
||||||
<Chip label={row.priority} size="small" sx={PRIORITY_CHIP_STYLES[row.priority]} />
|
renderRow(rev, true, false, false, group.baseNumber)
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell>
|
</TableBody>
|
||||||
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
|
</Table>
|
||||||
</TableCell>
|
</Collapse>
|
||||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
</TableRow>
|
||||||
<TableCell>{row.submittedByName || '-'}</TableCell>
|
)}
|
||||||
</TableRow>
|
</Fragment>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary">No proposals in queue</Typography>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -21,23 +21,29 @@ import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
|
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
||||||
import { QUERY_KEYS } from '../../constants/queryKeys';
|
import { QUERY_KEYS } from '../../constants/queryKeys';
|
||||||
import { STATUS_CHIP_STYLES } from '../../constants';
|
import { STATUS_CHIP_STYLES } from '../../constants';
|
||||||
import { formatCurrency, formatDate } from '../../lib/format';
|
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
|
||||||
return (
|
return (
|
||||||
<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)' } }}>
|
<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 }}>
|
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: '#0c4f6f' }}>{icon}</Box>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: '#0B5A73' }}>{icon}</Box>
|
||||||
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
|
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
|
<Typography sx={{ fontSize: '36px', fontWeight: 800, color: '#073B4C', letterSpacing: '-0.03em' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -84,28 +90,28 @@ export default function Dashboard() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
) : (
|
) : (
|
||||||
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} />
|
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" />
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, sm: 3 }}>
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
) : (
|
) : (
|
||||||
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} />
|
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, sm: 3 }}>
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
) : (
|
) : (
|
||||||
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} />
|
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, sm: 3 }}>
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
) : (
|
) : (
|
||||||
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} />
|
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -154,7 +160,11 @@ export default function Dashboard() {
|
||||||
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
|
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
<TableCell>
|
||||||
|
<Tooltip title={formatDateFull(row.submittedAt)} arrow>
|
||||||
|
<span>{formatDate(row.submittedAt)}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && recentError && (
|
{!isLoading && recentError && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue