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

136 lines
4.5 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 { 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>
);
}