mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
Add status/priority display improvements, sortable tables, and list filters
- STATUS_LABELS map: InReview displays as "In Review" everywhere - PRIORITY_LABELS map: Emergency → "Emergency Dispatch" - Sent status color changed from green to blue (distinguishes from Approved) - Urgent/Emergency rows get colored borders and icons - All 8 columns in proposals list now sortable via TableSortLabel - Status, Category, and Priority filter dropdowns with server-side filtering - Filters reset pagination to page 1
This commit is contained in:
parent
9b502045dd
commit
666e2bad9e
4 changed files with 176 additions and 44 deletions
|
|
@ -17,21 +17,47 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token';
|
|||
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
|
||||
|
||||
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
|
||||
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const;
|
||||
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const;
|
||||
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
|
||||
|
||||
export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
Draft: 'Draft',
|
||||
InReview: 'In Review',
|
||||
Approved: 'Approved',
|
||||
Sent: 'Sent',
|
||||
Revised: 'Revised',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error' | 'secondary' | 'primary'> = {
|
||||
Draft: 'default',
|
||||
InReview: 'info',
|
||||
Approved: 'success',
|
||||
Sent: 'success',
|
||||
Sent: 'primary',
|
||||
Revised: 'warning',
|
||||
};
|
||||
|
||||
export const DRAWER_WIDTH = 220;
|
||||
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
Standard: 'Standard',
|
||||
Urgent: 'Urgent',
|
||||
Emergency: 'Emergency Dispatch',
|
||||
};
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||||
Standard: 'default',
|
||||
Urgent: 'warning',
|
||||
Emergency: 'error',
|
||||
};
|
||||
|
||||
export const PRIORITY_ROW_SX: Record<string, object> = {
|
||||
Urgent: {
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: 'warning.main',
|
||||
},
|
||||
Emergency: {
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: 'error.main',
|
||||
backgroundColor: 'error.50',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ 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 { 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_COLORS, PRIORITY_COLORS } from '../../../constants';
|
||||
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||
import { formatCurrency, formatDate } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
|
|
@ -171,12 +173,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
select
|
||||
label="Priority"
|
||||
value={priorityFilter}
|
||||
sx={{ width: 140 }}
|
||||
sx={{ width: 200 }}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>{p}</MenuItem>
|
||||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
|
|
@ -189,7 +191,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PROPOSAL_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
|
@ -226,7 +228,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
sx={{ cursor: 'pointer', ...PRIORITY_ROW_SX[row.priority] }}
|
||||
onClick={() => navigate(`/admin/proposals/${row.id}`)}
|
||||
>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
||||
|
|
@ -234,10 +236,15 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
<TableCell>{row.workOrderNumber}</TableCell>
|
||||
<TableCell>{row.serviceCategory}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={row.priority} size="small" color={PRIORITY_COLORS[row.priority] || 'default'} />
|
||||
<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={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
||||
import { QUERY_KEYS } from '../../constants/queryKeys';
|
||||
import { STATUS_COLORS } from '../../constants';
|
||||
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_ROW_SX } from '../../constants';
|
||||
import { formatCurrency, formatDate } from '../../lib/format';
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
||||
|
||||
|
|
@ -144,14 +144,14 @@ export default function Dashboard() {
|
|||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
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.serviceCategory}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
|
|
@ -11,24 +12,52 @@ import {
|
|||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
TableSortLabel,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
InputAdornment,
|
||||
Button,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import ReportIcon from '@mui/icons-material/Report';
|
||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||||
import { STATUS_COLORS } from '../../../constants';
|
||||
import { STATUS_COLORS, STATUS_LABELS, PROPOSAL_STATUSES, SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||
import { formatCurrency, formatDate } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
type SortKey = keyof ProposalListItem;
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const columns: { key: SortKey; label: string; align?: 'right' }[] = [
|
||||
{ key: 'proposalNumber', label: 'Proposal #' },
|
||||
{ key: 'customerName', label: 'Customer' },
|
||||
{ key: 'workOrderNumber', label: 'WO #' },
|
||||
{ key: 'serviceCategory', label: 'Category' },
|
||||
{ key: 'priority', label: 'Priority' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'totalBidAmount', label: 'Bid Amount', align: 'right' },
|
||||
{ key: 'submittedAt', label: 'Submitted' },
|
||||
];
|
||||
|
||||
export default function ProposalListPage() {
|
||||
useDocumentTitle('My Proposals');
|
||||
const navigate = useNavigate();
|
||||
const mineParams = { mine: true };
|
||||
const [sortKey, setSortKey] = useState<SortKey>('submittedAt');
|
||||
const [sortDir, setSortDir] = useState<SortDirection>('desc');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterPriority, setFilterPriority] = useState('');
|
||||
const extraParams = useMemo(() => ({
|
||||
mine: true,
|
||||
...(filterStatus && { status: filterStatus }),
|
||||
...(filterCategory && { serviceCategory: filterCategory }),
|
||||
...(filterPriority && { priority: filterPriority }),
|
||||
}), [filterStatus, filterCategory, filterPriority]);
|
||||
const {
|
||||
rows,
|
||||
search,
|
||||
|
|
@ -40,7 +69,26 @@ export default function ProposalListPage() {
|
|||
totalCount,
|
||||
loading,
|
||||
err,
|
||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
|
||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, extraParams);
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!rows.length) return rows;
|
||||
return [...rows].sort((a, b) => {
|
||||
const aVal = a[sortKey] ?? '';
|
||||
const bVal = b[sortKey] ?? '';
|
||||
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [rows, sortKey, sortDir]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -57,22 +105,63 @@ export default function ProposalListPage() {
|
|||
|
||||
<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>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: 260 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Status"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
sx={{ width: 140 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PROPOSAL_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Category"
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{SERVICE_CATEGORIES.map((c) => (
|
||||
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Priority"
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
sx={{ width: 200 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
{err && (
|
||||
<Typography color="error" sx={{ mb: 2 }}>
|
||||
|
|
@ -84,14 +173,17 @@ export default function ProposalListPage() {
|
|||
<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>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align} sortDirection={sortKey === col.key ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
active={sortKey === col.key}
|
||||
direction={sortKey === col.key ? sortDir : 'asc'}
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -105,21 +197,28 @@ export default function ProposalListPage() {
|
|||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
: sortedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
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>{row.priority}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={row.status}
|
||||
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'}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue