mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 14:43:13 +00:00
Replace MUI semantic chip colors with custom status/priority palette
This commit is contained in:
parent
83e1a7948e
commit
b507bb0c28
6 changed files with 294 additions and 1077 deletions
|
|
@ -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<string, string> = {
|
||||
Draft: 'Draft',
|
||||
InReview: 'In Review',
|
||||
Approved: 'Approved',
|
||||
Sent: 'Sent',
|
||||
Revised: 'Revised',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error' | 'secondary' | 'primary'> = {
|
||||
Draft: 'default',
|
||||
InReview: 'info',
|
||||
Approved: 'success',
|
||||
Sent: 'primary',
|
||||
Revised: 'warning',
|
||||
export const STATUS_CHIP_STYLES: Record<string, { bgcolor: string; color: string }> = {
|
||||
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<string, string> = {
|
||||
Standard: 'Standard',
|
||||
Urgent: 'Urgent',
|
||||
Emergency: 'Emergency Dispatch',
|
||||
};
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||||
Standard: 'default',
|
||||
Urgent: 'warning',
|
||||
Emergency: 'error',
|
||||
};
|
||||
|
||||
export const PRIORITY_ROW_SX: Record<string, object> = {
|
||||
Urgent: {
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: 'warning.main',
|
||||
},
|
||||
Emergency: {
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: 'error.main',
|
||||
backgroundColor: 'error.50',
|
||||
},
|
||||
export const PRIORITY_CHIP_STYLES: Record<string, { bgcolor: string; color: string }> = {
|
||||
Standard: { bgcolor: '#E5E7EB', color: '#374151' },
|
||||
Urgent: { bgcolor: '#EA580C', color: '#FFFFFF' },
|
||||
Emergency: { bgcolor: '#DC2626', color: '#FFFFFF' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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)' } }}>
|
||||
|
|
@ -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<DashboardStats>({
|
||||
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<Set<string>>(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) => (
|
||||
<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>
|
||||
|
|
@ -225,19 +136,6 @@ 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"
|
||||
|
|
@ -273,34 +171,27 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
|||
select
|
||||
label="Priority"
|
||||
value={priorityFilter}
|
||||
sx={{ width: 200 }}
|
||||
sx={{ width: 140 }}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||
<MenuItem key={p} value={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}>{s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{!showTabs && (
|
||||
<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>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{err && (
|
||||
|
|
@ -311,7 +202,6 @@ 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>
|
||||
|
|
@ -321,54 +211,43 @@ 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: COL_COUNT }).map((_, j) => (
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: grouped.map((group) => {
|
||||
const isExpanded = expanded.has(group.baseNumber);
|
||||
return (
|
||||
<Fragment 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>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
: rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
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={row.priority} size="small" sx={PRIORITY_CHIP_STYLES[row.priority]} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
<TableCell>{row.submittedByName || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{hasActiveFilters
|
||||
? 'No proposals match your current filters.'
|
||||
: 'No proposals in queue.'}
|
||||
</Typography>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">No proposals in queue</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<EditableLineItem[]>([]);
|
||||
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 | 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],
|
||||
|
|
@ -105,18 +59,6 @@ export default function AdminWorkspace() {
|
|||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: auditTrail } = useQuery<AuditEntry[]>({
|
||||
queryKey: ['audit', id],
|
||||
queryFn: () => adminApi.getAudit(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: revisionHistory } = useQuery<ProposalDetail[]>({
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
|
|
@ -308,202 +215,104 @@ export default function AdminWorkspace() {
|
|||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => {
|
||||
if (dirty && !window.confirm('You have unsaved changes. Are you sure you want to leave?')) return;
|
||||
navigate('/admin');
|
||||
}}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
|
||||
Back
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||
{proposal.proposalNumber}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`Rev ${proposal.currentRevision}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => setRevMenuAnchor(e.currentTarget)}
|
||||
onDelete={(e) => setRevMenuAnchor(e.currentTarget as HTMLElement)}
|
||||
deleteIcon={<ArrowDropDownIcon />}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={revMenuAnchor}
|
||||
open={Boolean(revMenuAnchor)}
|
||||
onClose={() => setRevMenuAnchor(null)}
|
||||
>
|
||||
{(revisionHistory ?? [])
|
||||
.sort((a, b) => a.currentRevision - b.currentRevision)
|
||||
.map((rev) => (
|
||||
<MenuItem
|
||||
key={rev.id}
|
||||
selected={rev.id === id}
|
||||
dense
|
||||
onClick={() => {
|
||||
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 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600 }}>Rev {rev.currentRevision}</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{STATUS_LABELS[rev.status] || rev.status}</Typography>
|
||||
</Box>
|
||||
{DOWNLOADABLE_STATUSES.includes(rev.status) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRevMenuAnchor(null);
|
||||
handleDownloadPdf(rev.id, rev.proposalNumber, rev.currentRevision);
|
||||
}}
|
||||
disabled={downloadingRevId === rev.id}
|
||||
>
|
||||
<DownloadIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
<Chip label={STATUS_LABELS[proposal.status] || proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
|
||||
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
|
||||
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
|
||||
</Box>
|
||||
|
||||
{/* Proposal Info Bar */}
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Customer</Typography>
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Customer</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Site</Typography>
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerAddress}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">WO #</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>WO #</Typography>
|
||||
{isEditable ? (
|
||||
<TextField
|
||||
size="small"
|
||||
variant="standard"
|
||||
placeholder="—"
|
||||
value={workOrderNumber}
|
||||
onChange={(e) => { 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 } }}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{workOrderNumber || '—'}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">Category</Typography>
|
||||
<Typography variant="body2">{proposal.serviceCategory}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>PO #</Typography>
|
||||
{isEditable ? (
|
||||
<TextField
|
||||
size="small"
|
||||
variant="standard"
|
||||
placeholder="—"
|
||||
value={poNumber}
|
||||
onChange={(e) => { 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 } }}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{poNumber || '—'}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">Priority</Typography>
|
||||
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Category</Typography>
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.serviceCategory}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Priority</Typography>
|
||||
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{PRIORITY_LABELS[proposal.priority] || proposal.priority}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Address</Typography>
|
||||
<Typography variant="body2">{proposal.customerAddress}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Three-Panel Layout */}
|
||||
<Grid container spacing={2}>
|
||||
{/* 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>
|
||||
{/* Left Panel: Reference */}
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</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 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 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<VendorDataPanel proposalId={id!} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Center Panel: Editor */}
|
||||
<Grid size={{ xs: 12, md: leftCollapsed ? 9 : 6 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{leftCollapsed && (
|
||||
<IconButton
|
||||
<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}
|
||||
size="small"
|
||||
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 }}
|
||||
/>
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<LineItemEditor
|
||||
items={lineItems}
|
||||
onChange={handleLineItemsChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
<LineItemEditor
|
||||
items={lineItems}
|
||||
onChange={handleLineItemsChange}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Similar Proposals */}
|
||||
|
|
@ -527,27 +336,12 @@ export default function AdminWorkspace() {
|
|||
</Grid>
|
||||
|
||||
{/* Action Bar */}
|
||||
<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 }} />
|
||||
|
||||
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
|
||||
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{dirty && (
|
||||
<Chip label="Unsaved changes" size="small" color="info" variant="outlined" />
|
||||
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
|
||||
)}
|
||||
{!dirty && <Box sx={{ flexGrow: 1 }} />}
|
||||
|
||||
{isEditable && (
|
||||
<>
|
||||
|
|
@ -557,51 +351,34 @@ export default function AdminWorkspace() {
|
|||
onClick={() => regenerateMutation.mutate()}
|
||||
disabled={regenerateMutation.isPending}
|
||||
>
|
||||
Regenerate Suggested Line Items
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={() => setApproveDialogOpen(true)}
|
||||
disabled={lineItems.length === 0 || bidTotal <= 0}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Tooltip title={!dirty ? 'No unsaved changes' : ''}>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={lineItems.length === 0 ? 'Add at least one line item before approving' : bidTotal <= 0 ? 'Total bid must be greater than $0' : ''}>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={() => setApproveDialogOpen(true)}
|
||||
disabled={lineItems.length === 0 || bidTotal <= 0}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isApproved && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<UndoIcon />}
|
||||
onClick={() => setReturnToReviewDialogOpen(true)}
|
||||
disabled={returnToReviewMutation.isPending}
|
||||
>
|
||||
Return to Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PictureAsPdfIcon />}
|
||||
onClick={() => pdfMutation.mutate(false)}
|
||||
onClick={() => pdfMutation.mutate()}
|
||||
disabled={pdfMutation.isPending}
|
||||
>
|
||||
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
|
||||
|
|
@ -619,39 +396,32 @@ export default function AdminWorkspace() {
|
|||
)}
|
||||
|
||||
{isSent && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadPdf(id!, proposal.proposalNumber, proposal.currentRevision)}
|
||||
disabled={downloadingRevId === id}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={() => setReviseDialogOpen(true)}
|
||||
disabled={reviseMutation.isPending}
|
||||
>
|
||||
Create Revision
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{proposal.status === 'Revised' && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => handleDownloadPdf(id!, proposal.proposalNumber, proposal.currentRevision)}
|
||||
disabled={downloadingRevId === id}
|
||||
startIcon={<HistoryIcon />}
|
||||
onClick={() => setReviseDialogOpen(true)}
|
||||
disabled={reviseMutation.isPending}
|
||||
>
|
||||
Download PDF
|
||||
Create Revision
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unsaved Changes Navigation Guard */}
|
||||
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => blocker.reset?.()}>Stay</Button>
|
||||
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}>
|
||||
Discard & Leave
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Approve Confirmation Dialog */}
|
||||
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
|
||||
<DialogTitle>Approve Proposal</DialogTitle>
|
||||
|
|
@ -726,71 +496,6 @@ export default function AdminWorkspace() {
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Return to Review Confirmation Dialog */}
|
||||
<Dialog open={returnToReviewDialogOpen} onClose={() => setReturnToReviewDialogOpen(false)}>
|
||||
<DialogTitle>Return to Review</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Return <strong>{proposal.proposalNumber}</strong> to review?
|
||||
This will clear the approval and allow further editing.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReturnToReviewDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => returnToReviewMutation.mutate()}
|
||||
disabled={returnToReviewMutation.isPending}
|
||||
>
|
||||
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Audit Trail */}
|
||||
{auditTrail && auditTrail.length > 0 && (
|
||||
<Card sx={{ mt: 2 }}>
|
||||
<CardContent sx={{ py: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Activity Log</Typography>
|
||||
<Box sx={{ maxHeight: 240, overflow: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
'& tr:hover': { bgcolor: 'action.hover' },
|
||||
'& td': { padding: '6px 8px', border: 0 },
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
{auditTrail.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', width: 155, paddingLeft: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</td>
|
||||
<td style={{ width: 150 }}>
|
||||
<Chip label={formatAction(entry.action)} size="small" variant="outlined" />
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap', width: 120 }}>
|
||||
<Typography variant="caption">{entry.userName}</Typography>
|
||||
</td>
|
||||
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{entry.details ? formatDetails(entry.details) : ''}
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 }}>
|
||||
<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 }}>
|
||||
<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: '22px', fontWeight: 700, color: '#003a5d' }}>
|
||||
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
|
@ -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<ProposalStats>({
|
||||
|
|
@ -80,20 +66,6 @@ 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 }}>
|
||||
|
|
@ -108,28 +80,42 @@ export default function Dashboard() {
|
|||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{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 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>
|
||||
</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"
|
||||
variant="text"
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
onClick={() => navigate(isAdmin ? '/admin/proposals' : '/proposals')}
|
||||
>
|
||||
View All Proposals
|
||||
<Button size="small" onClick={() => navigate('/proposals')}>
|
||||
View All
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
|
@ -139,9 +125,7 @@ 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>
|
||||
|
|
@ -151,7 +135,7 @@ export default function Dashboard() {
|
|||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -160,31 +144,22 @@ export default function Dashboard() {
|
|||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer', ...PRIORITY_ROW_SX[row.priority] }}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
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}
|
||||
/>
|
||||
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
|
||||
</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 align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!isLoading && recentError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="error">
|
||||
Failed to load proposals. Please try again.
|
||||
</Typography>
|
||||
|
|
@ -193,7 +168,7 @@ export default function Dashboard() {
|
|||
)}
|
||||
{!isLoading && !recentError && proposals.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
No proposals yet. Submit your first one!
|
||||
</Typography>
|
||||
|
|
|
|||
|
|
@ -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<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 }}>
|
||||
|
|
@ -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<number | null>(null);
|
||||
|
||||
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
||||
queryKey: ['proposals', id],
|
||||
|
|
@ -72,48 +43,8 @@ export default function ProposalDetailPage() {
|
|||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: pdfVersions } = useQuery<PdfVersion[]>({
|
||||
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 (
|
||||
<Box>
|
||||
|
|
@ -147,20 +78,10 @@ export default function ProposalDetailPage() {
|
|||
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||
{proposal.proposalNumber}
|
||||
</Typography>
|
||||
{DOWNLOADABLE_STATUSES.includes(proposal.status) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? 'Downloading...' : 'Download PDF'}
|
||||
</Button>
|
||||
)}
|
||||
<Chip
|
||||
label={STATUS_LABELS[proposal.status] || proposal.status}
|
||||
color={STATUS_COLORS[proposal.status] || 'default'}
|
||||
label={proposal.status}
|
||||
size="medium"
|
||||
sx={STATUS_CHIP_STYLES[proposal.status]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
|
@ -171,41 +92,16 @@ export default function ProposalDetailPage() {
|
|||
</Typography>
|
||||
{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>
|
||||
<Chip label="Revised — a new revision has been created" sx={STATUS_CHIP_STYLES['Revised']} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||
{STATUS_ORDER.map((label) => (
|
||||
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -216,15 +112,15 @@ export default function ProposalDetailPage() {
|
|||
Proposal Details
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<InfoRow label="Work Order Number" value={proposal.workOrderNumber} />
|
||||
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
|
||||
<InfoRow label="Customer" value={proposal.customerName} />
|
||||
<InfoRow label="Site" value={proposal.customerAddress} />
|
||||
<InfoRow label="Address" value={proposal.customerAddress} />
|
||||
<InfoRow label="Category" value={proposal.serviceCategory} />
|
||||
<InfoRow label="Priority" value={PRIORITY_LABELS[proposal.priority] || proposal.priority} />
|
||||
<InfoRow label="Priority" value={proposal.priority} />
|
||||
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
|
||||
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
||||
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
||||
<InfoRow label="Bid Amount" value={formatBidAmount(proposal.totalBidAmount)} />
|
||||
<InfoRow label="Bid Amount" value={formatCurrency(proposal.totalBidAmount)} />
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
|
@ -251,7 +147,7 @@ export default function ProposalDetailPage() {
|
|||
</Card>
|
||||
|
||||
{(proposal.approvedAt || proposal.sentAt) && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||
Approval & Delivery
|
||||
|
|
@ -266,39 +162,6 @@ export default function ProposalDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pdfVersions && pdfVersions.length > 1 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
PDF Versions
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{pdfVersions.map((v) => (
|
||||
<ListItem
|
||||
key={v.revision}
|
||||
secondaryAction={
|
||||
<Tooltip title={`Download Rev ${v.revision}`}>
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDownloadRevision(v.revision)}
|
||||
disabled={downloadingRevision === v.revision}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`Revision ${v.revision}${v.revision === proposal.currentRevision ? ' (current)' : ''}`}
|
||||
secondary={formatDateTime(v.generatedAt)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SortKey>('submittedAt');
|
||||
const [sortDir, setSortDir] = useState<SortDirection>('desc');
|
||||
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 mineParams = { mine: true };
|
||||
const {
|
||||
rows,
|
||||
search,
|
||||
|
|
@ -78,86 +40,7 @@ export default function ProposalListPage() {
|
|||
totalCount,
|
||||
loading,
|
||||
err,
|
||||
} = usePaginatedList<ProposalListItem>(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) => (
|
||||
<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>
|
||||
);
|
||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -174,68 +57,22 @@ export default function ProposalListPage() {
|
|||
|
||||
<Card>
|
||||
<CardContent sx={{ pb: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: 260 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Status"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
sx={{ width: 140 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PROPOSAL_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Category"
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
sx={{ width: 150 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{SERVICE_CATEGORIES.map((c) => (
|
||||
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Priority"
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
sx={{ width: 200 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ mb: 2, width: 320 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
<Typography color="error" sx={{ mb: 2 }}>
|
||||
|
|
@ -247,72 +84,56 @@ 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
|
||||
active={sortKey === col.key}
|
||||
direction={sortKey === col.key ? sortDir : 'asc'}
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: COL_COUNT }).map((_, j) => (
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton variant="text" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: grouped.map((group) => {
|
||||
const isExpanded = expanded.has(group.baseNumber);
|
||||
return (
|
||||
<Fragment 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>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
: rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
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>{row.priority}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={row.status}
|
||||
size="small"
|
||||
sx={STATUS_CHIP_STYLES[row.status]}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
{hasActiveFilters
|
||||
? 'No proposals match your current filters.'
|
||||
: 'You have not submitted any proposals yet.'}
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
No proposals found. Create your first one!
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{hasActiveFilters && (
|
||||
<Button size="small" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="contained" startIcon={<AddCircleIcon />} onClick={() => navigate('/proposals/new')}>
|
||||
New Proposal
|
||||
</Button>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue