mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-03 22:23:17 +00:00
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.
This commit is contained in:
parent
fc72e72cc6
commit
b29dd0cc6b
9 changed files with 1333 additions and 1 deletions
|
|
@ -12,6 +12,8 @@ import Dashboard from './pages/dashboard/Dashboard';
|
||||||
import ProposalFormPage from './pages/proposals/form/ProposalFormPage';
|
import ProposalFormPage from './pages/proposals/form/ProposalFormPage';
|
||||||
import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
|
import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
|
||||||
import ProposalListPage from './pages/proposals/list/ProposalListPage';
|
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;
|
const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
|
@ -45,6 +47,12 @@ export default function App() {
|
||||||
<Route path="/proposals" element={<ProposalListPage />} />
|
<Route path="/proposals" element={<ProposalListPage />} />
|
||||||
<Route path="/proposals/new" element={<ProposalFormPage />} />
|
<Route path="/proposals/new" element={<ProposalFormPage />} />
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/proposals" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/proposals/:id" element={<AdminWorkspace />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,15 @@ import {
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Box,
|
Box,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
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 { selectSidebarOpen } from '../app/slices/uiSlice';
|
||||||
import { selectUser } from '../app/slices/authSlice';
|
import { selectUser } from '../app/slices/authSlice';
|
||||||
import type { RootState } from '../app/store';
|
import type { RootState } from '../app/store';
|
||||||
|
|
@ -24,12 +29,23 @@ const dispatcherNav = [
|
||||||
{ label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon /> },
|
{ label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const adminNav = [
|
||||||
|
{ label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon /> },
|
||||||
|
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sysadminNav = [
|
||||||
|
{ label: 'Users', path: '/admin/users', icon: <PeopleIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const open = useSelector((state: RootState) => selectSidebarOpen(state));
|
const open = useSelector((state: RootState) => selectSidebarOpen(state));
|
||||||
const user = useSelector((state: RootState) => selectUser(state));
|
const user = useSelector((state: RootState) => selectUser(state));
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
|
||||||
|
const isSysAdmin = user?.role === 'SysAdmin';
|
||||||
const navItems = dispatcherNav;
|
const navItems = dispatcherNav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -46,7 +62,7 @@ export default function Sidebar() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<Box sx={{ overflow: 'auto' }}>
|
<Box sx={{ overflow: 'auto', flexGrow: 1 }}>
|
||||||
<List>
|
<List>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
|
|
@ -59,6 +75,37 @@ export default function Sidebar() {
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="overline" sx={{ px: 2, pt: 1, display: 'block', color: 'text.secondary' }}>
|
||||||
|
Admin
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{adminNav.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
{isSysAdmin && sysadminNav.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{user && (
|
{user && (
|
||||||
<Box sx={{ p: 2, mt: 'auto', borderTop: 1, borderColor: 'divider' }}>
|
<Box sx={{ p: 2, mt: 'auto', borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
|
|
||||||
249
web/src/components/admin/LineItemEditor.tsx
Normal file
249
web/src/components/admin/LineItemEditor.tsx
Normal file
|
|
@ -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<LineItemSource, 'primary' | 'secondary' | 'default' | 'success'> = {
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
Line Items ({items.length})
|
||||||
|
</Typography>
|
||||||
|
{!disabled && (
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addItem}>
|
||||||
|
Add Row
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer sx={{ maxHeight: 480 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 60 }}>#</TableCell>
|
||||||
|
<TableCell sx={{ minWidth: 200 }}>Description</TableCell>
|
||||||
|
<TableCell sx={{ width: 80 }}>Qty</TableCell>
|
||||||
|
<TableCell sx={{ width: 100 }}>Unit</TableCell>
|
||||||
|
<TableCell sx={{ width: 110 }}>Unit Price</TableCell>
|
||||||
|
<TableCell sx={{ width: 110 }}>Total</TableCell>
|
||||||
|
<TableCell sx={{ width: 90 }}>Source</TableCell>
|
||||||
|
{!disabled && <TableCell sx={{ width: 100 }}>Actions</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={item.id ?? `new-${index}`} hover>
|
||||||
|
<TableCell>
|
||||||
|
{!disabled && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0}>
|
||||||
|
<ArrowUpwardIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="caption">{index + 1}</Typography>
|
||||||
|
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1}>
|
||||||
|
<ArrowDownwardIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{disabled && <Typography variant="caption">{index + 1}</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
maxRows={3}
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{UNITS.map((u) => (
|
||||||
|
<MenuItem key={u} value={u}>{u}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice ?? ''}
|
||||||
|
onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={disabled || item.pricingMode === 'TotalPrice'}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.totalPrice}
|
||||||
|
onChange={(e) => 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 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={item.source}
|
||||||
|
size="small"
|
||||||
|
color={SOURCE_COLORS[item.source]}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
{!disabled && (
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeItem(index)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 3 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No line items yet. {!disabled && 'Click "Add Row" to start.'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
web/src/components/admin/SimilarProposalsPanel.tsx
Normal file
135
web/src/components/admin/SimilarProposalsPanel.tsx
Normal file
|
|
@ -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<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
|
||||||
|
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
|
||||||
|
queryKey: ['similarProposals', proposalId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await adminApi.getSimilar(proposalId);
|
||||||
|
return result as SimilarProposal[];
|
||||||
|
},
|
||||||
|
enabled: !!proposalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Similar Proposals</Typography>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="rectangular" height={40} sx={{ mb: 1, borderRadius: 1 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Similar Proposals {similar && similar.length > 0 && `(${similar.length})`}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{(!similar || similar.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No similar proposals found. AI suggestions will populate this panel once the RAG engine is active.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{similar?.map((sp, idx) => (
|
||||||
|
<Accordion key={idx} disableGutters sx={{ '&:before': { display: 'none' }, mb: 1 }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ minHeight: 'auto', px: 1 }}>
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{sp.proposalNumber}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${Math.round(sp.similarityScore * 100)}%`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{sp.customerName} - {sp.serviceCategory}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 1, pt: 0 }}>
|
||||||
|
{sp.lineItems.map((li, liIdx) => (
|
||||||
|
<Box
|
||||||
|
key={liIdx}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 0.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" noWrap>{li.description}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||||
|
onClick={() =>
|
||||||
|
onPullLineItem({
|
||||||
|
description: li.description,
|
||||||
|
quantity: li.quantity,
|
||||||
|
unit: li.unit,
|
||||||
|
unitPrice: li.unitPrice,
|
||||||
|
totalPrice: li.totalPrice,
|
||||||
|
pricingMode: li.unitPrice ? 'UnitPrice' : 'TotalPrice',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
web/src/components/admin/VendorDataPanel.tsx
Normal file
90
web/src/components/admin/VendorDataPanel.tsx
Normal file
|
|
@ -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<VendorProposal[]>({
|
||||||
|
queryKey: ['vendorProposals', proposalId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!proposalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton variant="rectangular" height={60} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vendors || vendors.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Vendor Data</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">No vendor proposals attached</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Vendor Data</Typography>
|
||||||
|
{vendors.map((vp) => (
|
||||||
|
<Box key={vp.id} sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{vp.vendorName}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={vp.processingStatus}
|
||||||
|
size="small"
|
||||||
|
color={vp.processingStatus === 'Complete' ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">{vp.fileName}</Typography>
|
||||||
|
|
||||||
|
{vp.totalVendorCost > 0 && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>
|
||||||
|
Vendor Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(vp.totalVendorCost)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vp.extractedData?.lineItems && vp.extractedData.lineItems.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1, pl: 1, borderLeft: 2, borderColor: 'divider' }}>
|
||||||
|
{vp.extractedData.lineItems.map((li, i) => (
|
||||||
|
<Box key={i} sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||||
|
{li.description}
|
||||||
|
</Typography>
|
||||||
|
{li.total != null && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
${li.total.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
web/src/lib/api/admin.ts
Normal file
67
web/src/lib/api/admin.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const res = await apiClient.get('/admin/dashboard');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<void> => {
|
||||||
|
await apiClient.put(`/proposals/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
approveProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/approve`);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/send`);
|
||||||
|
},
|
||||||
|
|
||||||
|
reviseProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/revise`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHistory: async (id: string): Promise<unknown[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/history`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAudit: async (id: string): Promise<AuditEntry[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/audit`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSuggestions: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/generate-suggestions`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSimilar: async (id: string): Promise<unknown[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/similar`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
63
web/src/lib/api/lineItems.ts
Normal file
63
web/src/lib/api/lineItems.ts
Normal file
|
|
@ -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<LineItem[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (proposalId: string, data: CreateLineItemRequest): Promise<LineItem> => {
|
||||||
|
const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => {
|
||||||
|
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (proposalId: string, itemId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
266
web/src/pages/admin/dashboard/AdminDashboard.tsx
Normal file
266
web/src/pages/admin/dashboard/AdminDashboard.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success'> = {
|
||||||
|
Draft: 'default',
|
||||||
|
InReview: 'info',
|
||||||
|
Approved: 'success',
|
||||||
|
Sent: 'success',
|
||||||
|
Revised: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
|
||||||
|
<Box sx={{ color, display: 'flex' }}>{icon}</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>{value}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||||||
|
queryKey: [QUERY_KEYS.dashboard],
|
||||||
|
queryFn: adminApi.getDashboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
err,
|
||||||
|
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3 }}>Admin Dashboard</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<PendingActionsIcon fontSize="large" />}
|
||||||
|
label="Pending Review"
|
||||||
|
value={String(stats?.pendingCount ?? 0)}
|
||||||
|
color="#1565c0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<CheckCircleIcon fontSize="large" />}
|
||||||
|
label="Approved This Week"
|
||||||
|
value={String(stats?.approvedThisWeek ?? 0)}
|
||||||
|
color="#2e7d32"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<TimerIcon fontSize="large" />}
|
||||||
|
label="Avg Turnaround"
|
||||||
|
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
|
||||||
|
color="#f57c00"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<DescriptionIcon fontSize="large" />}
|
||||||
|
label="Total Proposals"
|
||||||
|
value={String(stats?.totalProposals ?? 0)}
|
||||||
|
color="#5e35b1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{ width: 240 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value=""
|
||||||
|
sx={{ width: 160 }}
|
||||||
|
onChange={() => {}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{SERVICE_CATEGORIES.map((c) => (
|
||||||
|
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Priority"
|
||||||
|
value=""
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
onChange={() => {}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<MenuItem key={p} value={p}>{p}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<Typography color="error" sx={{ mb: 2 }}>{err}</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<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>
|
||||||
|
<TableCell>Submitted By</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 9 }).map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: 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" color={PRIORITY_COLORS[row.priority] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||||
|
</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={9} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography color="text.secondary">No proposals in queue</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page - 1}
|
||||||
|
onPageChange={(_, newPage) => setPage(newPage + 1)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
407
web/src/pages/admin/workspace/AdminWorkspace.tsx
Normal file
407
web/src/pages/admin/workspace/AdminWorkspace.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success'> = {
|
||||||
|
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<EditableLineItem[]>([]);
|
||||||
|
const [refinedScope, setRefinedScope] = useState('');
|
||||||
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
|
||||||
|
queryKey: [QUERY_KEYS.proposals, id],
|
||||||
|
queryFn: () => proposalsApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fetchedLineItems } = useQuery<LineItem[]>({
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposal) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography color="error">Proposal not found</Typography>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<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" />
|
||||||
|
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Proposal Info Bar */}
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Customer</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">WO #</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Category</Typography>
|
||||||
|
<Typography variant="body2">{proposal.serviceCategory}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Priority</Typography>
|
||||||
|
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<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 */}
|
||||||
|
<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</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 }} />
|
||||||
|
<VendorDataPanel proposalId={id!} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Center Panel: Editor */}
|
||||||
|
<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"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LineItemEditor
|
||||||
|
items={lineItems}
|
||||||
|
onChange={handleLineItemsChange}
|
||||||
|
disabled={!isEditable}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Right Panel: Similar Proposals */}
|
||||||
|
<Grid size={{ xs: 12, md: 3 }}>
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<SimilarProposalsPanel
|
||||||
|
proposalId={id!}
|
||||||
|
onPullLineItem={(item) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{dirty && (
|
||||||
|
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
|
||||||
|
)}
|
||||||
|
{!dirty && <Box sx={{ flexGrow: 1 }} />}
|
||||||
|
|
||||||
|
{isEditable && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => regenerateMutation.mutate()}
|
||||||
|
disabled={regenerateMutation.isPending}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isApproved && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PictureAsPdfIcon />}
|
||||||
|
onClick={() => toast.info('PDF generation will be available in Phase 5')}
|
||||||
|
>
|
||||||
|
Generate PDF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={<SendIcon />}
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
Mark as Sent
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSent && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<HistoryIcon />}
|
||||||
|
onClick={() => reviseMutation.mutate()}
|
||||||
|
disabled={reviseMutation.isPending}
|
||||||
|
>
|
||||||
|
Create Revision
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Approve Confirmation Dialog */}
|
||||||
|
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
|
||||||
|
<DialogTitle>Approve Proposal</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography gutterBottom>
|
||||||
|
Approve <strong>{proposal.proposalNumber}</strong> for {proposal.customerName}?
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2">Line Items: {lineItems.length}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)}
|
||||||
|
</Typography>
|
||||||
|
{proposal.vendorTotalCost != null && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setApproveDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
onClick={() => approveMutation.mutate()}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue