diff --git a/HANDOFF-2026-05-22.md b/HANDOFF-2026-05-22.md
index 4902f38..78543a2 100644
--- a/HANDOFF-2026-05-22.md
+++ b/HANDOFF-2026-05-22.md
@@ -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)
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
index 5b7f15c..e0a45e5 100644
--- a/web/src/components/Sidebar.tsx
+++ b/web/src/components/Sidebar.tsx
@@ -73,33 +73,6 @@ export default function Sidebar() {
>
-
- Proposals
-
-
- {navItems.map((item) => (
- navigate(item.path)}
- sx={{ py: 1 }}
- >
- {item.icon}
-
-
- ))}
-
-
{isAdmin && (
<>
@@ -142,6 +114,33 @@ export default function Sidebar() {
>
)}
+
+ Proposals
+
+
+ {navItems.map((item) => (
+ navigate(item.path)}
+ sx={{ py: 1 }}
+ >
+ {item.icon}
+
+
+ ))}
+
{user && (
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
index d11a9cf..8a141a5 100644
--- a/web/src/lib/format.ts
+++ b/web/src/lib/format.ts
@@ -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 {
+ latest: T;
+ revisions: T[];
+ hasRevisions: boolean;
+ baseNumber: string;
+}
+
+export function groupRevisions(rows: T[]): GroupedProposal[] {
+ const groups = new Map();
+ 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[] = [];
+ 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;
+}
diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx
index 87efdb8..a5f1cf9 100644
--- a/web/src/pages/admin/dashboard/AdminDashboard.tsx
+++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx
@@ -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 (
@@ -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({
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>(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) => (
+ navigate(`/admin/proposals/${row.id}`)}
+ >
+
+ {showExpandIcon && (
+ {
+ e.stopPropagation();
+ toggleExpand(baseNumber);
+ }}
+ >
+ {isExpanded ? : }
+
+ )}
+
+ {row.proposalNumber}
+ {row.customerName}
+ {row.workOrderNumber}
+ {row.serviceCategory}
+
+ : row.priority === 'Urgent' ? : undefined}
+ />
+
+
+
+
+ {formatBidAmount(row.totalBidAmount)}
+ {formatDate(row.submittedAt)}
+ {row.submittedByName || '-'}
+
+ {formatAge(row.submittedAt)}
+
+
+ );
+
return (
Admin Dashboard
@@ -139,6 +225,19 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
+ {showTabs && (
+ = 0 ? tabIndex : 0}
+ onChange={handleTabChange}
+ sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
+ >
+
+ {TAB_STATUSES.slice(1).map((s) => (
+
+ ))}
+
+ )}
+
{PRIORITY_LABELS[p] || p}
))}
- setStatusFilter(e.target.value)}
- >
-
- {PROPOSAL_STATUSES.map((s) => (
-
- ))}
-
- {(statusFilter || categoryFilter || priorityFilter || search) && (
-