proposal-system/web/src/pages/proposals/list/ProposalListPage.tsx
Adam Moussa 5e89e42e6a Extract shared constants and format utils, wire admin dashboard filters
Move STATUS_COLORS and PRIORITY_COLORS to constants/index.ts and
formatCurrency/formatDate/formatDateTime to lib/format.ts — previously
duplicated across 5 and 4 files respectively.

AdminDashboard: Wire Category and Priority filter dropdowns to
usePaginatedList extraParams (were no-op onChange handlers).

ProposalDetailPage: Add 'Revised' to STATUS_ORDER so the stepper
renders correctly for revised proposals.
2026-05-20 18:09:11 -04:00

155 lines
5 KiB
TypeScript

import { useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Chip,
TextField,
InputAdornment,
Button,
Skeleton,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { STATUS_COLORS } from '../../../constants';
import { formatCurrency, formatDate } from '../../../lib/format';
export default function ProposalListPage() {
const navigate = useNavigate();
const mineParams = { mine: true };
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">My Proposals</Typography>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={() => navigate('/proposals/new')}
>
New Proposal
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
<TextField
size="small"
placeholder="Search proposals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 2, width: 320 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
{err && (
<Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)}
<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>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: rows.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.workOrderNumber}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<Chip
label={row.status}
size="small"
color={STATUS_COLORS[row.status] || 'default'}
/>
</TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No proposals found. Create your first one!
</Typography>
</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>
);
}