diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 8a141a5..0d75c6b 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -7,6 +7,16 @@ export function formatBidAmount(amount: number): string { } export function formatDate(iso: string): string { + const d = new Date(iso); + const isCurrentYear = d.getFullYear() === new Date().getFullYear(); + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + ...(isCurrentYear ? {} : { year: 'numeric' }), + }); +} + +export function formatDateFull(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index d17f4ee..c1157b1 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, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -18,32 +18,50 @@ import { TextField, InputAdornment, MenuItem, + Button, Skeleton, + Tabs, + Tab, + Collapse, + IconButton, + Tooltip, } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import PendingActionsIcon from '@mui/icons-material/PendingActions'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import TimerIcon from '@mui/icons-material/Timer'; import DescriptionIcon from '@mui/icons-material/Description'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import ReportIcon from '@mui/icons-material/Report'; +import 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_CHIP_STYLES, PRIORITY_CHIP_STYLES } from '../../../constants'; -import { formatCurrency, formatDate } from '../../../lib/format'; +import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; +import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; -function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) { +const COL_COUNT = 11; +const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; + +function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNode; label: string; value: string; color: string; subtitle?: string }) { return ( {icon} - + {label} - + {value} + {subtitle && ( + + {subtitle} + + )} ); @@ -52,6 +70,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], @@ -61,6 +80,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, @@ -79,9 +99,88 @@ 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 + {defaultStatus ? 'Admin Queue' : 'All Proposals'} @@ -93,6 +192,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Pending Review" value={String(stats?.pendingCount ?? 0)} color="#1565c0" + subtitle="Awaiting action" /> )} @@ -105,6 +205,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Approved This Week" value={String(stats?.approvedThisWeek ?? 0)} color="#2e7d32" + subtitle={`${stats?.approvedThisWeek ?? 0} approved`} /> )} @@ -117,6 +218,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Avg Turnaround" value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'} color="#f57c00" + subtitle="Target: <24h" /> )} @@ -129,6 +231,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Total Proposals" value={String(stats?.totalProposals ?? 0)} color="#5e35b1" + subtitle="All time" /> )} @@ -136,6 +239,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) => ( + + ))} + + )} + setPriorityFilter(e.target.value)} > All {PRIORITIES.map((p) => ( - {p} - ))} - - setStatusFilter(e.target.value)} - > - All - {PROPOSAL_STATUSES.map((s) => ( - {s} + {PRIORITY_LABELS[p] || p} ))} + {!showTabs && ( + setStatusFilter(e.target.value)} + > + All + {PROPOSAL_STATUSES.map((s) => ( + {STATUS_LABELS[s] || s} + ))} + + )} + {hasActiveFilters && ( + + )} {err && ( @@ -202,6 +325,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri + Proposal # Customer WO # @@ -211,43 +335,54 @@ 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} - - - - - - - {formatCurrency(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 && ( - - No proposals in queue + + + {hasActiveFilters + ? 'No proposals match your current filters.' + : 'No proposals in queue.'} + + {hasActiveFilters && ( + + )} )} diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 29a67ef..52311ae 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -21,23 +21,29 @@ 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 Tooltip from '@mui/material/Tooltip'; import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals'; import { QUERY_KEYS } from '../../constants/queryKeys'; import { STATUS_CHIP_STYLES } from '../../constants'; -import { formatCurrency, formatDate } from '../../lib/format'; +import { formatCurrency, formatDate, formatDateFull } from '../../lib/format'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; -function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { +function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { return ( - {icon} - + {icon} + {label} - + {value} + {subtitle && ( + + {subtitle} + + )} ); @@ -84,28 +90,28 @@ export default function Dashboard() { {isLoading ? ( ) : ( - } label="Total Submitted" value={String(total)} /> + } label="Total Submitted" value={String(total)} subtitle="All time" /> )} {isLoading ? ( ) : ( - } label="In Review" value={String(counts.inReview)} /> + } label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" /> )} {isLoading ? ( ) : ( - } label="Approved" value={String(counts.approved)} /> + } label="Approved" value={String(counts.approved)} subtitle="Ready to send" /> )} {isLoading ? ( ) : ( - } label="Sent" value={String(counts.sent)} /> + } label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" /> )} @@ -154,7 +160,11 @@ export default function Dashboard() { {formatCurrency(row.totalBidAmount)} - {formatDate(row.submittedAt)} + + + {formatDate(row.submittedAt)} + + ))} {!isLoading && recentError && (