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)} - > - All - {PROPOSAL_STATUSES.map((s) => ( - {STATUS_LABELS[s] || s} - ))} - - {(statusFilter || categoryFilter || priorityFilter || search) && ( - )} @@ -213,6 +311,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri + Proposal # Customer WO # @@ -222,54 +321,51 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri Bid Amount Submitted Submitted By + Age {loading ? Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 9 }).map((_, j) => ( + {Array.from({ length: COL_COUNT }).map((_, j) => ( ))} )) - : rows.map((row) => ( - navigate(`/admin/proposals/${row.id}`)} - > - {row.proposalNumber} - {row.customerName} - {row.workOrderNumber} - {row.serviceCategory} - - : row.priority === 'Urgent' ? : undefined} - /> - - - - - {formatBidAmount(row.totalBidAmount)} - {formatDate(row.submittedAt)} - {row.submittedByName || '-'} - - ))} + : grouped.map((group) => { + const isExpanded = expanded.has(group.baseNumber); + return ( + + {renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)} + {group.hasRevisions && ( + + + +
+ + {group.revisions.map((rev) => + renderRow(rev, true, false, false, group.baseNumber) + )} + +
+ + + + )} +
+ ); + })} {!loading && rows.length === 0 && ( - + - {(statusFilter || categoryFilter || priorityFilter || search) + {hasActiveFilters ? 'No proposals match your current filters.' : 'No proposals in queue.'} - {(statusFilter || categoryFilter || priorityFilter || search) && ( - )} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 851a04a..84033d3 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -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 = { Submit: 'Submitted', @@ -88,6 +91,7 @@ export default function AdminWorkspace() { const [dirty, setDirty] = useState(false); const [revMenuAnchor, setRevMenuAnchor] = useState(null); const [downloadingRevId, setDownloadingRevId] = useState(null); + const [leftCollapsed, setLeftCollapsed] = useState(false); const { data: proposal, isLoading } = useQuery({ queryKey: [QUERY_KEYS.proposals, id], @@ -420,66 +424,86 @@ export default function AdminWorkspace() { {/* Three-Panel Layout */} - {/* Left Panel: Reference */} - - - - Submission Details + {/* Left Panel: Reference (collapsible) */} + {!leftCollapsed && ( + + + + + + Submission Details + setLeftCollapsed(true)}> + + + - Submitted By - {proposal.submittedByName || '-'} + Submitted By + {proposal.submittedByName || '-'} - Submitted - - {new Date(proposal.submittedAt).toLocaleString()} - - - - - Original Scope - - {proposal.scopeOfWork} - - - {proposal.notes && ( - <> - Notes - - {proposal.notes} + Submitted + + {new Date(proposal.submittedAt).toLocaleString()} - - )} - - - - - + + + Original Scope + + {proposal.scopeOfWork} + + + {proposal.notes && ( + <> + Notes + + {proposal.notes} + + + )} + + + + + + + + )} {/* Center Panel: Editor */} - - - - Refined Scope - { setRefinedScope(e.target.value); setDirty(true); }} - disabled={!isEditable} + + + {leftCollapsed && ( + + onClick={() => setLeftCollapsed(false)} + sx={{ alignSelf: 'flex-start', mt: 1 }} + > + + + )} + + + Refined Scope + { setRefinedScope(e.target.value); setDirty(true); }} + disabled={!isEditable} + size="small" + sx={{ mb: 2 }} + /> - - - + + + +
{/* Right Panel: Similar Proposals */} @@ -503,12 +527,27 @@ export default function AdminWorkspace() { {/* Action Bar */} - - + + + + + {bidTotal > 0 ? `Total: ${formatCurrency(bidTotal)}` : 'Total: Not priced'} + + + {lineItems.length} {lineItems.length === 1 ? 'item' : 'items'} + + {proposal.vendorTotalCost != null && ( + + Vendor: {formatCurrency(proposal.vendorTotalCost)} + + )} + + + + {dirty && ( - Unsaved changes + )} - {!dirty && } {isEditable && ( <> diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 7084af9..0ed3d8e 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -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 ( - - + + {icon} {label} - + {value} @@ -69,6 +80,20 @@ export default function Dashboard() { sent: stats?.sentCount ?? 0, }; + const kpiCards = isAdmin + ? [ + { icon: , label: 'All Proposals', value: String(total), onClick: () => navigate('/admin/proposals') }, + { icon: , label: 'Pending Review', value: String(counts.inReview), onClick: () => navigate('/admin') }, + { icon: , label: 'Approved', value: String(counts.approved), onClick: () => navigate('/admin/proposals?status=Approved') }, + { icon: , label: 'Sent', value: String(counts.sent), onClick: () => navigate('/admin/proposals?status=Sent') }, + ] + : [ + { icon: , label: 'Total Submitted', value: String(total), onClick: () => navigate('/proposals') }, + { icon: , label: 'In Review', value: String(counts.inReview), onClick: () => navigate('/proposals?status=InReview') }, + { icon: , label: 'Approved', value: String(counts.approved), onClick: () => navigate('/proposals?status=Approved') }, + { icon: , label: 'Sent', value: String(counts.sent), onClick: () => navigate('/proposals?status=Sent') }, + ]; + return ( @@ -83,42 +108,28 @@ export default function Dashboard() { - - {isLoading ? ( - - ) : ( - } label="Total Submitted" value={String(total)} /> - )} - - - {isLoading ? ( - - ) : ( - } label="In Review" value={String(counts.inReview)} /> - )} - - - {isLoading ? ( - - ) : ( - } label="Approved" value={String(counts.approved)} /> - )} - - - {isLoading ? ( - - ) : ( - } label="Sent" value={String(counts.sent)} /> - )} - + {kpiCards.map((card) => ( + + {isLoading ? ( + + ) : ( + + )} + + ))} Recent Proposals - @@ -128,7 +139,9 @@ export default function Dashboard() { Proposal # Customer + WO # Category + Priority Status Bid Amount Submitted @@ -138,7 +151,7 @@ export default function Dashboard() { {isLoading ? Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 6 }).map((_, j) => ( + {Array.from({ length: 8 }).map((_, j) => ( ))} @@ -152,7 +165,16 @@ export default function Dashboard() { > {row.proposalNumber} {row.customerName} + {row.workOrderNumber} {row.serviceCategory} + + : row.priority === 'Urgent' ? : undefined} + /> + @@ -162,7 +184,7 @@ export default function Dashboard() { ))} {!isLoading && recentError && ( - + Failed to load proposals. Please try again. @@ -171,7 +193,7 @@ export default function Dashboard() { )} {!isLoading && !recentError && proposals.length === 0 && ( - + No proposals yet. Submit your first one! diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx index 8707cd4..2ae2341 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -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 = { + 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 ( @@ -154,15 +172,40 @@ export default function ProposalDetailPage() { {proposal.status === 'Revised' ? ( + + + + {STATUS_EXPLANATIONS[proposal.status]} + + ) : ( - = 0 ? activeStep : 0} alternativeLabel> - {STATUS_ORDER.map((label) => ( - - {STATUS_LABELS[label] || label} - - ))} - + <> + = 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 ( + + + {STATUS_LABELS[label] || label} + {timestamp && ( + + {formatDate(timestamp)} + + )} + + + ); + })} + + + + + {STATUS_EXPLANATIONS[proposal.status] ?? ''} + + + )} diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 89ed01a..1cdb54f 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -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 ( New Proposal Request - - - + + + + Job Details - + handleChange('workOrderNumber', e.target.value)} /> - - + handleChange('poNumber', e.target.value)} /> - - + - - - {manualEntry ? ( - - - - setManualSiteCode(e.target.value)} - placeholder="e.g. LGB8" - /> - - - setManualStreet(e.target.value)} - /> - - - setManualCity(e.target.value)} - /> - - - setManualState(e.target.value)} - placeholder="e.g. CA" - /> - - - setManualZip(e.target.value)} - /> - - - - - ) : ( - - 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) => ( - - {siteLoading && } - {params.InputProps.endAdornment} - - ), - }, - }} - /> - )} - /> - - - )} + + handleChange('priority', e.target.value)} + > + {PRIORITIES.map((p) => ( + + {PRIORITY_LABELS[p] || p} + + ))} + + + + + + + Site & Location + {manualEntry ? ( + + + + setManualSiteCode(e.target.value)} + placeholder="e.g. LGB8" + /> + + + setManualStreet(e.target.value)} + /> + + + setManualCity(e.target.value)} + /> + + + setManualState(e.target.value)} + placeholder="e.g. CA" + /> + + + setManualZip(e.target.value)} + /> + + + + + ) : ( + + 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) => ( + + {siteLoading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + + + )} + + + + + + Work Details + )} - - handleChange('priority', e.target.value)} - > - {PRIORITIES.map((p) => ( - - {PRIORITY_LABELS[p] || p} - - ))} - - - - - - - - - - - - - + + + + + {!isValid && missingFields.length > 0 && ( + + Complete required fields to submit: {missingFields.join(', ')} + + )} + + + + ); } diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index 188dc64..c7e582a 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -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>(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) => ( + navigate(`/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)} + + ); + return ( @@ -161,11 +230,8 @@ export default function ProposalListPage() { {PRIORITY_LABELS[p] || p} ))} - {(filterStatus || filterCategory || filterPriority || search) && ( - )} @@ -181,6 +247,7 @@ export default function ProposalListPage() { + {columns.map((col) => ( ( - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: COL_COUNT }).map((_, j) => ( ))} )) - : sortedRows.map((row) => ( - navigate(`/proposals/${row.id}`)} - > - {row.proposalNumber} - {row.customerName} - {row.workOrderNumber} - {row.serviceCategory} - - : row.priority === 'Urgent' ? : undefined} - /> - - - - - {formatBidAmount(row.totalBidAmount)} - {formatDate(row.submittedAt)} - - ))} + : grouped.map((group) => { + const isExpanded = expanded.has(group.baseNumber); + return ( + + {renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)} + {group.hasRevisions && ( + + + +
+ + {group.revisions.map((rev) => + renderRow(rev, true, false, false, group.baseNumber) + )} + +
+ + + + )} +
+ ); + })} {!loading && rows.length === 0 && ( - + - {(filterStatus || filterCategory || filterPriority || search) + {hasActiveFilters ? 'No proposals match your current filters.' : 'You have not submitted any proposals yet.'} - {(filterStatus || filterCategory || filterPriority || search) && ( - )}