mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
Add UX improvements: clickable KPIs, status tabs, revision grouping, workspace restructure, form sections
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")
This commit is contained in:
parent
d583f99f5b
commit
594d3395c6
9 changed files with 749 additions and 424 deletions
|
|
@ -66,27 +66,11 @@
|
|||
## Not done yet
|
||||
|
||||
### Must do before push
|
||||
- **Not pushed** — 7 commits on main ahead of origin, needs `git push`
|
||||
- **Not pushed** — 8 commits on main ahead of origin, needs `git push`
|
||||
- CDK compute stack needs `IAmazonDynamoDB` read permission on `verified-sites` table for production `SiteService`
|
||||
|
||||
### UX improvements (from external review — medium effort)
|
||||
- Shrink KPI cards 25–35%, make each clickable (navigate to filtered list)
|
||||
- Role-specific KPI labels (dispatcher: My Submitted / Needs Revision; admin: Pending Review / Urgent / Unpriced)
|
||||
- Reorder nav items per role (admins: Admin Queue first, dispatchers: Dashboard first)
|
||||
- Move Priority column before Status in tables
|
||||
- Add Age column to admin queue (e.g., "2h", "1d")
|
||||
- Replace "View All" with stronger "View All Proposals" button
|
||||
- Add WO # and Priority to Dashboard recent proposals table
|
||||
|
||||
### UX improvements (from external review — heavy lift)
|
||||
- Group proposal revisions in tables (show latest only, expand for history)
|
||||
- Restructure admin workspace layout (wider center panel, collapsible submission details, hide empty Similar Proposals)
|
||||
- Restructure proposal form into sections (job identifiers, site/location, work details, attachments)
|
||||
- Status tabs on All Proposals page (replace status dropdown)
|
||||
- Sticky action bar in admin workspace with total display
|
||||
- Add disabled-submit helper text on proposal form ("Complete required fields to submit")
|
||||
- Reduce status timeline height 30–40%, add timestamps under completed steps
|
||||
- Add "current status" explanation on detail page (e.g., "Awaiting admin pricing and approval")
|
||||
### UX improvements (from external review — DONE)
|
||||
All 15 medium-effort and heavy-lift UX improvements from the external review have been implemented (commit 8).
|
||||
|
||||
### Infrastructure / ops
|
||||
- Confluence architecture map update (page id 1540098)
|
||||
|
|
|
|||
|
|
@ -73,33 +73,6 @@ export default function Sidebar() {
|
|||
>
|
||||
<Toolbar sx={{ minHeight: 64 }} />
|
||||
<Box sx={{ overflow: 'auto', flexGrow: 1, pt: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#9aa0a6',
|
||||
px: 2.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Proposals
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{navItems.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={isNavActive(location.pathname, item.path)}
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Typography
|
||||
|
|
@ -110,7 +83,6 @@ export default function Sidebar() {
|
|||
textTransform: 'uppercase',
|
||||
color: '#9aa0a6',
|
||||
px: 2.5,
|
||||
mt: 2,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
|
|
@ -142,6 +114,33 @@ export default function Sidebar() {
|
|||
</List>
|
||||
</>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#9aa0a6',
|
||||
px: 2.5,
|
||||
mt: isAdmin ? 2 : 0,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Proposals
|
||||
</Typography>
|
||||
<List disablePadding>
|
||||
{navItems.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={isNavActive(location.pathname, item.path)}
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
{user && (
|
||||
<Box sx={{ p: 2, mt: 'auto', borderTop: '1px solid #d9dde0' }}>
|
||||
|
|
|
|||
|
|
@ -20,3 +20,64 @@ export function formatDateTime(iso: string | null): string {
|
|||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAge(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
|
||||
if (diffHours < 1) return `${Math.max(diffMinutes, 0)}m`;
|
||||
if (diffDays < 1) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return `${diffWeeks}w`;
|
||||
}
|
||||
|
||||
export function getAgeColor(iso: string): string | undefined {
|
||||
const diffDays = (Date.now() - new Date(iso).getTime()) / 86400000;
|
||||
if (diffDays > 5) return '#d32f2f';
|
||||
if (diffDays > 2) return '#ed6c02';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface GroupedProposal<T extends { proposalNumber: string }> {
|
||||
latest: T;
|
||||
revisions: T[];
|
||||
hasRevisions: boolean;
|
||||
baseNumber: string;
|
||||
}
|
||||
|
||||
export function groupRevisions<T extends { proposalNumber: string }>(rows: T[]): GroupedProposal<T>[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
const base = row.proposalNumber.replace(/-R\d+$/, '');
|
||||
const existing = groups.get(base);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
groups.set(base, [row]);
|
||||
}
|
||||
}
|
||||
|
||||
const result: GroupedProposal<T>[] = [];
|
||||
for (const [baseNumber, items] of groups) {
|
||||
items.sort((a, b) => {
|
||||
const revA = extractRevision(a.proposalNumber);
|
||||
const revB = extractRevision(b.proposalNumber);
|
||||
return revB - revA;
|
||||
});
|
||||
result.push({
|
||||
latest: items[0]!,
|
||||
revisions: items.slice(1),
|
||||
hasRevisions: items.length > 1,
|
||||
baseNumber,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractRevision(proposalNumber: string): number {
|
||||
const match = proposalNumber.match(/-R(\d+)$/);
|
||||
return match ? parseInt(match[1]!, 10) : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -20,6 +20,10 @@ import {
|
|||
MenuItem,
|
||||
Button,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
Tab,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import PendingActionsIcon from '@mui/icons-material/PendingActions';
|
||||
|
|
@ -28,14 +32,19 @@ 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 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_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||
import { formatBidAmount, formatDate } from '../../../lib/format';
|
||||
import { formatBidAmount, formatDate, 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 }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
||||
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)' } }}>
|
||||
|
|
@ -55,6 +64,7 @@ function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label:
|
|||
export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
|
||||
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
|
||||
const navigate = useNavigate();
|
||||
const showTabs = !defaultStatus;
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||||
queryKey: [QUERY_KEYS.dashboard],
|
||||
|
|
@ -64,6 +74,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [priorityFilter, setPriorityFilter] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
rows,
|
||||
|
|
@ -82,6 +93,81 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
...(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"
|
||||
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>
|
||||
<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 }}>Admin Dashboard</Typography>
|
||||
|
|
@ -139,6 +225,19 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
|
||||
<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"
|
||||
|
|
@ -182,24 +281,23 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
<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}>{STATUS_LABELS[s] || s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{(statusFilter || categoryFilter || priorityFilter || search) && (
|
||||
<Button
|
||||
{!showTabs && (
|
||||
<TextField
|
||||
size="small"
|
||||
onClick={() => { setStatusFilter(defaultStatus ?? ''); setCategoryFilter(''); setPriorityFilter(''); setSearch(''); }}
|
||||
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>
|
||||
)}
|
||||
|
|
@ -213,6 +311,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 40, px: 1 }} />
|
||||
<TableCell>Proposal #</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>WO #</TableCell>
|
||||
|
|
@ -222,54 +321,51 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
<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: 9 }).map((_, j) => (
|
||||
{Array.from({ length: COL_COUNT }).map((_, j) => (
|
||||
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer', ...PRIORITY_ROW_SX[row.priority] }}
|
||||
onClick={() => navigate(`/admin/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>
|
||||
<TableCell>{row.submittedByName || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
: grouped.map((group) => {
|
||||
const isExpanded = expanded.has(group.baseNumber);
|
||||
return (
|
||||
<Box component="tbody" 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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{(statusFilter || categoryFilter || priorityFilter || search)
|
||||
{hasActiveFilters
|
||||
? 'No proposals match your current filters.'
|
||||
: 'No proposals in queue.'}
|
||||
</Typography>
|
||||
{(statusFilter || categoryFilter || priorityFilter || search) && (
|
||||
<Button size="small" onClick={() => { setStatusFilter(defaultStatus ?? ''); setCategoryFilter(''); setPriorityFilter(''); setSearch(''); }}>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Grid,
|
||||
Button,
|
||||
Chip,
|
||||
Collapse,
|
||||
Divider,
|
||||
TextField,
|
||||
Dialog,
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
DialogContent,
|
||||
DialogActions,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Menu,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
|
|
@ -31,6 +31,8 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
|||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import UndoIcon from '@mui/icons-material/Undo';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { toast } from 'react-toastify';
|
||||
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
||||
|
|
@ -42,6 +44,7 @@ import LineItemEditor, { type EditableLineItem } from '../../../components/admin
|
|||
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
||||
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
||||
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
|
||||
import { formatCurrency } from '../../../lib/format';
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
Submit: 'Submitted',
|
||||
|
|
@ -88,6 +91,7 @@ export default function AdminWorkspace() {
|
|||
const [dirty, setDirty] = useState(false);
|
||||
const [revMenuAnchor, setRevMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [downloadingRevId, setDownloadingRevId] = useState<string | null>(null);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
|
||||
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
|
||||
queryKey: [QUERY_KEYS.proposals, id],
|
||||
|
|
@ -420,66 +424,86 @@ export default function AdminWorkspace() {
|
|||
|
||||
{/* Three-Panel Layout */}
|
||||
<Grid container spacing={2}>
|
||||
{/* Left Panel: Reference */}
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
|
||||
{/* Left Panel: Reference (collapsible) */}
|
||||
{!leftCollapsed && (
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Collapse in={!leftCollapsed} orientation="horizontal">
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">Submission Details</Typography>
|
||||
<IconButton size="small" onClick={() => setLeftCollapsed(true)}>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">Submitted By</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Submitted By</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">Submitted</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{new Date(proposal.submittedAt).toLocaleString()}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Typography variant="caption" color="text.secondary">Original Scope</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}>
|
||||
{proposal.scopeOfWork}
|
||||
</Typography>
|
||||
|
||||
{proposal.notes && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">Notes</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
|
||||
{proposal.notes}
|
||||
<Typography variant="caption" color="text.secondary">Submitted</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{new Date(proposal.submittedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<VendorDataPanel proposalId={id!} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Typography variant="caption" color="text.secondary">Original Scope</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}>
|
||||
{proposal.scopeOfWork}
|
||||
</Typography>
|
||||
|
||||
{proposal.notes && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">Notes</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
|
||||
{proposal.notes}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<VendorDataPanel proposalId={id!} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Center Panel: Editor */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
value={refinedScope}
|
||||
onChange={(e) => { setRefinedScope(e.target.value); setDirty(true); }}
|
||||
disabled={!isEditable}
|
||||
<Grid size={{ xs: 12, md: leftCollapsed ? 9 : 6 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{leftCollapsed && (
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
sx={{ alignSelf: 'flex-start', mt: 1 }}
|
||||
>
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Card sx={{ flexGrow: 1 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
value={refinedScope}
|
||||
onChange={(e) => { setRefinedScope(e.target.value); setDirty(true); }}
|
||||
disabled={!isEditable}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<LineItemEditor
|
||||
items={lineItems}
|
||||
onChange={handleLineItemsChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<LineItemEditor
|
||||
items={lineItems}
|
||||
onChange={handleLineItemsChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Similar Proposals */}
|
||||
|
|
@ -503,12 +527,27 @@ export default function AdminWorkspace() {
|
|||
</Grid>
|
||||
|
||||
{/* Action Bar */}
|
||||
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
|
||||
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Card sx={{ position: 'sticky', bottom: 0, zIndex: 10, borderTop: '1px solid', borderColor: 'divider', borderRadius: 0 }}>
|
||||
<CardContent sx={{ py: 1.5, px: 2, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.1rem' }}>
|
||||
{bidTotal > 0 ? `Total: ${formatCurrency(bidTotal)}` : 'Total: Not priced'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{lineItems.length} {lineItems.length === 1 ? 'item' : 'items'}
|
||||
</Typography>
|
||||
{proposal.vendorTotalCost != null && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Vendor: {formatCurrency(proposal.vendorTotalCost)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{dirty && (
|
||||
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
|
||||
<Chip label="Unsaved changes" size="small" color="info" variant="outlined" />
|
||||
)}
|
||||
{!dirty && <Box sx={{ flexGrow: 1 }} />}
|
||||
|
||||
{isEditable && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -21,22 +21,33 @@ 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_ROW_SX } from '../../constants';
|
||||
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 }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
function KpiCard({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: string; onClick: () => void }) {
|
||||
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)' } }}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
|
||||
<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: '32px', fontWeight: 700, color: '#003a5d' }}>
|
||||
<Typography sx={{ fontSize: '22px', fontWeight: 700, color: '#003a5d' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
|
@ -69,6 +80,20 @@ export default function Dashboard() {
|
|||
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 }}>
|
||||
|
|
@ -83,42 +108,28 @@ export default function Dashboard() {
|
|||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||
) : (
|
||||
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} />
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||
) : (
|
||||
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} />
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||
) : (
|
||||
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} />
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
{isLoading ? (
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||
) : (
|
||||
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} />
|
||||
)}
|
||||
</Grid>
|
||||
{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" onClick={() => navigate('/proposals')}>
|
||||
View All
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
onClick={() => navigate(isAdmin ? '/admin/proposals' : '/proposals')}
|
||||
>
|
||||
View All Proposals
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
|
@ -128,7 +139,9 @@ export default function Dashboard() {
|
|||
<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>
|
||||
|
|
@ -138,7 +151,7 @@ export default function Dashboard() {
|
|||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -152,7 +165,16 @@ export default function Dashboard() {
|
|||
>
|
||||
<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>
|
||||
|
|
@ -162,7 +184,7 @@ export default function Dashboard() {
|
|||
))}
|
||||
{!isLoading && recentError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="error">
|
||||
Failed to load proposals. Please try again.
|
||||
</Typography>
|
||||
|
|
@ -171,7 +193,7 @@ export default function Dashboard() {
|
|||
)}
|
||||
{!isLoading && !recentError && proposals.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
No proposals yet. Submit your first one!
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -22,13 +22,31 @@ import {
|
|||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import { proposalsApi, type ProposalDetail, type PdfVersion } from '../../../lib/api/proposals';
|
||||
import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
|
||||
import { formatBidAmount, formatCurrency, formatDateTime } from '../../../lib/format';
|
||||
import { formatBidAmount, formatCurrency, formatDate, formatDateTime } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
|
||||
|
||||
const STATUS_EXPLANATIONS: Record<string, string> = {
|
||||
Draft: 'This proposal is in draft and has not been submitted.',
|
||||
InReview: 'Awaiting admin pricing and approval.',
|
||||
Approved: 'Approved and ready for PDF generation and delivery.',
|
||||
Sent: 'This proposal has been sent to the customer.',
|
||||
Revised: 'This version has been superseded by a newer revision.',
|
||||
};
|
||||
|
||||
function getStepTimestamp(status: string, proposal: ProposalDetail): string | null {
|
||||
switch (status) {
|
||||
case 'InReview': return proposal.submittedAt;
|
||||
case 'Approved': return proposal.approvedAt;
|
||||
case 'Sent': return proposal.sentAt;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
|
|
@ -154,15 +172,40 @@ export default function ProposalDetailPage() {
|
|||
{proposal.status === 'Revised' ? (
|
||||
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||
<Chip label="Revised — a new revision has been created" color="warning" />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mt: 1.5 }}>
|
||||
<InfoOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{STATUS_EXPLANATIONS[proposal.status]}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||
{STATUS_ORDER.map((label) => (
|
||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||
<StepLabel>{STATUS_LABELS[label] || label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
<>
|
||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel sx={{ py: 1 }}>
|
||||
{STATUS_ORDER.map((label) => {
|
||||
const completed = STATUS_ORDER.indexOf(label) <= activeStep;
|
||||
const timestamp = completed ? getStepTimestamp(label, proposal) : null;
|
||||
return (
|
||||
<Step key={label} completed={completed}>
|
||||
<StepLabel>
|
||||
<Typography variant="caption">{STATUS_LABELS[label] || label}</Typography>
|
||||
{timestamp && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{formatDate(timestamp)}
|
||||
</Typography>
|
||||
)}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mt: 1.5 }}>
|
||||
<InfoOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{STATUS_EXPLANATIONS[proposal.status] ?? ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -136,17 +136,28 @@ export default function ProposalFormPage() {
|
|||
form.serviceCategory &&
|
||||
(form.serviceCategory !== 'Other' || customCategory.trim());
|
||||
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!form.workOrderNumber.trim()) missing.push('Work Order Number');
|
||||
if (!form.customerAddress.trim()) missing.push('Site address');
|
||||
if (!form.serviceCategory) missing.push('Service Category');
|
||||
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
|
||||
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
|
||||
return missing;
|
||||
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3 }}>
|
||||
New Proposal Request
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Work Order Number"
|
||||
required
|
||||
|
|
@ -155,8 +166,7 @@ export default function ProposalFormPage() {
|
|||
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Purchase Order Number"
|
||||
fullWidth
|
||||
|
|
@ -164,8 +174,7 @@ export default function ProposalFormPage() {
|
|||
onChange={(e) => handleChange('poNumber', e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Customer"
|
||||
required
|
||||
|
|
@ -181,146 +190,172 @@ export default function ProposalFormPage() {
|
|||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
{manualEntry ? (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
label="Site Code"
|
||||
fullWidth
|
||||
value={manualSiteCode}
|
||||
onChange={(e) => setManualSiteCode(e.target.value)}
|
||||
placeholder="e.g. LGB8"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8 }}>
|
||||
<TextField
|
||||
label="Street Address"
|
||||
required
|
||||
fullWidth
|
||||
value={manualStreet}
|
||||
onChange={(e) => setManualStreet(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 5 }}>
|
||||
<TextField
|
||||
label="City"
|
||||
required
|
||||
fullWidth
|
||||
value={manualCity}
|
||||
onChange={(e) => setManualCity(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<TextField
|
||||
label="State"
|
||||
required
|
||||
fullWidth
|
||||
value={manualState}
|
||||
onChange={(e) => setManualState(e.target.value)}
|
||||
placeholder="e.g. CA"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 4 }}>
|
||||
<TextField
|
||||
label="Zip Code"
|
||||
required
|
||||
fullWidth
|
||||
value={manualZip}
|
||||
onChange={(e) => setManualZip(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
setManualEntry(false);
|
||||
setManualSiteCode('');
|
||||
setManualStreet('');
|
||||
setManualCity('');
|
||||
setManualState('');
|
||||
setManualZip('');
|
||||
setSiteInputValue('');
|
||||
handleChange('customerAddress', '');
|
||||
}}
|
||||
>
|
||||
Search sites instead
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Autocomplete
|
||||
options={siteOptions}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(opt) =>
|
||||
opt.fullAddress
|
||||
? `${opt.siteCode} — ${opt.fullAddress}`
|
||||
: opt.siteCode
|
||||
}
|
||||
loading={siteLoading}
|
||||
inputValue={siteInputValue}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === 'input') {
|
||||
setSiteInputValue(value);
|
||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
||||
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
|
||||
}
|
||||
}}
|
||||
onChange={(_, value) => {
|
||||
if (value) {
|
||||
const display = value.fullAddress
|
||||
? `${value.siteCode} — ${value.fullAddress}`
|
||||
: value.siteCode;
|
||||
handleChange('customerAddress', display);
|
||||
setSiteInputValue(display);
|
||||
} else {
|
||||
handleChange('customerAddress', '');
|
||||
setSiteInputValue('');
|
||||
}
|
||||
}}
|
||||
noOptionsText={
|
||||
siteLoading
|
||||
? 'Searching...'
|
||||
: siteInputValue.length >= 2
|
||||
? `No verified sites found for "${siteInputValue}"`
|
||||
: 'Type a site code to search (e.g. LGB8)'
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Site"
|
||||
required
|
||||
placeholder="Search by site code..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{siteLoading && <CircularProgress size={20} />}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ mt: 0.5 }}
|
||||
onClick={() => setManualEntry(true)}
|
||||
>
|
||||
Enter address manually
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Priority"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={form.priority}
|
||||
onChange={(e) => handleChange('priority', e.target.value)}
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{PRIORITY_LABELS[p] || p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Site & Location</Typography>
|
||||
{manualEntry ? (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<TextField
|
||||
label="Site Code"
|
||||
fullWidth
|
||||
value={manualSiteCode}
|
||||
onChange={(e) => setManualSiteCode(e.target.value)}
|
||||
placeholder="e.g. LGB8"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8 }}>
|
||||
<TextField
|
||||
label="Street Address"
|
||||
required
|
||||
fullWidth
|
||||
value={manualStreet}
|
||||
onChange={(e) => setManualStreet(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 5 }}>
|
||||
<TextField
|
||||
label="City"
|
||||
required
|
||||
fullWidth
|
||||
value={manualCity}
|
||||
onChange={(e) => setManualCity(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 3 }}>
|
||||
<TextField
|
||||
label="State"
|
||||
required
|
||||
fullWidth
|
||||
value={manualState}
|
||||
onChange={(e) => setManualState(e.target.value)}
|
||||
placeholder="e.g. CA"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, sm: 4 }}>
|
||||
<TextField
|
||||
label="Zip Code"
|
||||
required
|
||||
fullWidth
|
||||
value={manualZip}
|
||||
onChange={(e) => setManualZip(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
setManualEntry(false);
|
||||
setManualSiteCode('');
|
||||
setManualStreet('');
|
||||
setManualCity('');
|
||||
setManualState('');
|
||||
setManualZip('');
|
||||
setSiteInputValue('');
|
||||
handleChange('customerAddress', '');
|
||||
}}
|
||||
>
|
||||
Search sites instead
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Autocomplete
|
||||
options={siteOptions}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(opt) =>
|
||||
opt.fullAddress
|
||||
? `${opt.siteCode} — ${opt.fullAddress}`
|
||||
: opt.siteCode
|
||||
}
|
||||
loading={siteLoading}
|
||||
inputValue={siteInputValue}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === 'input') {
|
||||
setSiteInputValue(value);
|
||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
||||
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
|
||||
}
|
||||
}}
|
||||
onChange={(_, value) => {
|
||||
if (value) {
|
||||
const display = value.fullAddress
|
||||
? `${value.siteCode} — ${value.fullAddress}`
|
||||
: value.siteCode;
|
||||
handleChange('customerAddress', display);
|
||||
setSiteInputValue(display);
|
||||
} else {
|
||||
handleChange('customerAddress', '');
|
||||
setSiteInputValue('');
|
||||
}
|
||||
}}
|
||||
noOptionsText={
|
||||
siteLoading
|
||||
? 'Searching...'
|
||||
: siteInputValue.length >= 2
|
||||
? `No verified sites found for "${siteInputValue}"`
|
||||
: 'Type a site code to search (e.g. LGB8)'
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Site"
|
||||
required
|
||||
placeholder="Search by site code..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{siteLoading && <CircularProgress size={20} />}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ mt: 0.5 }}
|
||||
onClick={() => setManualEntry(true)}
|
||||
>
|
||||
Enter address manually
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
label="Service Category"
|
||||
|
|
@ -353,23 +388,6 @@ export default function ProposalFormPage() {
|
|||
<Grid size={{ sm: 6 }} sx={{ display: { xs: 'none', sm: 'block' } }} />
|
||||
)}
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
label="Priority"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={form.priority}
|
||||
onChange={(e) => handleChange('priority', e.target.value)}
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{PRIORITY_LABELS[p] || p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
label="Scope of Work"
|
||||
|
|
@ -405,26 +423,29 @@ export default function ProposalFormPage() {
|
|||
/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button variant="text" onClick={() => navigate(-1)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
disabled={!isValid || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{!isValid && missingFields.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Complete required fields to submit: {missingFields.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
<Button variant="text" onClick={() => navigate(-1)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
disabled={!isValid || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,15 +19,19 @@ import {
|
|||
InputAdornment,
|
||||
Button,
|
||||
Skeleton,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} 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 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 { STATUS_COLORS, STATUS_LABELS, PROPOSAL_STATUSES, SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants';
|
||||
import { formatBidAmount, formatDate } from '../../../lib/format';
|
||||
import { formatBidAmount, formatDate, groupRevisions } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
type SortKey = keyof ProposalListItem;
|
||||
|
|
@ -44,6 +48,8 @@ const columns: { key: SortKey; label: string; align?: 'right' }[] = [
|
|||
{ key: 'submittedAt', label: 'Submitted' },
|
||||
];
|
||||
|
||||
const COL_COUNT = 9;
|
||||
|
||||
export default function ProposalListPage() {
|
||||
useDocumentTitle('My Proposals');
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -52,12 +58,15 @@ export default function ProposalListPage() {
|
|||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterPriority, setFilterPriority] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const extraParams = useMemo(() => ({
|
||||
mine: true,
|
||||
...(filterStatus && { status: filterStatus }),
|
||||
...(filterCategory && { serviceCategory: filterCategory }),
|
||||
...(filterPriority && { priority: filterPriority }),
|
||||
}), [filterStatus, filterCategory, filterPriority]);
|
||||
|
||||
const {
|
||||
rows,
|
||||
search,
|
||||
|
|
@ -81,6 +90,17 @@ export default function ProposalListPage() {
|
|||
});
|
||||
}, [rows, sortKey, sortDir]);
|
||||
|
||||
const grouped = useMemo(() => groupRevisions(sortedRows), [sortedRows]);
|
||||
|
||||
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 handleSort = (key: SortKey) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
|
|
@ -90,6 +110,55 @@ export default function ProposalListPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterStatus || filterCategory || filterPriority || search;
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterStatus('');
|
||||
setFilterCategory('');
|
||||
setFilterPriority('');
|
||||
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(`/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"
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
|
|
@ -161,11 +230,8 @@ export default function ProposalListPage() {
|
|||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{(filterStatus || filterCategory || filterPriority || search) && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => { setFilterStatus(''); setFilterCategory(''); setFilterPriority(''); setSearch(''); }}
|
||||
>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -181,6 +247,7 @@ export default function ProposalListPage() {
|
|||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 40, px: 1 }} />
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align} sortDirection={sortKey === col.key ? sortDir : false}>
|
||||
<TableSortLabel
|
||||
|
|
@ -198,54 +265,47 @@ export default function ProposalListPage() {
|
|||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
{Array.from({ length: COL_COUNT }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton variant="text" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: sortedRows.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>
|
||||
))}
|
||||
: grouped.map((group) => {
|
||||
const isExpanded = expanded.has(group.baseNumber);
|
||||
return (
|
||||
<Box component="tbody" 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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{(filterStatus || filterCategory || filterPriority || search)
|
||||
{hasActiveFilters
|
||||
? 'No proposals match your current filters.'
|
||||
: 'You have not submitted any proposals yet.'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{(filterStatus || filterCategory || filterPriority || search) && (
|
||||
<Button size="small" onClick={() => { setFilterStatus(''); setFilterCategory(''); setFilterPriority(''); setSearch(''); }}>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue