mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-06 07:41:59 +00:00
Medium-effort improvements: - Shrink KPI cards and make each clickable (navigates to filtered list) - Role-specific KPI labels (admin: All Proposals/Pending Review; dispatcher: Total Submitted/In Review) - Reorder sidebar nav per role (admins see Admin section first) - Add WO# and Priority columns to Dashboard recent proposals table - Replace "View All" with "View All Proposals" button with arrow icon - Add Age column to admin queue with color-coded staleness (>2d orange, >5d red) Heavy-lift improvements: - Status tabs on All Proposals page (replace status dropdown with All/In Review/Approved/Sent/Revised tabs) - Group proposal revisions in tables (expand/collapse, latest shown by default) - Collapsible left panel in admin workspace (chevron toggle, center panel expands to fill) - Sticky action bar with total display, item count, vendor cost, compact unsaved-changes chip - Restructure proposal form into 3 card sections (Job Details, Site & Location, Work Details) - Disabled-submit helper text showing missing required fields - Compact status timeline with timestamps under completed steps - Status explanation below timeline (e.g., "Awaiting admin pricing and approval")
210 lines
8.9 KiB
TypeScript
210 lines
8.9 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,
|
|
} 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 ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
|
import ReportIcon from '@mui/icons-material/Report';
|
|
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
|
import { QUERY_KEYS } from '../../constants/queryKeys';
|
|
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../constants';
|
|
import { formatBidAmount, formatDate } from '../../lib/format';
|
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
|
import { useAuth } from '../../hooks/useAuth';
|
|
|
|
function KpiCard({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: string; onClick: () => void }) {
|
|
return (
|
|
<Card
|
|
onClick={onClick}
|
|
sx={{
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
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: 1.5 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: '#0c4f6f' }}>{icon}</Box>
|
|
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
|
|
{label}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '22px', fontWeight: 700, color: '#003a5d' }}>
|
|
{value}
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
useDocumentTitle('Dashboard');
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
|
|
|
|
const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
|
|
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent', isAdmin],
|
|
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: !isAdmin }),
|
|
});
|
|
|
|
const { data: stats, isLoading: statsLoading } = useQuery<ProposalStats>({
|
|
queryKey: [QUERY_KEYS.proposals, 'stats'],
|
|
queryFn: () => proposalsApi.getStats(),
|
|
});
|
|
|
|
const isLoading = recentLoading || statsLoading;
|
|
const proposals = recentData?.items ?? [];
|
|
const total = stats?.totalCount ?? 0;
|
|
const counts = {
|
|
inReview: stats?.inReviewCount ?? 0,
|
|
approved: stats?.approvedCount ?? 0,
|
|
sent: stats?.sentCount ?? 0,
|
|
};
|
|
|
|
const kpiCards = isAdmin
|
|
? [
|
|
{ icon: <DescriptionIcon fontSize="large" />, label: 'All Proposals', value: String(total), onClick: () => navigate('/admin/proposals') },
|
|
{ icon: <HourglassTopIcon fontSize="large" />, label: 'Pending Review', value: String(counts.inReview), onClick: () => navigate('/admin') },
|
|
{ icon: <CheckCircleIcon fontSize="large" />, label: 'Approved', value: String(counts.approved), onClick: () => navigate('/admin/proposals?status=Approved') },
|
|
{ icon: <SendIcon fontSize="large" />, label: 'Sent', value: String(counts.sent), onClick: () => navigate('/admin/proposals?status=Sent') },
|
|
]
|
|
: [
|
|
{ icon: <DescriptionIcon fontSize="large" />, label: 'Total Submitted', value: String(total), onClick: () => navigate('/proposals') },
|
|
{ icon: <HourglassTopIcon fontSize="large" />, label: 'In Review', value: String(counts.inReview), onClick: () => navigate('/proposals?status=InReview') },
|
|
{ icon: <CheckCircleIcon fontSize="large" />, label: 'Approved', value: String(counts.approved), onClick: () => navigate('/proposals?status=Approved') },
|
|
{ icon: <SendIcon fontSize="large" />, label: 'Sent', value: String(counts.sent), onClick: () => navigate('/proposals?status=Sent') },
|
|
];
|
|
|
|
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>
|
|
|
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
|
{kpiCards.map((card) => (
|
|
<Grid key={card.label} size={{ xs: 6, sm: 3 }}>
|
|
{isLoading ? (
|
|
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 2 }} />
|
|
) : (
|
|
<KpiCard icon={card.icon} label={card.label} value={card.value} onClick={card.onClick} />
|
|
)}
|
|
</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"
|
|
variant="text"
|
|
endIcon={<ArrowForwardIcon />}
|
|
onClick={() => navigate(isAdmin ? '/admin/proposals' : '/proposals')}
|
|
>
|
|
View All Proposals
|
|
</Button>
|
|
</Box>
|
|
|
|
<TableContainer>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<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>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{isLoading
|
|
? Array.from({ length: 5 }).map((_, i) => (
|
|
<TableRow key={i}>
|
|
{Array.from({ length: 8 }).map((_, j) => (
|
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
: proposals.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
hover
|
|
sx={{ cursor: 'pointer', ...PRIORITY_ROW_SX[row.priority] }}
|
|
onClick={() => navigate(`/proposals/${row.id}`)}
|
|
>
|
|
<TableCell sx={{ fontWeight: 600 }}>{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"
|
|
color={PRIORITY_COLORS[row.priority] || 'default'}
|
|
icon={row.priority === 'Emergency' ? <ReportIcon /> : row.priority === 'Urgent' ? <WarningAmberIcon /> : undefined}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
|
</TableCell>
|
|
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
|
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!isLoading && recentError && (
|
|
<TableRow>
|
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
|
<Typography color="error">
|
|
Failed to load proposals. Please try again.
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!isLoading && !recentError && proposals.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={8} 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>
|
|
);
|
|
}
|