diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index c3e3746..2fbabd9 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -17,47 +17,21 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const; -export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const; +export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const; export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const; -export const STATUS_LABELS: Record = { - Draft: 'Draft', - InReview: 'In Review', - Approved: 'Approved', - Sent: 'Sent', - Revised: 'Revised', -}; - -export const STATUS_COLORS: Record = { - Draft: 'default', - InReview: 'info', - Approved: 'success', - Sent: 'primary', - Revised: 'warning', +export const STATUS_CHIP_STYLES: Record = { + Draft: { bgcolor: '#E5E7EB', color: '#374151' }, + InReview: { bgcolor: '#0284C7', color: '#FFFFFF' }, + Approved: { bgcolor: '#2E7D32', color: '#FFFFFF' }, + Sent: { bgcolor: '#075985', color: '#FFFFFF' }, + Revised: { bgcolor: '#7C3AED', color: '#FFFFFF' }, }; export const DRAWER_WIDTH = 220; -export const PRIORITY_LABELS: Record = { - Standard: 'Standard', - Urgent: 'Urgent', - Emergency: 'Emergency Dispatch', -}; - -export const PRIORITY_COLORS: Record = { - Standard: 'default', - Urgent: 'warning', - Emergency: 'error', -}; - -export const PRIORITY_ROW_SX: Record = { - Urgent: { - borderLeft: '4px solid', - borderLeftColor: 'warning.main', - }, - Emergency: { - borderLeft: '4px solid', - borderLeftColor: 'error.main', - backgroundColor: 'error.50', - }, +export const PRIORITY_CHIP_STYLES: Record = { + Standard: { bgcolor: '#E5E7EB', color: '#374151' }, + Urgent: { bgcolor: '#EA580C', color: '#FFFFFF' }, + Emergency: { bgcolor: '#DC2626', color: '#FFFFFF' }, }; diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index f021fd0..d17f4ee 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, Fragment } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -18,33 +18,21 @@ import { TextField, InputAdornment, MenuItem, - Button, Skeleton, - Tabs, - Tab, - Collapse, - IconButton, } 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_COLORS, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../../constants'; -import { formatBidAmount, formatDate, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; +import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, PRIORITY_CHIP_STYLES } from '../../../constants'; +import { formatCurrency, formatDate } 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 ( @@ -64,7 +52,6 @@ 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], @@ -74,7 +61,6 @@ 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, @@ -93,81 +79,6 @@ 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 @@ -225,19 +136,6 @@ 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) => ( - {PRIORITY_LABELS[p] || p} + {p} + ))} + + setStatusFilter(e.target.value)} + > + All + {PROPOSAL_STATUSES.map((s) => ( + {s} ))} - {!showTabs && ( - setStatusFilter(e.target.value)} - > - All - {PROPOSAL_STATUSES.map((s) => ( - {STATUS_LABELS[s] || s} - ))} - - )} - {hasActiveFilters && ( - - )} {err && ( @@ -311,7 +202,6 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri - Proposal # Customer WO # @@ -321,54 +211,43 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri Bid Amount Submitted Submitted By - Age {loading ? Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: COL_COUNT }).map((_, j) => ( + {Array.from({ length: 9 }).map((_, j) => ( ))} )) - : 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) - )} - -
- - - - )} - - ); - })} + : rows.map((row) => ( + navigate(`/admin/proposals/${row.id}`)} + > + {row.proposalNumber} + {row.customerName} + {row.workOrderNumber} + {row.serviceCategory} + + + + + + + {formatCurrency(row.totalBidAmount)} + {formatDate(row.submittedAt)} + {row.submittedByName || '-'} + + ))} {!loading && rows.length === 0 && ( - - - {hasActiveFilters - ? 'No proposals match your current filters.' - : 'No proposals in queue.'} - - {hasActiveFilters && ( - - )} + + No proposals in queue )} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 84033d3..5eff598 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, useBlocker } from 'react-router-dom'; import { useQuery, useMutation } from '@tanstack/react-query'; import { Box, @@ -9,7 +9,6 @@ import { Grid, Button, Chip, - Collapse, Divider, TextField, Dialog, @@ -17,9 +16,7 @@ import { DialogContent, DialogActions, CircularProgress, - Menu, - MenuItem, - IconButton, + Alert, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SaveIcon from '@mui/icons-material/Save'; @@ -28,53 +25,16 @@ import SendIcon from '@mui/icons-material/Send'; import RefreshIcon from '@mui/icons-material/Refresh'; import HistoryIcon from '@mui/icons-material/History'; 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'; import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; -import { adminApi, type AuditEntry } from '../../../lib/api/admin'; +import { adminApi } from '../../../lib/api/admin'; import { queryClient } from '../../../lib/queryClient'; import { QUERY_KEYS } from '../../../constants/queryKeys'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; 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', - Edit: 'Edited', - Approve: 'Approved', - ReturnToReview: 'Returned to Review', - MarkSent: 'Marked Sent', - CreateRevision: 'Revision Created', - EditLineItem: 'Line Items Updated', - EditScope: 'Scope Edited', - Regenerate: 'Regenerated', - GeneratePDF: 'PDF Generated', - Download: 'Downloaded', - AssignAdmin: 'Admin Assigned', - UpdateRole: 'Role Updated', - Revise: 'Revised', -}; - -function formatAction(action: string): string { - return ACTION_LABELS[action] || action.replace(/([A-Z])/g, ' $1').trim(); -} - -function formatDetails(details: string): string { - try { - const parsed = JSON.parse(details); - return parsed.message || details; - } catch { - return details; - } -} +import { STATUS_CHIP_STYLES } from '../../../constants'; export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); @@ -82,16 +42,10 @@ export default function AdminWorkspace() { const [lineItems, setLineItems] = useState([]); const [refinedScope, setRefinedScope] = useState(''); - const [workOrderNumber, setWorkOrderNumber] = useState(''); - const [poNumber, setPoNumber] = useState(''); const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [reviseDialogOpen, setReviseDialogOpen] = useState(false); - const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = useState(false); 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], @@ -105,18 +59,6 @@ export default function AdminWorkspace() { enabled: !!id, }); - const { data: auditTrail } = useQuery({ - queryKey: ['audit', id], - queryFn: () => adminApi.getAudit(id!), - enabled: !!id, - }); - - const { data: revisionHistory } = useQuery({ - queryKey: [QUERY_KEYS.proposals, id, 'history'], - queryFn: () => adminApi.getHistory(id!), - enabled: !!id, - }); - useEffect(() => { if (fetchedLineItems) { setLineItems( @@ -138,14 +80,9 @@ export default function AdminWorkspace() { useEffect(() => { if (proposal) { setRefinedScope(proposal.refinedScope || proposal.scopeOfWork); - setWorkOrderNumber(proposal.workOrderNumber || ''); - setPoNumber(proposal.poNumber || ''); } }, [proposal]); - const dirtyRef = useRef(dirty); - dirtyRef.current = dirty; - useEffect(() => { if (!dirty) return; const handler = (e: BeforeUnloadEvent) => { @@ -155,9 +92,17 @@ export default function AdminWorkspace() { return () => window.removeEventListener('beforeunload', handler); }, [dirty]); + const blocker = useBlocker( + useCallback( + ({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) => + dirty && currentLocation.pathname !== nextLocation.pathname, + [dirty], + ), + ); + const saveMutation = useMutation({ mutationFn: async () => { - await adminApi.updateProposal(id!, { refinedScope, workOrderNumber, poNumber }); + await adminApi.updateProposal(id!, { refinedScope }); await lineItemsApi.bulkUpdate( id!, lineItems.map((li) => ({ @@ -176,7 +121,6 @@ export default function AdminWorkspace() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); setDirty(false); toast.success('Changes saved'); }, @@ -192,7 +136,6 @@ export default function AdminWorkspace() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); setApproveDialogOpen(false); setDirty(false); toast.success('Proposal approved'); @@ -203,7 +146,6 @@ export default function AdminWorkspace() { mutationFn: () => adminApi.sendProposal(id!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); setSendDialogOpen(false); toast.success('Proposal marked as sent'); }, @@ -213,22 +155,11 @@ export default function AdminWorkspace() { mutationFn: () => adminApi.reviseProposal(id!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); setReviseDialogOpen(false); toast.success('Revision created'); }, }); - const returnToReviewMutation = useMutation({ - mutationFn: () => adminApi.returnToReview(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); - setReturnToReviewDialogOpen(false); - toast.success('Proposal returned to review'); - }, - }); - const regenerateMutation = useMutation({ mutationFn: () => adminApi.generateSuggestions(id!), onSuccess: () => { @@ -237,12 +168,11 @@ export default function AdminWorkspace() { }); const pdfMutation = useMutation({ - mutationFn: (regenerate?: boolean) => adminApi.getPdf(id!, regenerate ?? false), + mutationFn: () => adminApi.getPdf(id!), onSuccess: (data) => { if (data?.downloadUrl) { window.open(data.downloadUrl, '_blank'); - queryClient.invalidateQueries({ queryKey: ['audit', id] }); - toast.success('PDF ready'); + toast.success('PDF ready — downloading'); } else { toast.info('PDF generation queued — try again in a few seconds'); } @@ -257,29 +187,6 @@ export default function AdminWorkspace() { setDirty(true); }; - const DOWNLOADABLE_STATUSES = ['Approved', 'Sent', 'Revised']; - - const handleDownloadPdf = async (revId: string, proposalNumber: string, revision: number) => { - setDownloadingRevId(revId); - try { - const result = await proposalsApi.getPdf(revId); - if (result) { - const a = document.createElement('a'); - a.href = result.downloadUrl; - a.download = `${proposalNumber}-rev${revision}.pdf`; - a.click(); - URL.revokeObjectURL(result.downloadUrl); - toast.success('PDF downloaded'); - } else { - toast.info('PDF not yet generated for this revision'); - } - } catch { - toast.error('Failed to download PDF'); - } finally { - setDownloadingRevId(null); - } - }; - if (isLoading) { return ( @@ -308,202 +215,104 @@ export default function AdminWorkspace() { {/* Header */} - {proposal.proposalNumber} - setRevMenuAnchor(e.currentTarget)} - onDelete={(e) => setRevMenuAnchor(e.currentTarget as HTMLElement)} - deleteIcon={} - sx={{ cursor: 'pointer' }} - /> - setRevMenuAnchor(null)} - > - {(revisionHistory ?? []) - .sort((a, b) => a.currentRevision - b.currentRevision) - .map((rev) => ( - { - setRevMenuAnchor(null); - if (rev.id !== id) { - if (dirty && !window.confirm('You have unsaved changes. Are you sure you want to switch revisions?')) return; - navigate(`/admin/proposals/${rev.id}`); - } - }} - sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, minWidth: 160 }} - > - - Rev {rev.currentRevision} - {STATUS_LABELS[rev.status] || rev.status} - - {DOWNLOADABLE_STATUSES.includes(rev.status) && ( - { - e.stopPropagation(); - setRevMenuAnchor(null); - handleDownloadPdf(rev.id, rev.proposalNumber, rev.currentRevision); - }} - disabled={downloadingRevId === rev.id} - > - - - )} - - ))} - - + + {/* Proposal Info Bar */} - + - Customer - {proposal.customerName} + Customer + {proposal.customerName} - Site - {proposal.customerAddress} + WO # + {proposal.workOrderNumber} - WO # - {isEditable ? ( - { setWorkOrderNumber(e.target.value); setDirty(true); }} - sx={{ width: 140, display: 'block', '& input': { fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600, p: 0 }, '& .MuiInput-root': { mt: 0 } }} - /> - ) : ( - {workOrderNumber || '—'} - )} + Category + {proposal.serviceCategory} - PO # - {isEditable ? ( - { setPoNumber(e.target.value); setDirty(true); }} - sx={{ width: 120, display: 'block', '& input': { fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600, p: 0 }, '& .MuiInput-root': { mt: 0 } }} - /> - ) : ( - {poNumber || '—'} - )} + Priority + - Category - {proposal.serviceCategory} - - - Priority - {PRIORITY_LABELS[proposal.priority] || proposal.priority} + Address + {proposal.customerAddress} {/* Three-Panel Layout */} - {/* Left Panel: Reference (collapsible) */} - {!leftCollapsed && ( - - - - - - Submission Details - setLeftCollapsed(true)}> - - - + {/* Left Panel: Reference */} + + + + Submission Details - Submitted By - {proposal.submittedByName || '-'} + Submitted By + {proposal.submittedByName || '-'} - Submitted - - {new Date(proposal.submittedAt).toLocaleString()} + Submitted + + {new Date(proposal.submittedAt).toLocaleString()} + + + + + Original Scope + + {proposal.scopeOfWork} + + + {proposal.notes && ( + <> + Notes + + {proposal.notes} + + )} - - - Original Scope - - {proposal.scopeOfWork} - - - {proposal.notes && ( - <> - Notes - - {proposal.notes} - - - )} - - - - - - - - )} + + +
+
+ {/* Center Panel: Editor */} - - - {leftCollapsed && ( - + + + Refined Scope + { setRefinedScope(e.target.value); setDirty(true); }} + disabled={!isEditable} size="small" - onClick={() => setLeftCollapsed(false)} - sx={{ alignSelf: 'flex-start', mt: 1 }} - > - - - )} - - - Refined Scope - { setRefinedScope(e.target.value); setDirty(true); }} - disabled={!isEditable} - size="small" - sx={{ mb: 2 }} - /> + sx={{ mb: 2 }} + /> - - - - + + +
{/* Right Panel: Similar Proposals */} @@ -527,27 +336,12 @@ 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 && ( <> @@ -557,51 +351,34 @@ export default function AdminWorkspace() { onClick={() => regenerateMutation.mutate()} disabled={regenerateMutation.isPending} > - Regenerate Suggested Line Items + Regenerate + + + - - - - - - - - - - )} {isApproved && ( <> - - - - )} - - {proposal.status === 'Revised' && ( )} + {/* Unsaved Changes Navigation Guard */} + blocker.reset?.()}> + Unsaved Changes + + You have unsaved changes. Are you sure you want to leave? + + + + + + + {/* Approve Confirmation Dialog */} setApproveDialogOpen(false)}> Approve Proposal @@ -726,71 +496,6 @@ export default function AdminWorkspace() { - - {/* Return to Review Confirmation Dialog */} - setReturnToReviewDialogOpen(false)}> - Return to Review - - - Return {proposal.proposalNumber} to review? - This will clear the approval and allow further editing. - - - - - - - - - {/* Audit Trail */} - {auditTrail && auditTrail.length > 0 && ( - - - Activity Log - - - - {auditTrail.map((entry) => ( - - - - {new Date(entry.timestamp).toLocaleString()} - - - - - - - {entry.userName} - - - - {entry.details ? formatDetails(entry.details) : ''} - - - - ))} - - - - - - )} ); } diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 0ed3d8e..29a67ef 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -21,33 +21,21 @@ 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_LABELS, PRIORITY_COLORS, PRIORITY_ROW_SX } from '../../constants'; -import { formatBidAmount, formatDate } from '../../lib/format'; +import { STATUS_CHIP_STYLES } from '../../constants'; +import { formatCurrency, formatDate } from '../../lib/format'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; -import { useAuth } from '../../hooks/useAuth'; -function KpiCard({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: string; onClick: () => void }) { +function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( - - + + {icon} {label} - + {value} @@ -58,12 +46,10 @@ function KpiCard({ icon, label, value, onClick }: { icon: React.ReactNode; label export default function Dashboard() { useDocumentTitle('Dashboard'); const navigate = useNavigate(); - const { user } = useAuth(); - const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({ - queryKey: [QUERY_KEYS.proposals, 'dashboard-recent', isAdmin], - queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: !isAdmin }), + queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'], + queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }), }); const { data: stats, isLoading: statsLoading } = useQuery({ @@ -80,20 +66,6 @@ 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 ( @@ -108,28 +80,42 @@ export default function Dashboard() { - {kpiCards.map((card) => ( - - {isLoading ? ( - - ) : ( - - )} - - ))} + + {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)} /> + )} + Recent Proposals - @@ -139,9 +125,7 @@ export default function Dashboard() { Proposal # Customer - WO # Category - Priority Status Bid Amount Submitted @@ -151,7 +135,7 @@ export default function Dashboard() { {isLoading ? Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: 6 }).map((_, j) => ( ))} @@ -160,31 +144,22 @@ export default function Dashboard() { navigate(`/proposals/${row.id}`)} > {row.proposalNumber} {row.customerName} - {row.workOrderNumber} {row.serviceCategory} - : row.priority === 'Urgent' ? : undefined} - /> + - - - - {formatBidAmount(row.totalBidAmount)} + {formatCurrency(row.totalBidAmount)} {formatDate(row.submittedAt)} ))} {!isLoading && recentError && ( - + Failed to load proposals. Please try again. @@ -193,7 +168,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 2ae2341..41a923a 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -10,43 +9,19 @@ import { Chip, Divider, Button, - IconButton, Skeleton, Stepper, Step, StepLabel, - List, - ListItem, - ListItemText, - Tooltip, } 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, formatDate, formatDateTime } from '../../../lib/format'; +import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; +import { STATUS_CHIP_STYLES } from '../../../constants'; +import { formatCurrency, 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 ( @@ -58,13 +33,9 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { ); } -const DOWNLOADABLE_STATUSES = ['Approved', 'Sent', 'Revised']; - export default function ProposalDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [downloading, setDownloading] = useState(false); - const [downloadingRevision, setDownloadingRevision] = useState(null); const { data: proposal, isLoading, error } = useQuery({ queryKey: ['proposals', id], @@ -72,48 +43,8 @@ export default function ProposalDetailPage() { enabled: !!id, }); - const { data: pdfVersions } = useQuery({ - queryKey: ['proposals', id, 'pdf-versions'], - queryFn: () => proposalsApi.getPdfVersions(id!), - enabled: !!id && !!proposal && DOWNLOADABLE_STATUSES.includes(proposal.status), - }); - useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); - const handleDownload = async () => { - if (!id || !proposal) return; - setDownloading(true); - try { - const result = await proposalsApi.getPdf(id); - if (result) { - const a = document.createElement('a'); - a.href = result.downloadUrl; - a.download = `${proposal.proposalNumber}.pdf`; - a.click(); - URL.revokeObjectURL(result.downloadUrl); - } - } finally { - setDownloading(false); - } - }; - - const handleDownloadRevision = async (revision: number) => { - if (!id || !proposal) return; - setDownloadingRevision(revision); - try { - const result = await proposalsApi.getPdfRevision(id, revision); - if (result) { - const a = document.createElement('a'); - a.href = result.downloadUrl; - a.download = `${proposal.proposalNumber}-rev${revision}.pdf`; - a.click(); - URL.revokeObjectURL(result.downloadUrl); - } - } finally { - setDownloadingRevision(null); - } - }; - if (isLoading) { return ( @@ -147,20 +78,10 @@ export default function ProposalDetailPage() { {proposal.proposalNumber} - {DOWNLOADABLE_STATUSES.includes(proposal.status) && ( - - )} @@ -171,41 +92,16 @@ export default function ProposalDetailPage() { {proposal.status === 'Revised' ? ( - - - - - {STATUS_EXPLANATIONS[proposal.status]} - - + ) : ( - <> - = 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] ?? ''} - - - + = 0 ? activeStep : 0} alternativeLabel> + {STATUS_ORDER.map((label) => ( + + {label} + + ))} + )} @@ -216,15 +112,15 @@ export default function ProposalDetailPage() { Proposal Details - + - + - + - + @@ -251,7 +147,7 @@ export default function ProposalDetailPage() { {(proposal.approvedAt || proposal.sentAt) && ( - + Approval & Delivery @@ -266,39 +162,6 @@ export default function ProposalDetailPage() { )} - - {pdfVersions && pdfVersions.length > 1 && ( - - - - PDF Versions - - - {pdfVersions.map((v) => ( - - handleDownloadRevision(v.revision)} - disabled={downloadingRevision === v.revision} - > - - - - } - > - - - ))} - - - - )} ); } diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index 15c430f..bdc3b83 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -1,4 +1,3 @@ -import { useState, useMemo, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -12,61 +11,24 @@ import { TableHead, TableRow, TablePagination, - TableSortLabel, Chip, TextField, - MenuItem, 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, groupRevisions } from '../../../lib/format'; +import { STATUS_CHIP_STYLES } from '../../../constants'; +import { formatCurrency, formatDate } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; -type SortKey = keyof ProposalListItem; -type SortDirection = 'asc' | 'desc'; - -const columns: { key: SortKey; label: string; align?: 'right' }[] = [ - { key: 'proposalNumber', label: 'Proposal #' }, - { key: 'customerName', label: 'Customer' }, - { key: 'workOrderNumber', label: 'WO #' }, - { key: 'serviceCategory', label: 'Category' }, - { key: 'priority', label: 'Priority' }, - { key: 'status', label: 'Status' }, - { key: 'totalBidAmount', label: 'Bid Amount', align: 'right' }, - { key: 'submittedAt', label: 'Submitted' }, -]; - -const COL_COUNT = 9; - export default function ProposalListPage() { useDocumentTitle('My Proposals'); const navigate = useNavigate(); - const [sortKey, setSortKey] = useState('submittedAt'); - const [sortDir, setSortDir] = useState('desc'); - 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 mineParams = { mine: true }; const { rows, search, @@ -78,86 +40,7 @@ export default function ProposalListPage() { totalCount, loading, err, - } = usePaginatedList(proposalsApi.getAll, extraParams); - - const sortedRows = useMemo(() => { - if (!rows.length) return rows; - return [...rows].sort((a, b) => { - const aVal = a[sortKey] ?? ''; - const bVal = b[sortKey] ?? ''; - const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; - return sortDir === 'asc' ? cmp : -cmp; - }); - }, [rows, sortKey, sortDir]); - - const 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')); - } else { - setSortKey(key); - setSortDir('asc'); - } - }; - - 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)} - - ); + } = usePaginatedList(proposalsApi.getAll, mineParams); return ( @@ -174,68 +57,22 @@ export default function ProposalListPage() { - - setSearch(e.target.value)} - sx={{ width: 260 }} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - /> - setFilterStatus(e.target.value)} - sx={{ width: 140 }} - > - All - {PROPOSAL_STATUSES.map((s) => ( - {STATUS_LABELS[s] || s} - ))} - - setFilterCategory(e.target.value)} - sx={{ width: 150 }} - > - All - {SERVICE_CATEGORIES.map((c) => ( - {c} - ))} - - setFilterPriority(e.target.value)} - sx={{ width: 200 }} - > - All - {PRIORITIES.map((p) => ( - {PRIORITY_LABELS[p] || p} - ))} - - {hasActiveFilters && ( - - )} - + setSearch(e.target.value)} + sx={{ mb: 2, width: 320 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> {err && ( @@ -247,72 +84,56 @@ export default function ProposalListPage() { - - {columns.map((col) => ( - - handleSort(col.key)} - > - {col.label} - - - ))} + Proposal # + Customer + WO # + Category + Priority + Status + Bid Amount + Submitted {loading ? Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: COL_COUNT }).map((_, j) => ( + {Array.from({ length: 8 }).map((_, j) => ( ))} )) - : 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) - )} - -
- - - - )} - - ); - })} + : rows.map((row) => ( + navigate(`/proposals/${row.id}`)} + > + {row.proposalNumber} + {row.customerName} + {row.workOrderNumber} + {row.serviceCategory} + {row.priority} + + + + {formatCurrency(row.totalBidAmount)} + {formatDate(row.submittedAt)} + + ))} {!loading && rows.length === 0 && ( - - - {hasActiveFilters - ? 'No proposals match your current filters.' - : 'You have not submitted any proposals yet.'} + + + No proposals found. Create your first one! - - {hasActiveFilters && ( - - )} - - )}