mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 20:02:00 +00:00
* 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
249 lines
8.7 KiB
TypeScript
249 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|