From b29dd0cc6b733cc7ec3ff16fa238a338009eff95 Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Sat, 16 May 2026 19:42:47 -0400
Subject: [PATCH] Implement Admin Frontend Experience (Phase 3)
Three-panel admin workspace: left reference panel (submission details,
vendor data), center editor (refined scope, inline line item table with
reorder/add/remove/pricing), right similar proposals panel (KB results
with pull-to-editor). Admin dashboard with stats cards and proposal
queue table. Approval flow with confirmation dialog, mark-as-sent,
and create-revision actions. Role-based sidebar navigation.
---
web/src/App.tsx | 8 +
web/src/components/Sidebar.tsx | 49 ++-
web/src/components/admin/LineItemEditor.tsx | 249 +++++++++++
.../admin/SimilarProposalsPanel.tsx | 135 ++++++
web/src/components/admin/VendorDataPanel.tsx | 90 ++++
web/src/lib/api/admin.ts | 67 +++
web/src/lib/api/lineItems.ts | 63 +++
.../pages/admin/dashboard/AdminDashboard.tsx | 266 ++++++++++++
.../pages/admin/workspace/AdminWorkspace.tsx | 407 ++++++++++++++++++
9 files changed, 1333 insertions(+), 1 deletion(-)
create mode 100644 web/src/components/admin/LineItemEditor.tsx
create mode 100644 web/src/components/admin/SimilarProposalsPanel.tsx
create mode 100644 web/src/components/admin/VendorDataPanel.tsx
create mode 100644 web/src/lib/api/admin.ts
create mode 100644 web/src/lib/api/lineItems.ts
create mode 100644 web/src/pages/admin/dashboard/AdminDashboard.tsx
create mode 100644 web/src/pages/admin/workspace/AdminWorkspace.tsx
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 && (
+ } onClick={addItem}>
+ Add Row
+
+ )}
+
+
+
+
+
+
+ #
+ 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) => (
+
+ ))}
+
+
+
+ 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: (
+
+
+
+ ),
+ },
+ }}
+ />
+ {}}
+ >
+
+ {SERVICE_CATEGORIES.map((c) => (
+
+ ))}
+
+ {}}
+ >
+
+ {PRIORITIES.map((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
+ } onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
+ Back to Dashboard
+
+
+ );
+ }
+
+ 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 */}
+
+ } onClick={() => navigate('/admin')}>
+ Back
+
+
+ {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 && (
+ <>
+ }
+ onClick={() => regenerateMutation.mutate()}
+ disabled={regenerateMutation.isPending}
+ >
+ Regenerate
+
+ }
+ onClick={() => saveMutation.mutate()}
+ disabled={saveMutation.isPending || !dirty}
+ >
+ Save
+
+ }
+ onClick={() => setApproveDialogOpen(true)}
+ disabled={lineItems.length === 0 || bidTotal <= 0}
+ >
+ Approve
+
+ >
+ )}
+
+ {isApproved && (
+ <>
+ }
+ onClick={() => toast.info('PDF generation will be available in Phase 5')}
+ >
+ Generate PDF
+
+ }
+ onClick={() => sendMutation.mutate()}
+ disabled={sendMutation.isPending}
+ >
+ Mark as Sent
+
+ >
+ )}
+
+ {isSent && (
+ }
+ onClick={() => reviseMutation.mutate()}
+ disabled={reviseMutation.isPending}
+ >
+ Create Revision
+
+ )}
+
+
+
+ {/* Approve Confirmation Dialog */}
+
+
+ );
+}