proposal-system/web/src/components/admin/LineItemEditor.tsx

250 lines
8.7 KiB
TypeScript
Raw Normal View History

Implement Phases 2-5: Frontend, AI/RAG, PDF Generation (#22) * Fix NuGet versions and add InitialCreate EF Core migration - Update AWSSDK.SQS and AWSSDK.SecretsManager to 3.7.500.0 (actual available versions) - Update AWSSDK.Extensions.NETCore.Setup to 3.7.400 - Generate InitialCreate migration for PostgreSQL (all 8 entities) - Build verified: 0 errors, 0 warnings * Implement Dispatcher Frontend (Phase 2) React 19 + MUI v7 + TypeScript + Vite SPA matching SHOC patterns: Redux Toolkit (auth/ui slices), TanStack React Query, axios interceptors, react-toastify, Cognito OAuth PKCE login flow, paginated proposal list, new proposal form with customer autocomplete and vendor PDF upload, read-only proposal detail with status stepper timeline. * Add AuthController for Cognito code exchange and .env.example Backend endpoint POST /api/auth/callback exchanges the OAuth authorization code with Cognito's token endpoint, auto-provisions the user in the DB, and returns the access token to the frontend. * 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. * Implement backend dev mode, internal API auth, and service layer enhancements - Add dev-login endpoint with local JWT signing for local development - Add InternalApiKeyMiddleware with timing-safe comparison for Lambda-to-API auth - Add DevS3Service and NoOpJobPublisher for running without AWS services - Implement CurrentUserService cascading user resolution (ID → sub → email → create) - Add async ResolveAsync() to avoid synchronous DB calls in request pipeline - Add /proposals/stats endpoint for efficient server-side status counts - Guard status transitions: only allow Draft → InReview via update endpoint - Add vendor proposals, generated PDFs, and similar proposals controllers - Add ISimilarProposalService and SimilarProposalService - Add [Authorize] to AddSimilarReference endpoint * Implement Lambda functions for PDF processing, suggestions, and library ingest - pdf-extract: Parse vendor PDFs with pdfplumber, fallback to Claude multimodal - pdf-generate: Generate branded proposal PDFs with reportlab Platypus - library-ingest: Format approved proposals as markdown and sync to Bedrock KB - suggestions: Query KB for similar proposals, generate line items via Claude - All Lambdas use internal API key auth and cold-start secret caching - Fix pdf_path unbound variable in pdf-extract error handling * Add Bedrock Knowledge Base, OpenSearch Serverless, and SQS message filtering - Provision OpenSearch Serverless collection for vector search - Create Bedrock Knowledge Base with Titan embedding model - Configure S3 data source with fixed-size chunking (512 tokens, 20% overlap) - Add suggestions Lambda with SQS event source filtering - Scope bedrock:InvokeModel IAM to specific model ARN patterns - Add internal API key secret in Secrets Manager - Add log retention (2 months) to all Lambda functions - Add docker-compose.yml for local PostgreSQL * Apply SHOC design system styling across frontend - Rewrite theme with SHOC palette (#0c4f6f primary, Nunito font, 4px radius) - Add global CSS with Google Fonts import for Nunito - Redesign Topbar with avatar initials, role subtitle, gradient header - Redesign Sidebar with 220px width, section headers, active state border - Restyle LoginPage with SHOC branded card and dev-mode role selector - Update AdminDashboard KPI cards to centered SHOC style - Add devLogin API method for local development auth flow * Fix frontend navigation bugs, differentiate Dashboard from Proposals list - Fix double nav selection by adding isNavActive() with ALL_NAV_PATHS set - Fix /admin/users routing to placeholder instead of redirect to / - Fix ProposalDetailPage Back button navigating to / instead of /proposals - Differentiate Dashboard (KPI cards + recent 5) from ProposalListPage (full paginated table) - Dashboard now uses dedicated /proposals/stats endpoint for accurate counts - Fix adminApi.getPdf dead code (axios rejects before status check) - Wire up PDF generation button in AdminWorkspace - Adjust layout: 220px drawer, 10px content padding, 64px toolbar height * Add appsettings.Development.json to gitignore Prevent dev-only signing keys and connection strings from being committed. * Fix CI failures: unused Python imports and CDK synth asset path CDK synth job needs the .NET API published first so the Lambda asset path exists. Python lint had 3 unused imports in pdf-generate. * Apply ruff formatting to all Lambda Python files
2026-05-17 13:06:23 -04:00
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>
);
}