diff --git a/web/src/App.tsx b/web/src/App.tsx index 2226cee..46d7756 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,8 @@ import Dashboard from './pages/dashboard/Dashboard'; import ProposalFormPage from './pages/proposals/form/ProposalFormPage'; import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage'; import ProposalListPage from './pages/proposals/list/ProposalListPage'; +import AdminDashboard from './pages/admin/dashboard/AdminDashboard'; +import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; const DRAWER_WIDTH = 240; @@ -45,6 +47,12 @@ export default function App() { } /> } /> } /> + + {/* Admin Routes */} + } /> + } /> + } /> + } /> diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index b734a10..1feb785 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -8,10 +8,15 @@ import { ListItemText, Toolbar, Box, + Divider, + Typography, } from '@mui/material'; import DashboardIcon from '@mui/icons-material/Dashboard'; import DescriptionIcon from '@mui/icons-material/Description'; import AddCircleIcon from '@mui/icons-material/AddCircle'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import AssignmentIcon from '@mui/icons-material/Assignment'; +import PeopleIcon from '@mui/icons-material/People'; import { selectSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; @@ -24,12 +29,23 @@ const dispatcherNav = [ { label: 'New Proposal', path: '/proposals/new', icon: }, ]; +const adminNav = [ + { label: 'Admin Queue', path: '/admin', icon: }, + { label: 'All Proposals', path: '/admin/proposals', icon: }, +]; + +const sysadminNav = [ + { label: 'Users', path: '/admin/users', icon: }, +]; + export default function Sidebar() { const navigate = useNavigate(); const location = useLocation(); const open = useSelector((state: RootState) => selectSidebarOpen(state)); const user = useSelector((state: RootState) => selectUser(state)); + const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; + const isSysAdmin = user?.role === 'SysAdmin'; const navItems = dispatcherNav; return ( @@ -46,7 +62,7 @@ export default function Sidebar() { }} > - + {navItems.map((item) => ( ))} + + {isAdmin && ( + <> + + + Admin + + + {adminNav.map((item) => ( + navigate(item.path)} + > + {item.icon} + + + ))} + {isSysAdmin && sysadminNav.map((item) => ( + navigate(item.path)} + > + {item.icon} + + + ))} + + + )} {user && ( diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx new file mode 100644 index 0000000..cccc855 --- /dev/null +++ b/web/src/components/admin/LineItemEditor.tsx @@ -0,0 +1,249 @@ +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + IconButton, + MenuItem, + Chip, + Typography, + Button, + Tooltip, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import type { PricingMode, LineItemSource } from '../../lib/api/lineItems'; + +export interface EditableLineItem { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; +} + +interface LineItemEditorProps { + items: EditableLineItem[]; + onChange: (items: EditableLineItem[]) => void; + disabled?: boolean; +} + +const SOURCE_COLORS: Record = { + AI: 'primary', + Vendor: 'secondary', + Manual: 'default', + Historical: 'success', +}; + +const UNITS = ['each', 'sq ft', 'linear ft', 'hours', 'days', 'lot', 'gallon', 'ton']; + +function createEmptyItem(sortOrder: number): EditableLineItem { + return { + id: null, + description: '', + quantity: 1, + unit: 'each', + unitPrice: 0, + totalPrice: 0, + pricingMode: 'UnitPrice', + sortOrder, + source: 'Manual', + }; +} + +export default function LineItemEditor({ items, onChange, disabled = false }: LineItemEditorProps) { + const updateItem = (index: number, field: keyof EditableLineItem, value: unknown) => { + const updated = [...items]; + const current = updated[index]; + if (!current) return; + updated[index] = { ...current, [field]: value }; + + if (field === 'quantity' || field === 'unitPrice') { + const item = updated[index]!; + if (item.pricingMode === 'UnitPrice' || item.pricingMode === 'Both') { + item.totalPrice = (item.quantity || 0) * (item.unitPrice || 0); + } + } + + onChange(updated); + }; + + const addItem = () => { + const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0; + onChange([...items, createEmptyItem(maxSort + 1)]); + }; + + const removeItem = (index: number) => { + const updated = items.filter((_, i) => i !== index); + onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 }))); + }; + + const moveItem = (index: number, direction: -1 | 1) => { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= items.length) return; + const updated = [...items]; + const a = updated[index]; + const b = updated[targetIndex]; + if (!a || !b) return; + updated[index] = b; + updated[targetIndex] = a; + onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 }))); + }; + + const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); + + return ( + + + + Line Items ({items.length}) + + {!disabled && ( + + )} + + + + + + + # + Description + Qty + Unit + Unit Price + Total + Source + {!disabled && Actions} + + + + {items.map((item, index) => ( + + + {!disabled && ( + + moveItem(index, -1)} disabled={index === 0}> + + + {index + 1} + moveItem(index, 1)} disabled={index === items.length - 1}> + + + + )} + {disabled && {index + 1}} + + + updateItem(index, 'description', e.target.value)} + disabled={disabled} + variant="standard" + /> + + + updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + disabled={disabled} + variant="standard" + slotProps={{ htmlInput: { min: 0, step: 0.01 } }} + /> + + + updateItem(index, 'unit', e.target.value)} + disabled={disabled} + variant="standard" + fullWidth + > + {UNITS.map((u) => ( + {u} + ))} + + + + updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)} + disabled={disabled || item.pricingMode === 'TotalPrice'} + variant="standard" + slotProps={{ htmlInput: { min: 0, step: 0.01 } }} + /> + + + updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)} + disabled={disabled || item.pricingMode === 'UnitPrice'} + variant="standard" + slotProps={{ htmlInput: { min: 0, step: 0.01 } }} + sx={{ fontWeight: 600 }} + /> + + + + + {!disabled && ( + + + removeItem(index)}> + + + + + )} + + ))} + {items.length === 0 && ( + + + + No line items yet. {!disabled && 'Click "Add Row" to start.'} + + + + )} + +
+
+ + + + Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)} + + +
+ ); +} diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx new file mode 100644 index 0000000..07661e7 --- /dev/null +++ b/web/src/components/admin/SimilarProposalsPanel.tsx @@ -0,0 +1,135 @@ +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Typography, + Skeleton, + Accordion, + AccordionSummary, + AccordionDetails, + Button, + Chip, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { adminApi } from '../../lib/api/admin'; +import type { EditableLineItem } from './LineItemEditor'; + +interface SimilarProposal { + proposalNumber: string; + customerName: string; + serviceCategory: string; + similarityScore: number; + totalBidAmount: number; + lineItems: Array<{ + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + }>; +} + +interface SimilarProposalsPanelProps { + proposalId: string; + onPullLineItem: (item: Omit) => void; + disabled?: boolean; +} + +export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { + const { data: similar, isLoading } = useQuery({ + queryKey: ['similarProposals', proposalId], + queryFn: async () => { + const result = await adminApi.getSimilar(proposalId); + return result as SimilarProposal[]; + }, + enabled: !!proposalId, + }); + + if (isLoading) { + return ( + + Similar Proposals + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ); + } + + return ( + + + Similar Proposals {similar && similar.length > 0 && `(${similar.length})`} + + + {(!similar || similar.length === 0) && ( + + No similar proposals found. AI suggestions will populate this panel once the RAG engine is active. + + )} + + {similar?.map((sp, idx) => ( + + } sx={{ minHeight: 'auto', px: 1 }}> + + + + {sp.proposalNumber} + + + + + {sp.customerName} - {sp.serviceCategory} + + + + + {sp.lineItems.map((li, liIdx) => ( + + + {li.description} + + {li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)} + + + {!disabled && ( + + )} + + ))} + + + ))} + + ); +} diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx new file mode 100644 index 0000000..196441d --- /dev/null +++ b/web/src/components/admin/VendorDataPanel.tsx @@ -0,0 +1,90 @@ +import { useQuery } from '@tanstack/react-query'; +import { Box, Typography, Skeleton, Chip } from '@mui/material'; +import apiClient from '../../lib/api/client'; + +interface VendorProposal { + id: string; + vendorName: string; + fileName: string; + totalVendorCost: number; + processingStatus: string; + extractedData: { + lineItems?: Array<{ + description: string; + quantity?: number; + unitPrice?: number; + total?: number; + }>; + } | null; +} + +interface VendorDataPanelProps { + proposalId: string; +} + +export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { + const { data: vendors, isLoading } = useQuery({ + queryKey: ['vendorProposals', proposalId], + queryFn: async () => { + const res = await apiClient.get(`/proposals/${proposalId}/vendors`); + return res.data; + }, + enabled: !!proposalId, + }); + + if (isLoading) { + return ; + } + + if (!vendors || vendors.length === 0) { + return ( + + Vendor Data + No vendor proposals attached + + ); + } + + return ( + + Vendor Data + {vendors.map((vp) => ( + + + {vp.vendorName} + + + {vp.fileName} + + {vp.totalVendorCost > 0 && ( + + Vendor Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(vp.totalVendorCost)} + + )} + + {vp.extractedData?.lineItems && vp.extractedData.lineItems.length > 0 && ( + + {vp.extractedData.lineItems.map((li, i) => ( + + + {li.description} + + {li.total != null && ( + + ${li.total.toFixed(2)} + + )} + + ))} + + )} + + ))} + + ); +} diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts new file mode 100644 index 0000000..be4ca1e --- /dev/null +++ b/web/src/lib/api/admin.ts @@ -0,0 +1,67 @@ +import apiClient from './client'; + +export interface DashboardStats { + pendingCount: number; + approvedThisWeek: number; + avgTurnaroundHours: number; + totalProposals: number; +} + +export interface UpdateProposalRequest { + refinedScope?: string; + notes?: string; + assignedAdminId?: string; +} + +export interface AuditEntry { + id: string; + proposalId: string | null; + userId: string; + userName: string; + action: string; + details: Record; + timestamp: string; + ipAddress: string | null; +} + +export const adminApi = { + getDashboard: async (): Promise => { + const res = await apiClient.get('/admin/dashboard'); + return res.data; + }, + + updateProposal: async (id: string, data: UpdateProposalRequest): Promise => { + await apiClient.put(`/proposals/${id}`, data); + }, + + approveProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/approve`); + }, + + sendProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/send`); + }, + + reviseProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/revise`); + }, + + getHistory: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/history`); + return res.data; + }, + + getAudit: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/audit`); + return res.data; + }, + + generateSuggestions: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/generate-suggestions`); + }, + + getSimilar: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/similar`); + return res.data; + }, +}; diff --git a/web/src/lib/api/lineItems.ts b/web/src/lib/api/lineItems.ts new file mode 100644 index 0000000..a3f3fc4 --- /dev/null +++ b/web/src/lib/api/lineItems.ts @@ -0,0 +1,63 @@ +import apiClient from './client'; + +export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; +export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; + +export interface LineItem { + id: string; + proposalId: string; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; + createdAt: string; + updatedAt: string; +} + +export interface CreateLineItemRequest { + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; +} + +export interface UpdateLineItemEntry { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; +} + +export const lineItemsApi = { + getAll: async (proposalId: string): Promise => { + const res = await apiClient.get(`/proposals/${proposalId}/line-items`); + return res.data; + }, + + create: async (proposalId: string, data: CreateLineItemRequest): Promise => { + const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data); + return res.data; + }, + + bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise => { + const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems }); + return res.data; + }, + + delete: async (proposalId: string, itemId: string): Promise => { + await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`); + }, +}; diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx new file mode 100644 index 0000000..04f2bf3 --- /dev/null +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -0,0 +1,266 @@ +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Chip, + TextField, + InputAdornment, + MenuItem, + Skeleton, +} 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 { 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 } from '../../../constants'; + +const STATUS_COLORS: Record = { + Draft: 'default', + InReview: 'info', + Approved: 'success', + Sent: 'success', + Revised: 'warning', +}; + +const PRIORITY_COLORS: Record = { + Standard: 'default', + Urgent: 'warning', + Emergency: 'error', +}; + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) { + return ( + + + {icon} + + {value} + {label} + + + + ); +} + +export default function AdminDashboard() { + const navigate = useNavigate(); + + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: [QUERY_KEYS.dashboard], + queryFn: adminApi.getDashboard, + }); + + const { + rows, + search, + setSearch, + page, + setPage, + pageSize, + setPageSize, + totalCount, + loading, + err, + } = usePaginatedList(proposalsApi.getAll); + + return ( + + Admin Dashboard + + + + {statsLoading ? ( + + ) : ( + } + label="Pending Review" + value={String(stats?.pendingCount ?? 0)} + color="#1565c0" + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Approved This Week" + value={String(stats?.approvedThisWeek ?? 0)} + color="#2e7d32" + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Avg Turnaround" + value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'} + color="#f57c00" + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Total Proposals" + value={String(stats?.totalProposals ?? 0)} + color="#5e35b1" + /> + )} + + + + + + + setSearch(e.target.value)} + sx={{ width: 240 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + {}} + > + All + {SERVICE_CATEGORIES.map((c) => ( + {c} + ))} + + {}} + > + All + {PRIORITIES.map((p) => ( + {p} + ))} + + + + {err && ( + {err} + )} + + + + + + Proposal # + Customer + WO # + Category + Priority + Status + Bid Amount + Submitted + Submitted By + + + + {loading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 9 }).map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + navigate(`/admin/proposals/${row.id}`)} + > + {row.proposalNumber} + {row.customerName} + {row.workOrderNumber} + {row.serviceCategory} + + + + + + + {formatCurrency(row.totalBidAmount)} + {formatDate(row.submittedAt)} + {row.submittedByName || '-'} + + ))} + {!loading && rows.length === 0 && ( + + + No proposals in queue + + + )} + +
+
+ + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))} + rowsPerPageOptions={[12, 24, 48]} + /> +
+
+
+ ); +} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx new file mode 100644 index 0000000..488396d --- /dev/null +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -0,0 +1,407 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Grid, + Button, + Chip, + Divider, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + CircularProgress, + Alert, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SaveIcon from '@mui/icons-material/Save'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +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 { toast } from 'react-toastify'; +import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; +import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; +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'; + +const STATUS_COLORS: Record = { + Draft: 'default', + InReview: 'info', + Approved: 'success', + Sent: 'success', + Revised: 'warning', +}; + +export default function AdminWorkspace() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [lineItems, setLineItems] = useState([]); + const [refinedScope, setRefinedScope] = useState(''); + const [approveDialogOpen, setApproveDialogOpen] = useState(false); + const [dirty, setDirty] = useState(false); + + const { data: proposal, isLoading } = useQuery({ + queryKey: [QUERY_KEYS.proposals, id], + queryFn: () => proposalsApi.getById(id!), + enabled: !!id, + }); + + const { data: fetchedLineItems } = useQuery({ + queryKey: [QUERY_KEYS.proposalLineItems, id], + queryFn: () => lineItemsApi.getAll(id!), + enabled: !!id, + }); + + useEffect(() => { + if (fetchedLineItems) { + setLineItems( + fetchedLineItems.map((li) => ({ + id: li.id, + description: li.description, + quantity: li.quantity, + unit: li.unit, + unitPrice: li.unitPrice, + totalPrice: li.totalPrice, + pricingMode: li.pricingMode, + sortOrder: li.sortOrder, + source: li.source, + })) + ); + } + }, [fetchedLineItems]); + + useEffect(() => { + if (proposal) { + setRefinedScope(proposal.refinedScope || proposal.scopeOfWork); + } + }, [proposal]); + + const saveMutation = useMutation({ + mutationFn: async () => { + await adminApi.updateProposal(id!, { refinedScope }); + await lineItemsApi.bulkUpdate( + id!, + lineItems.map((li) => ({ + id: li.id, + description: li.description, + quantity: li.quantity, + unit: li.unit, + unitPrice: li.unitPrice, + totalPrice: li.totalPrice, + pricingMode: li.pricingMode, + sortOrder: li.sortOrder, + source: li.source, + })) + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + setDirty(false); + toast.success('Changes saved'); + }, + }); + + const approveMutation = useMutation({ + mutationFn: () => adminApi.approveProposal(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + setApproveDialogOpen(false); + toast.success('Proposal approved'); + }, + }); + + const sendMutation = useMutation({ + mutationFn: () => adminApi.sendProposal(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + toast.success('Proposal marked as sent'); + }, + }); + + const reviseMutation = useMutation({ + mutationFn: () => adminApi.reviseProposal(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + toast.success('Revision created'); + }, + }); + + const regenerateMutation = useMutation({ + mutationFn: () => adminApi.generateSuggestions(id!), + onSuccess: () => { + toast.info('AI suggestion generation started'); + }, + }); + + const handleLineItemsChange = (updated: EditableLineItem[]) => { + setLineItems(updated); + setDirty(true); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (!proposal) { + return ( + + Proposal not found + + + ); + } + + const isEditable = proposal.status === 'InReview' || proposal.status === 'Revised'; + const isApproved = proposal.status === 'Approved'; + const isSent = proposal.status === 'Sent'; + const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0); + + return ( + + {/* Header */} + + + + {proposal.proposalNumber} + + + + + + {/* Proposal Info Bar */} + + + + Customer + {proposal.customerName} + + + WO # + {proposal.workOrderNumber} + + + Category + {proposal.serviceCategory} + + + Priority + + + + Address + {proposal.customerAddress} + + + + + {/* Three-Panel Layout */} + + {/* Left Panel: Reference */} + + + + Submission Details + + Submitted By + {proposal.submittedByName || '-'} + + Submitted + + {new Date(proposal.submittedAt).toLocaleString()} + + + + + Original Scope + + {proposal.scopeOfWork} + + + {proposal.notes && ( + <> + Notes + + {proposal.notes} + + + )} + + + + + + + + {/* Center Panel: Editor */} + + + + Refined Scope + { setRefinedScope(e.target.value); setDirty(true); }} + disabled={!isEditable} + size="small" + sx={{ mb: 2 }} + /> + + + + + + + {/* Right Panel: Similar Proposals */} + + + + { + const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; + handleLineItemsChange([ + ...lineItems, + { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, + ]); + }} + disabled={!isEditable} + /> + + + + + + {/* Action Bar */} + + + {dirty && ( + Unsaved changes + )} + {!dirty && } + + {isEditable && ( + <> + + + + + )} + + {isApproved && ( + <> + + + + )} + + {isSent && ( + + )} + + + + {/* Approve Confirmation Dialog */} + setApproveDialogOpen(false)}> + Approve Proposal + + + Approve {proposal.proposalNumber} for {proposal.customerName}? + + + Line Items: {lineItems.length} + + Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)} + + {proposal.vendorTotalCost != null && ( + + Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)} + + )} + + + + + + + + + ); +}