From ceefae2850e021948447064b7f8ceb8cdbb28529 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Sun, 17 May 2026 13:06:23 -0400 Subject: [PATCH] Implement Phases 2-5: Frontend, AI/RAG, PDF Generation (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/workflows/ci.yaml | 12 +- .gitignore | 1 + .../Controllers/AuthController.cs | 195 + .../Controllers/FilesController.cs | 24 + .../Controllers/GeneratedPdfsController.cs | 44 + .../Controllers/ProposalsController.cs | 33 +- .../Controllers/VendorProposalsController.cs | 75 + .../Middleware/InternalApiKeyMiddleware.cs | 41 + api/src/ProposalSystem.Api/Program.cs | 86 +- .../Properties/launchSettings.json | 13 + .../ProposalSystem.Api.csproj | 2 +- .../Services/CurrentUserService.cs | 104 +- .../Services/DevServices.cs | 32 + api/src/ProposalSystem.Api/appsettings.json | 4 +- .../DTOs/FileDtos.cs | 9 + .../DTOs/ProposalDtos.cs | 10 +- .../DTOs/SimilarProposalDtos.cs | 23 + .../Interfaces/ICurrentUserService.cs | 1 + .../Interfaces/IProposalService.cs | 1 + .../Interfaces/ISimilarProposalService.cs | 9 + .../20260516225740_InitialCreate.Designer.cs | 504 +++ .../20260516225740_InitialCreate.cs | 353 ++ .../ProposalDbContextModelSnapshot.cs | 501 +++ .../ProposalSystem.Infrastructure.csproj | 4 +- .../Services/ProposalService.cs | 19 +- .../Services/SimilarProposalService.cs | 78 + docker-compose.yml | 14 + infra/lib/compute-stack.ts | 186 +- lambdas/library-ingest/app.py | 188 +- lambdas/library-ingest/batch_ingest.py | 411 +++ lambdas/library-ingest/requirements.txt | 1 + lambdas/pdf-extract/app.py | 324 +- lambdas/pdf-extract/requirements.txt | 1 + lambdas/pdf-generate/app.py | 532 ++- lambdas/pdf-generate/requirements.txt | 1 + lambdas/suggestions/app.py | 315 ++ lambdas/suggestions/requirements.txt | 2 + web/.env.example | 6 + web/package-lock.json | 3121 +++++++++++++++++ web/package.json | 16 +- web/src/App.tsx | 67 + web/src/app/slices/authSlice.ts | 87 + web/src/app/slices/uiSlice.ts | 29 + web/src/app/store.ts | 19 + web/src/components/ProtectedRoute.tsx | 12 + web/src/components/Sidebar.tsx | 154 + web/src/components/Topbar.tsx | 58 + web/src/components/admin/LineItemEditor.tsx | 249 ++ .../admin/SimilarProposalsPanel.tsx | 135 + web/src/components/admin/VendorDataPanel.tsx | 90 + web/src/constants/index.ts | 21 + web/src/constants/queryKeys.ts | 9 + web/src/hooks/useAuth.ts | 35 + web/src/hooks/usePaginatedList.ts | 68 + web/src/index.css | 27 + web/src/lib/api/admin.ts | 76 + web/src/lib/api/auth.ts | 33 + web/src/lib/api/client.ts | 62 + web/src/lib/api/customers.ts | 21 + web/src/lib/api/lineItems.ts | 63 + web/src/lib/api/proposals.ts | 112 + web/src/lib/queryClient.ts | 19 + web/src/main.tsx | 27 +- .../pages/admin/dashboard/AdminDashboard.tsx | 268 ++ .../pages/admin/workspace/AdminWorkspace.tsx | 423 +++ web/src/pages/auth/AuthCallback.tsx | 61 + web/src/pages/auth/LoginPage.tsx | 169 + web/src/pages/dashboard/Dashboard.tsx | 188 + .../proposals/detail/ProposalDetailPage.tsx | 178 + .../pages/proposals/form/ProposalFormPage.tsx | 274 ++ .../pages/proposals/list/ProposalListPage.tsx | 168 + web/src/theme.ts | 164 + web/src/vite-env.d.ts | 1 + web/vite.config.ts | 6 + 74 files changed, 10611 insertions(+), 58 deletions(-) create mode 100644 api/src/ProposalSystem.Api/Controllers/AuthController.cs create mode 100644 api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs create mode 100644 api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs create mode 100644 api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs create mode 100644 api/src/ProposalSystem.Api/Properties/launchSettings.json create mode 100644 api/src/ProposalSystem.Api/Services/DevServices.cs create mode 100644 api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs create mode 100644 api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs create mode 100644 docker-compose.yml create mode 100644 lambdas/library-ingest/batch_ingest.py create mode 100644 lambdas/suggestions/app.py create mode 100644 lambdas/suggestions/requirements.txt create mode 100644 web/.env.example create mode 100644 web/package-lock.json create mode 100644 web/src/App.tsx create mode 100644 web/src/app/slices/authSlice.ts create mode 100644 web/src/app/slices/uiSlice.ts create mode 100644 web/src/app/store.ts create mode 100644 web/src/components/ProtectedRoute.tsx create mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/Topbar.tsx 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/constants/index.ts create mode 100644 web/src/constants/queryKeys.ts create mode 100644 web/src/hooks/useAuth.ts create mode 100644 web/src/hooks/usePaginatedList.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/api/admin.ts create mode 100644 web/src/lib/api/auth.ts create mode 100644 web/src/lib/api/client.ts create mode 100644 web/src/lib/api/customers.ts create mode 100644 web/src/lib/api/lineItems.ts create mode 100644 web/src/lib/api/proposals.ts create mode 100644 web/src/lib/queryClient.ts create mode 100644 web/src/pages/admin/dashboard/AdminDashboard.tsx create mode 100644 web/src/pages/admin/workspace/AdminWorkspace.tsx create mode 100644 web/src/pages/auth/AuthCallback.tsx create mode 100644 web/src/pages/auth/LoginPage.tsx create mode 100644 web/src/pages/dashboard/Dashboard.tsx create mode 100644 web/src/pages/proposals/detail/ProposalDetailPage.tsx create mode 100644 web/src/pages/proposals/form/ProposalFormPage.tsx create mode 100644 web/src/pages/proposals/list/ProposalListPage.tsx create mode 100644 web/src/theme.ts create mode 100644 web/src/vite-env.d.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 63a1e52..aa18247 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,12 +60,16 @@ jobs: infra: name: CDK Synth runs-on: ubuntu-latest - defaults: - run: - working-directory: infra steps: - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Publish .NET API (required for CDK asset path) + run: dotnet publish api/src/ProposalSystem.Api/ProposalSystem.Api.csproj --configuration Release --runtime linux-arm64 --self-contained + - uses: actions/setup-node@v4 with: node-version: 24 @@ -73,4 +77,6 @@ jobs: cache-dependency-path: infra/package-lock.json - run: npm ci + working-directory: infra - run: npx cdk synth + working-directory: infra diff --git a/.gitignore b/.gitignore index 45fc845..e801a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ cdk.out/ .env .env.* !.env.example +appsettings.Development.json # IDE .idea/ diff --git a/api/src/ProposalSystem.Api/Controllers/AuthController.cs b/api/src/ProposalSystem.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..8bbf8cb --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/AuthController.cs @@ -0,0 +1,195 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly ProposalDbContext _db; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _config; + + public AuthController(ProposalDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config) + { + _db = db; + _httpClientFactory = httpClientFactory; + _config = config; + } + + [HttpPost("callback")] + public async Task> Callback([FromBody] AuthCallbackRequest request, CancellationToken ct) + { + var domain = _config["Auth:CognitoDomain"]; + var clientId = _config["Auth:ClientId"]; + + if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(clientId)) + return StatusCode(500, new { message = "Auth not configured" }); + + var tokenResponse = await ExchangeCodeAsync(domain, clientId, request.Code, request.RedirectUri, ct); + if (tokenResponse == null) + return BadRequest(new { message = "Failed to exchange authorization code" }); + + var handler = new JwtSecurityTokenHandler(); + var idToken = handler.ReadJwtToken(tokenResponse.IdToken); + + var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value + ?? throw new InvalidOperationException("No sub claim in ID token"); + var email = idToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value ?? ""; + var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value + ?? idToken.Claims.FirstOrDefault(c => c.Type == "cognito:username")?.Value + ?? email.Split('@')[0]; + var groups = idToken.Claims.Where(c => c.Type == "cognito:groups").Select(c => c.Value).ToList(); + + var role = groups.Contains("sysadmins") ? UserRole.SysAdmin + : groups.Contains("admins") ? UserRole.Admin + : UserRole.Dispatcher; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.CognitoSub == sub, ct); + if (user == null) + { + user = new User + { + Id = Guid.NewGuid(), + CognitoSub = sub, + Email = email, + DisplayName = name, + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.Users.Add(user); + await _db.SaveChangesAsync(ct); + } + else if (user.Email != email || user.DisplayName != name) + { + user.Email = email; + user.DisplayName = name; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + return Ok(new AuthResponse( + user.Id.ToString(), + user.Email, + user.DisplayName, + user.Role.ToString(), + tokenResponse.AccessToken + )); + } + + [HttpPost("dev-login")] + public async Task> DevLogin([FromBody] DevLoginRequest request, CancellationToken ct) + { + var devMode = _config.GetValue("Auth:DevMode"); + if (!devMode) + return NotFound(); + + var signingKey = _config["Auth:DevSigningKey"]; + if (string.IsNullOrEmpty(signingKey)) + return StatusCode(500, new { message = "Dev signing key not configured" }); + + var role = Enum.TryParse(request.Role, true, out var parsed) ? parsed : UserRole.Admin; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, ct); + if (user == null) + { + user = new User + { + Id = Guid.NewGuid(), + CognitoSub = $"dev-{Guid.NewGuid():N}", + Email = request.Email, + DisplayName = request.DisplayName ?? request.Email.Split('@')[0], + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + _db.Users.Add(user); + await _db.SaveChangesAsync(ct); + } + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new("sub", user.CognitoSub), + new("email", user.Email), + new("name", user.DisplayName), + new("cognito:groups", role.ToString().ToLower() + "s"), + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: "proposal-system-dev", + audience: "proposal-system-dev", + claims: claims, + expires: DateTime.UtcNow.AddHours(12), + signingCredentials: creds); + + var tokenString = new JwtSecurityTokenHandler().WriteToken(token); + + return Ok(new AuthResponse( + user.Id.ToString(), + user.Email, + user.DisplayName, + user.Role.ToString(), + tokenString + )); + } + + private async Task ExchangeCodeAsync( + string domain, string clientId, string code, string redirectUri, CancellationToken ct) + { + var client = _httpClientFactory.CreateClient(); + var tokenUrl = $"https://{domain}/oauth2/token"; + + var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = clientId, + ["code"] = code, + ["redirect_uri"] = redirectUri, + }); + + var response = await client.PostAsync(tokenUrl, content, ct); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(json); + } +} + +public record AuthCallbackRequest(string Code, string RedirectUri); + +public record DevLoginRequest(string Email, string? DisplayName, string? Role); + +public record AuthResponse(string Id, string Email, string DisplayName, string Role, string Token); + +internal class CognitoTokenResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("id_token")] + public string IdToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } +} diff --git a/api/src/ProposalSystem.Api/Controllers/FilesController.cs b/api/src/ProposalSystem.Api/Controllers/FilesController.cs index 71a34b5..8026dd0 100644 --- a/api/src/ProposalSystem.Api/Controllers/FilesController.cs +++ b/api/src/ProposalSystem.Api/Controllers/FilesController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -111,4 +112,27 @@ public class FilesController : ControllerBase return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60))); } + + [HttpGet("vendors")] + [Authorize(Roles = "admins,sysadmins")] + public async Task>> GetVendors( + Guid proposalId, + CancellationToken ct) + { + var entities = await _db.VendorProposals + .Where(v => v.ProposalId == proposalId) + .OrderByDescending(v => v.UploadedAt) + .ToListAsync(ct); + + var vendors = entities.Select(v => new VendorProposalResponse( + v.Id, + v.VendorName, + v.FileName, + v.TotalVendorCost, + v.ProcessingStatus.ToString(), + string.IsNullOrEmpty(v.ExtractedData) ? null : JsonSerializer.Deserialize(v.ExtractedData) + )).ToList(); + + return Ok(vendors); + } } diff --git a/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs b/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs new file mode 100644 index 0000000..9677b6d --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/GeneratedPdfsController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/generated-pdfs")] +[Authorize] +public class GeneratedPdfsController : ControllerBase +{ + private readonly ProposalDbContext _db; + + public GeneratedPdfsController(ProposalDbContext db) + { + _db = db; + } + + [HttpPost] + public async Task Create([FromBody] CreateGeneratedPdfRequest request, CancellationToken ct) + { + var proposal = await _db.Proposals.FindAsync(new object[] { request.ProposalId }, ct); + if (proposal == null) return NotFound(); + + var pdf = new GeneratedPdf + { + Id = Guid.NewGuid(), + ProposalId = request.ProposalId, + Revision = proposal.CurrentRevision, + S3Key = request.S3Key, + GeneratedAt = DateTime.UtcNow, + GeneratedById = Guid.Empty, + }; + + _db.GeneratedPdfs.Add(pdf); + await _db.SaveChangesAsync(ct); + + return Created($"/api/generated-pdfs/{pdf.Id}", new { pdf.Id, pdf.S3Key, pdf.Revision }); + } +} + +public record CreateGeneratedPdfRequest(Guid ProposalId, string S3Key); diff --git a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs index cf0b455..899cbd9 100644 --- a/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs +++ b/api/src/ProposalSystem.Api/Controllers/ProposalsController.cs @@ -12,11 +12,16 @@ public class ProposalsController : ControllerBase { private readonly IProposalService _proposalService; private readonly IJobPublisher _jobPublisher; + private readonly ISimilarProposalService _similarService; - public ProposalsController(IProposalService proposalService, IJobPublisher jobPublisher) + public ProposalsController( + IProposalService proposalService, + IJobPublisher jobPublisher, + ISimilarProposalService similarService) { _proposalService = proposalService; _jobPublisher = jobPublisher; + _similarService = similarService; } [HttpPost] @@ -95,6 +100,14 @@ public class ProposalsController : ControllerBase return Ok(result); } + [HttpGet("{id:guid}/similar")] + [Authorize(Roles = "admins,sysadmins")] + public async Task>> GetSimilar(Guid id, CancellationToken ct) + { + var result = await _similarService.GetSimilarProposalsAsync(id, ct); + return Ok(result); + } + [HttpPost("{id:guid}/generate-suggestions")] [Authorize(Roles = "admins,sysadmins")] public async Task GenerateSuggestions(Guid id, CancellationToken ct) @@ -110,4 +123,22 @@ public class ProposalsController : ControllerBase await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct); return Accepted(); } + + [HttpGet("stats")] + public async Task> GetStats(CancellationToken ct) + { + var stats = await _proposalService.GetStatsAsync(ct); + return Ok(stats); + } + + [HttpPost("{id:guid}/similar-references")] + [Authorize(Roles = "admins,sysadmins")] + public async Task AddSimilarReference( + Guid id, + [FromBody] CreateSimilarReferenceRequest request, + CancellationToken ct) + { + await _similarService.AddReferenceAsync(id, request, ct); + return Created(); + } } diff --git a/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs b/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs new file mode 100644 index 0000000..f311a99 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/VendorProposalsController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/vendor-proposals")] +[Authorize] +public class VendorProposalsController : ControllerBase +{ + private readonly ProposalDbContext _db; + + public VendorProposalsController(ProposalDbContext db) + { + _db = db; + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateVendorProposalRequest request, CancellationToken ct) + { + var vendor = await _db.VendorProposals.FindAsync(new object[] { id }, ct); + if (vendor == null) return NotFound(); + + if (request.VendorName != null) + vendor.VendorName = request.VendorName; + + if (request.ExtractedData != null) + vendor.ExtractedData = request.ExtractedData; + + if (request.TotalVendorCost.HasValue) + vendor.TotalVendorCost = request.TotalVendorCost.Value; + + if (request.ProcessingStatus != null && Enum.TryParse(request.ProcessingStatus, out var status)) + vendor.ProcessingStatus = status; + + await _db.SaveChangesAsync(ct); + + if (vendor.TotalVendorCost > 0) + { + var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct); + if (proposal != null) + { + proposal.VendorTotalCost = vendor.TotalVendorCost; + await _db.SaveChangesAsync(ct); + } + } + + return NoContent(); + } + + [HttpPut("{id:guid}/status")] + public async Task UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request, CancellationToken ct) + { + var vendor = await _db.VendorProposals.FindAsync(new object[] { id }, ct); + if (vendor == null) return NotFound(); + + if (Enum.TryParse(request.ProcessingStatus, out var status)) + vendor.ProcessingStatus = status; + + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} + +public record UpdateVendorProposalRequest( + string? VendorName, + string? ExtractedData, + decimal? TotalVendorCost, + string? ProcessingStatus +); + +public record UpdateStatusRequest(string ProcessingStatus); diff --git a/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs new file mode 100644 index 0000000..a88c3e7 --- /dev/null +++ b/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace ProposalSystem.Api.Middleware; + +public class InternalApiKeyMiddleware +{ + private readonly RequestDelegate _next; + private readonly byte[] _apiKeyBytes; + + public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + var key = configuration["INTERNAL_API_KEY"] ?? ""; + _apiKeyBytes = Encoding.UTF8.GetBytes(key); + } + + public async Task InvokeAsync(HttpContext context) + { + if (_apiKeyBytes.Length > 0 && + context.Request.Headers.TryGetValue("X-Internal-Api-Key", out var providedKey) && + !string.IsNullOrEmpty(providedKey.ToString())) + { + var providedBytes = Encoding.UTF8.GetBytes(providedKey.ToString()); + if (CryptographicOperations.FixedTimeEquals(providedBytes, _apiKeyBytes)) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "system"), + new Claim(ClaimTypes.Role, "admins"), + new Claim("cognito:groups", "admins"), + }; + var identity = new ClaimsIdentity(claims, "InternalApiKey"); + context.User = new ClaimsPrincipal(identity); + } + } + + await _next(context); + } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index 02ffc5c..b3ac383 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text; using Amazon.S3; using Amazon.SecretsManager; using Amazon.SQS; @@ -14,11 +15,17 @@ using ProposalSystem.Infrastructure.Services; var builder = WebApplication.CreateBuilder(args); -// AWS SDK clients -builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); -builder.Services.AddAWSService(); -builder.Services.AddAWSService(); -builder.Services.AddAWSService(); +// Dev mode flag (read early for conditional setup) +var devMode = builder.Configuration.GetValue("Auth:DevMode"); + +// AWS SDK clients (skip in dev mode — no real AWS credentials needed) +if (!devMode) +{ + builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); + builder.Services.AddAWSService(); + builder.Services.AddAWSService(); + builder.Services.AddAWSService(); +} // Database var dbSecretArn = builder.Configuration["DB_SECRET_ARN"]; @@ -35,8 +42,21 @@ else options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); } +// Internal API key (for Lambda-to-API calls) +var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"]; +if (!string.IsNullOrEmpty(internalApiKeySecretArn)) +{ + var smClient = new AmazonSecretsManagerClient(); + var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest + { + SecretId = internalApiKeySecretArn, + }).GetAwaiter().GetResult(); + builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString; +} + // Authentication var cognitoAuthority = builder.Configuration["Auth:Authority"]; + if (!string.IsNullOrEmpty(cognitoAuthority)) { builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -49,6 +69,26 @@ if (!string.IsNullOrEmpty(cognitoAuthority)) ValidateIssuer = true, ValidateAudience = false, ValidateLifetime = true, + RoleClaimType = "cognito:groups", + }; + }); +} +else if (devMode) +{ + var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!; + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)), + ValidateIssuer = true, + ValidIssuer = "proposal-system-dev", + ValidateAudience = true, + ValidAudience = "proposal-system-dev", + ValidateLifetime = true, + RoleClaimType = "cognito:groups", }; }); } @@ -68,13 +108,27 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(sp => +if (devMode) + builder.Services.AddScoped(); +else + builder.Services.AddScoped(); +builder.Services.AddScoped(); +var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; +if (string.IsNullOrEmpty(jobsQueueUrl)) { - var sqsClient = sp.GetRequiredService(); - var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; - return new SqsJobPublisher(sqsClient, queueUrl); -}); + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddScoped(sp => + { + var sqsClient = sp.GetRequiredService(); + return new SqsJobPublisher(sqsClient, jobsQueueUrl); + }); +} + +// HTTP client for Cognito token exchange +builder.Services.AddHttpClient(); // Validation builder.Services.AddValidatorsFromAssemblyContaining(); @@ -112,8 +166,18 @@ var app = builder.Build(); app.UseMiddleware(); app.UseCors(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); +app.Use(async (context, next) => +{ + if (context.User.Identity?.IsAuthenticated == true) + { + var userService = context.RequestServices.GetRequiredService(); + await userService.ResolveAsync(); + } + await next(); +}); app.MapControllers(); app.MapHealthChecks("/api/health"); diff --git a/api/src/ProposalSystem.Api/Properties/launchSettings.json b/api/src/ProposalSystem.Api/Properties/launchSettings.json new file mode 100644 index 0000000..df85fca --- /dev/null +++ b/api/src/ProposalSystem.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj index 257f631..1f960d6 100644 --- a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj +++ b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj @@ -17,7 +17,7 @@ - + diff --git a/api/src/ProposalSystem.Api/Services/CurrentUserService.cs b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs index 3d1e752..2da3c9d 100644 --- a/api/src/ProposalSystem.Api/Services/CurrentUserService.cs +++ b/api/src/ProposalSystem.Api/Services/CurrentUserService.cs @@ -25,28 +25,46 @@ public class CurrentUserService : ICurrentUserService public string? IpAddress => _httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString(); - private User GetUser() + public async Task ResolveAsync() { - if (_cachedUser != null) return _cachedUser; + if (_cachedUser != null) return; - var cognitoSub = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? _httpContext.HttpContext?.User.FindFirstValue("sub") + var principal = _httpContext.HttpContext?.User ?? throw new UnauthorizedAccessException("No authenticated user"); - _cachedUser = _db.Users - .FirstOrDefault(u => u.CognitoSub == cognitoSub); + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var sub = principal.FindFirstValue("sub"); + + if (userId != null && Guid.TryParse(userId, out var parsedId)) + { + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == parsedId); + } + + if (_cachedUser == null && sub != null) + { + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.CognitoSub == sub); + } if (_cachedUser == null) { - var email = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.Email) - ?? _httpContext.HttpContext?.User.FindFirstValue("email") + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") ?? "unknown@seahaven.com"; - var name = _httpContext.HttpContext?.User.FindFirstValue("name") + _cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + var name = principal.FindFirstValue("name") ?? email.Split('@')[0]; - var groups = _httpContext.HttpContext?.User.FindAll("cognito:groups") - .Select(c => c.Value).ToList() ?? new List(); + var groups = principal.FindAll("cognito:groups") + .Select(c => c.Value).ToList(); var role = groups.Contains("sysadmins") ? UserRole.SysAdmin : groups.Contains("admins") ? UserRole.Admin @@ -55,7 +73,69 @@ public class CurrentUserService : ICurrentUserService _cachedUser = new User { Id = Guid.NewGuid(), - CognitoSub = cognitoSub, + CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}", + Email = email, + DisplayName = name, + Role = role, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + _db.Users.Add(_cachedUser); + await _db.SaveChangesAsync(); + } + } + + private User GetUser() + { + if (_cachedUser != null) return _cachedUser; + + var principal = _httpContext.HttpContext?.User + ?? throw new UnauthorizedAccessException("No authenticated user"); + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var sub = principal.FindFirstValue("sub"); + + if (userId != null && Guid.TryParse(userId, out var parsedId)) + { + _cachedUser = _db.Users.FirstOrDefault(u => u.Id == parsedId); + } + + if (_cachedUser == null && sub != null) + { + _cachedUser = _db.Users.FirstOrDefault(u => u.CognitoSub == sub); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + _cachedUser = _db.Users.FirstOrDefault(u => u.Email == email); + } + + if (_cachedUser == null) + { + var email = principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? "unknown@seahaven.com"; + + var name = principal.FindFirstValue("name") + ?? email.Split('@')[0]; + + var groups = principal.FindAll("cognito:groups") + .Select(c => c.Value).ToList(); + + var role = groups.Contains("sysadmins") ? UserRole.SysAdmin + : groups.Contains("admins") ? UserRole.Admin + : UserRole.Dispatcher; + + _cachedUser = new User + { + Id = Guid.NewGuid(), + CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}", Email = email, DisplayName = name, Role = role, diff --git a/api/src/ProposalSystem.Api/Services/DevServices.cs b/api/src/ProposalSystem.Api/Services/DevServices.cs new file mode 100644 index 0000000..e366062 --- /dev/null +++ b/api/src/ProposalSystem.Api/Services/DevServices.cs @@ -0,0 +1,32 @@ +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Services; + +public class NoOpJobPublisher : IJobPublisher +{ + private readonly ILogger _logger; + + public NoOpJobPublisher(ILogger logger) + { + _logger = logger; + } + + public Task PublishAsync(string jobType, object payload, CancellationToken ct = default) + { + _logger.LogInformation("Dev mode - skipping job publish: {JobType}", jobType); + return Task.CompletedTask; + } +} + +public class DevS3Service : IS3Service +{ + public Task GeneratePresignedUploadUrlAsync(string bucket, string key, string contentType, int expirationMinutes = 15) + { + return Task.FromResult($"http://localhost:5000/dev-null/upload?bucket={bucket}&key={key}"); + } + + public Task GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60) + { + return Task.FromResult($"http://localhost:5000/dev-null/download?bucket={bucket}&key={key}"); + } +} diff --git a/api/src/ProposalSystem.Api/appsettings.json b/api/src/ProposalSystem.Api/appsettings.json index 38ef245..16c567b 100644 --- a/api/src/ProposalSystem.Api/appsettings.json +++ b/api/src/ProposalSystem.Api/appsettings.json @@ -7,7 +7,9 @@ }, "Auth": { "Authority": "", - "Audience": "" + "Audience": "", + "CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com", + "ClientId": "" }, "ConnectionStrings": { "DefaultConnection": "" diff --git a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs index c0dfa7a..ea7e393 100644 --- a/api/src/ProposalSystem.Application/DTOs/FileDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/FileDtos.cs @@ -10,3 +10,12 @@ public record PdfDownloadResponse( string DownloadUrl, DateTime ExpiresAt ); + +public record VendorProposalResponse( + Guid Id, + string VendorName, + string FileName, + decimal TotalVendorCost, + string ProcessingStatus, + object? ExtractedData +); diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs index f1d0a20..27f1aa6 100644 --- a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -15,7 +15,8 @@ public record CreateProposalRequest( public record UpdateProposalRequest( string? RefinedScope, string? Notes, - Guid? AssignedAdminId + Guid? AssignedAdminId, + ProposalStatus? Status ); public record ProposalResponse( @@ -76,3 +77,10 @@ public record PagedResponse( int Page, int PageSize ); + +public record ProposalStatsResponse( + int TotalCount, + int InReviewCount, + int ApprovedCount, + int SentCount +); diff --git a/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs new file mode 100644 index 0000000..49f1e42 --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/SimilarProposalDtos.cs @@ -0,0 +1,23 @@ +namespace ProposalSystem.Application.DTOs; + +public record SimilarProposalResponse( + string ProposalNumber, + string CustomerName, + string ServiceCategory, + float SimilarityScore, + decimal TotalBidAmount, + IReadOnlyList LineItems +); + +public record SimilarLineItemResponse( + string Description, + decimal Quantity, + string Unit, + decimal? UnitPrice, + decimal TotalPrice +); + +public record CreateSimilarReferenceRequest( + string ReferencedLibraryItemId, + float SimilarityScore +); diff --git a/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs index d71827d..df78444 100644 --- a/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/ICurrentUserService.cs @@ -8,4 +8,5 @@ public interface ICurrentUserService string Email { get; } UserRole Role { get; } string? IpAddress { get; } + Task ResolveAsync(); } diff --git a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs index 411ef18..47f9b00 100644 --- a/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs +++ b/api/src/ProposalSystem.Application/Interfaces/IProposalService.cs @@ -13,4 +13,5 @@ public interface IProposalService Task ReviseAsync(Guid id, CancellationToken ct = default); Task> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default); Task> GetAuditTrailAsync(Guid id, CancellationToken ct = default); + Task GetStatsAsync(CancellationToken ct = default); } diff --git a/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs b/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs new file mode 100644 index 0000000..79d9737 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ISimilarProposalService.cs @@ -0,0 +1,9 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface ISimilarProposalService +{ + Task> GetSimilarProposalsAsync(Guid proposalId, CancellationToken ct = default); + Task AddReferenceAsync(Guid proposalId, CreateSimilarReferenceRequest request, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs new file mode 100644 index 0000000..3c62091 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs @@ -0,0 +1,504 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProposalSystem.Infrastructure.Data; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ProposalDbContext))] + [Migration("20260516225740_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.cs new file mode 100644 index 0000000..5dcefad --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.cs @@ -0,0 +1,353 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Addresses = table.Column(type: "jsonb", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CognitoSub = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + Role = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Proposals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalNumber = table.Column(type: "text", nullable: false), + WorkOrderNumber = table.Column(type: "text", nullable: false), + CustomerName = table.Column(type: "text", nullable: false), + CustomerAddress = table.Column(type: "text", nullable: false), + ScopeOfWork = table.Column(type: "text", nullable: false), + RefinedScope = table.Column(type: "text", nullable: true), + ServiceCategory = table.Column(type: "text", nullable: false), + Priority = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + TotalBidAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + VendorTotalCost = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + Notes = table.Column(type: "text", nullable: false), + SubmittedById = table.Column(type: "uuid", nullable: false), + SubmittedAt = table.Column(type: "timestamp with time zone", nullable: false), + AssignedAdminId = table.Column(type: "uuid", nullable: true), + ApprovedById = table.Column(type: "uuid", nullable: true), + ApprovedAt = table.Column(type: "timestamp with time zone", nullable: true), + SentAt = table.Column(type: "timestamp with time zone", nullable: true), + CurrentRevision = table.Column(type: "integer", nullable: false), + ParentProposalId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Proposals", x => x.Id); + table.ForeignKey( + name: "FK_Proposals_Proposals_ParentProposalId", + column: x => x.ParentProposalId, + principalTable: "Proposals", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Proposals_Users_ApprovedById", + column: x => x.ApprovedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Proposals_Users_AssignedAdminId", + column: x => x.AssignedAdminId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Proposals_Users_SubmittedById", + column: x => x.SubmittedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: true), + UserId = table.Column(type: "uuid", nullable: false), + Action = table.Column(type: "text", nullable: false), + Details = table.Column(type: "jsonb", nullable: true), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + IpAddress = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_AuditLogs_Proposals_ProposalId", + column: x => x.ProposalId, + principalTable: "Proposals", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_AuditLogs_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GeneratedPdfs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: false), + Revision = table.Column(type: "integer", nullable: false), + S3Key = table.Column(type: "text", nullable: false), + GeneratedAt = table.Column(type: "timestamp with time zone", nullable: false), + GeneratedById = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GeneratedPdfs", x => x.Id); + table.ForeignKey( + name: "FK_GeneratedPdfs_Proposals_ProposalId", + column: x => x.ProposalId, + principalTable: "Proposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GeneratedPdfs_Users_GeneratedById", + column: x => x.GeneratedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LineItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: false), + Description = table.Column(type: "text", nullable: false), + Quantity = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Unit = table.Column(type: "text", nullable: false), + UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + TotalPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + PricingMode = table.Column(type: "text", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + Source = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LineItems", x => x.Id); + table.ForeignKey( + name: "FK_LineItems_Proposals_ProposalId", + column: x => x.ProposalId, + principalTable: "Proposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SimilarProposalReferences", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: false), + ReferencedLibraryItemId = table.Column(type: "text", nullable: false), + SimilarityScore = table.Column(type: "real", nullable: false), + ReferencedAt = table.Column(type: "timestamp with time zone", nullable: false), + ReferencedById = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SimilarProposalReferences", x => x.Id); + table.ForeignKey( + name: "FK_SimilarProposalReferences_Proposals_ProposalId", + column: x => x.ProposalId, + principalTable: "Proposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SimilarProposalReferences_Users_ReferencedById", + column: x => x.ReferencedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "VendorProposals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ProposalId = table.Column(type: "uuid", nullable: false), + VendorName = table.Column(type: "text", nullable: false), + FileName = table.Column(type: "text", nullable: false), + S3Key = table.Column(type: "text", nullable: false), + ExtractedData = table.Column(type: "jsonb", nullable: true), + TotalVendorCost = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + UploadedAt = table.Column(type: "timestamp with time zone", nullable: false), + ProcessingStatus = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorProposals", x => x.Id); + table.ForeignKey( + name: "FK_VendorProposals_Proposals_ProposalId", + column: x => x.ProposalId, + principalTable: "Proposals", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_ProposalId", + table: "AuditLogs", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Timestamp", + table: "AuditLogs", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_UserId", + table: "AuditLogs", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_GeneratedPdfs_GeneratedById", + table: "GeneratedPdfs", + column: "GeneratedById"); + + migrationBuilder.CreateIndex( + name: "IX_GeneratedPdfs_ProposalId", + table: "GeneratedPdfs", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_LineItems_ProposalId", + table: "LineItems", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_Proposals_ApprovedById", + table: "Proposals", + column: "ApprovedById"); + + migrationBuilder.CreateIndex( + name: "IX_Proposals_AssignedAdminId", + table: "Proposals", + column: "AssignedAdminId"); + + migrationBuilder.CreateIndex( + name: "IX_Proposals_ParentProposalId", + table: "Proposals", + column: "ParentProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_Proposals_ProposalNumber", + table: "Proposals", + column: "ProposalNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Proposals_SubmittedById", + table: "Proposals", + column: "SubmittedById"); + + migrationBuilder.CreateIndex( + name: "IX_SimilarProposalReferences_ProposalId", + table: "SimilarProposalReferences", + column: "ProposalId"); + + migrationBuilder.CreateIndex( + name: "IX_SimilarProposalReferences_ReferencedById", + table: "SimilarProposalReferences", + column: "ReferencedById"); + + migrationBuilder.CreateIndex( + name: "IX_Users_CognitoSub", + table: "Users", + column: "CognitoSub", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VendorProposals_ProposalId", + table: "VendorProposals", + column: "ProposalId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "GeneratedPdfs"); + + migrationBuilder.DropTable( + name: "LineItems"); + + migrationBuilder.DropTable( + name: "SimilarProposalReferences"); + + migrationBuilder.DropTable( + name: "VendorProposals"); + + migrationBuilder.DropTable( + name: "Proposals"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs new file mode 100644 index 0000000..3d20496 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs @@ -0,0 +1,501 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProposalSystem.Infrastructure.Data; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ProposalDbContext))] + partial class ProposalDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj index 5b7e787..d2cd721 100644 --- a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj +++ b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index 2679f7e..19e19bd 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -56,7 +56,7 @@ public class ProposalService : IProposalService await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct); - await _jobPublisher.PublishAsync("pdf-extract", new { proposalId = proposal.Id }, ct); + await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct); return MapToResponse(proposal); } @@ -147,6 +147,10 @@ public class ProposalService : IProposalService if (request.AssignedAdminId.HasValue) proposal.AssignedAdminId = request.AssignedAdminId.Value; + if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft + && request.Status.Value == ProposalStatus.InReview) + proposal.Status = request.Status.Value; + proposal.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); @@ -296,6 +300,19 @@ public class ProposalService : IProposalService .ToListAsync(ct); } + public async Task GetStatsAsync(CancellationToken ct = default) + { + var userId = _currentUser.UserId; + var baseQuery = _db.Proposals.Where(p => p.SubmittedById == userId); + + var total = await baseQuery.CountAsync(ct); + var inReview = await baseQuery.CountAsync(p => p.Status == ProposalStatus.InReview, ct); + var approved = await baseQuery.CountAsync(p => p.Status == ProposalStatus.Approved, ct); + var sent = await baseQuery.CountAsync(p => p.Status == ProposalStatus.Sent, ct); + + return new ProposalStatsResponse(total, inReview, approved, sent); + } + private static ProposalResponse MapToResponse(Proposal p) => new( p.Id, p.ProposalNumber, diff --git a/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs new file mode 100644 index 0000000..71b8a9e --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SimilarProposalService.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; +using ProposalSystem.Domain.Entities; +using ProposalSystem.Infrastructure.Data; + +namespace ProposalSystem.Infrastructure.Services; + +public class SimilarProposalService : ISimilarProposalService +{ + private readonly ProposalDbContext _db; + + public SimilarProposalService(ProposalDbContext db) + { + _db = db; + } + + public async Task> GetSimilarProposalsAsync( + Guid proposalId, + CancellationToken ct = default) + { + var references = await _db.SimilarProposalReferences + .Where(r => r.ProposalId == proposalId) + .OrderByDescending(r => r.SimilarityScore) + .Take(5) + .ToListAsync(ct); + + var results = new List(); + + foreach (var reference in references) + { + var referencedProposal = await _db.Proposals + .Include(p => p.LineItems) + .FirstOrDefaultAsync(p => p.ProposalNumber == reference.ReferencedLibraryItemId, ct); + + if (referencedProposal == null) continue; + + results.Add(new SimilarProposalResponse( + referencedProposal.ProposalNumber, + referencedProposal.CustomerName, + referencedProposal.ServiceCategory.ToString(), + reference.SimilarityScore, + referencedProposal.TotalBidAmount, + referencedProposal.LineItems + .OrderBy(li => li.SortOrder) + .Select(li => new SimilarLineItemResponse( + li.Description, + li.Quantity, + li.Unit, + li.UnitPrice, + li.TotalPrice + )) + .ToList() + )); + } + + return results; + } + + public async Task AddReferenceAsync( + Guid proposalId, + CreateSimilarReferenceRequest request, + CancellationToken ct = default) + { + var reference = new SimilarProposalReference + { + Id = Guid.NewGuid(), + ProposalId = proposalId, + ReferencedLibraryItemId = request.ReferencedLibraryItemId, + SimilarityScore = request.SimilarityScore, + ReferencedAt = DateTime.UtcNow, + ReferencedById = Guid.Empty, + }; + + _db.SimilarProposalReferences.Add(reference); + await _db.SaveChangesAsync(ct); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5031858 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: proposalsystem + POSTGRES_USER: postgres + POSTGRES_PASSWORD: localdev + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts index c5e0553..9ff5d48 100644 --- a/infra/lib/compute-stack.ts +++ b/infra/lib/compute-stack.ts @@ -9,6 +9,9 @@ import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; +import * as bedrock from 'aws-cdk-lib/aws-bedrock'; +import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless'; +import * as logs from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; export interface ComputeStackProps extends cdk.StackProps { @@ -28,6 +31,123 @@ export class ComputeStack extends cdk.Stack { const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; + // Internal API key for Lambda-to-API calls (stored in Secrets Manager) + const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', { + secretName: 'proposal-system/internal-api-key', + generateSecretString: { + excludePunctuation: true, + passwordLength: 48, + }, + }); + + // OpenSearch Serverless collection for Bedrock KB vector store + const ossEncryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssEncryptionPolicy', { + name: 'proposal-system-kb-enc', + type: 'encryption', + policy: JSON.stringify({ + Rules: [{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }], + AWSOwnedKey: true, + }), + }); + + const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', { + name: 'proposal-system-kb-net', + type: 'network', + policy: JSON.stringify([{ + Rules: [ + { ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }, + { ResourceType: 'dashboard', Resource: ['collection/proposal-system-kb'] }, + ], + AllowFromPublic: true, + }]), + }); + + const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', { + name: 'proposal-system-kb', + type: 'VECTORSEARCH', + }); + ossCollection.addDependency(ossEncryptionPolicy); + ossCollection.addDependency(ossNetworkPolicy); + + // Bedrock KB execution role + const kbRole = new iam.Role(this, 'KnowledgeBaseRole', { + roleName: 'proposal-system-kb-role', + assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'), + }); + + kbRole.addToPolicy(new iam.PolicyStatement({ + actions: ['s3:GetObject', 's3:ListBucket'], + resources: [props.libraryBucket.bucketArn, `${props.libraryBucket.bucketArn}/*`], + })); + + kbRole.addToPolicy(new iam.PolicyStatement({ + actions: ['aoss:APIAccessAll'], + resources: [ossCollection.attrArn], + })); + + kbRole.addToPolicy(new iam.PolicyStatement({ + actions: ['bedrock:InvokeModel'], + resources: [`arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`], + })); + + // OpenSearch Serverless data access policy + new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', { + name: 'proposal-system-kb-access', + type: 'data', + policy: JSON.stringify([{ + Rules: [ + { ResourceType: 'collection', Resource: ['collection/proposal-system-kb'], Permission: ['aoss:*'] }, + { ResourceType: 'index', Resource: ['index/proposal-system-kb/*'], Permission: ['aoss:*'] }, + ], + Principal: [kbRole.roleArn, `arn:aws:iam::${this.account}:root`], + }]), + }); + + // Bedrock Knowledge Base + const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', { + name: 'proposal-system-kb', + roleArn: kbRole.roleArn, + knowledgeBaseConfiguration: { + type: 'VECTOR', + vectorKnowledgeBaseConfiguration: { + embeddingModelArn: `arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`, + }, + }, + storageConfiguration: { + type: 'OPENSEARCH_SERVERLESS', + opensearchServerlessConfiguration: { + collectionArn: ossCollection.attrArn, + vectorIndexName: 'proposal-system-index', + fieldMapping: { + vectorField: 'embedding', + textField: 'text', + metadataField: 'metadata', + }, + }, + }, + }); + + // KB Data Source (S3 library bucket) + const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', { + name: 'proposal-system-library', + knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId, + dataSourceConfiguration: { + type: 'S3', + s3Configuration: { + bucketArn: props.libraryBucket.bucketArn, + }, + }, + vectorIngestionConfiguration: { + chunkingConfiguration: { + chunkingStrategy: 'FIXED_SIZE', + fixedSizeChunkingConfiguration: { + maxTokens: 512, + overlapPercentage: 20, + }, + }, + }, + }); + // .NET 8 API Lambda const apiFunction = new lambda.Function(this, 'ApiFunction', { functionName: 'proposal-system-api', @@ -47,12 +167,15 @@ export class ComputeStack extends cdk.Stack { GENERATED_BUCKET: props.generatedBucket.bucketName, LIBRARY_BUCKET: props.libraryBucket.bucketName, JOBS_QUEUE_URL: props.jobsQueue.queueUrl, + INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, tracing: lambda.Tracing.ACTIVE, + logRetention: logs.RetentionDays.TWO_MONTHS, }); // API Lambda permissions props.dbSecret.grantRead(apiFunction); + internalApiKeySecret.grantRead(apiFunction); props.uploadsBucket.grantReadWrite(apiFunction); props.generatedBucket.grantRead(apiFunction); props.jobsQueue.grantSendMessages(apiFunction); @@ -93,6 +216,37 @@ export class ComputeStack extends cdk.Stack { integration: apiIntegration, }); + // Python Lambda: Suggestions Engine + const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', { + functionName: 'proposal-system-suggestions', + runtime: lambda.Runtime.PYTHON_3_12, + architecture: lambda.Architecture.ARM_64, + handler: 'app.handler', + code: lambda.Code.fromAsset('../lambdas/suggestions'), + memorySize: 512, + timeout: cdk.Duration.seconds(60), + vpc: props.vpc, + vpcSubnets: privateSubnets, + securityGroups: [props.lambdaSecurityGroup], + environment: { + KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, + MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + API_BASE_URL: httpApi.apiEndpoint, + INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, + }, + logRetention: logs.RetentionDays.TWO_MONTHS, + }); + + internalApiKeySecret.grantRead(suggestionsFunction); + suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:InvokeModel'], + resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`], + })); + suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:Retrieve'], + resources: [knowledgeBase.attrKnowledgeBaseArn], + })); + // Python Lambda: PDF Extract const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', { functionName: 'proposal-system-pdf-extract', @@ -107,14 +261,18 @@ export class ComputeStack extends cdk.Stack { securityGroups: [props.lambdaSecurityGroup], environment: { UPLOADS_BUCKET: props.uploadsBucket.bucketName, + MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', API_BASE_URL: httpApi.apiEndpoint, + INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, + logRetention: logs.RetentionDays.TWO_MONTHS, }); + internalApiKeySecret.grantRead(pdfExtractFunction); props.uploadsBucket.grantRead(pdfExtractFunction); pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:InvokeModel'], - resources: ['*'], + resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`], })); // Python Lambda: PDF Generate @@ -132,9 +290,12 @@ export class ComputeStack extends cdk.Stack { environment: { GENERATED_BUCKET: props.generatedBucket.bucketName, API_BASE_URL: httpApi.apiEndpoint, + INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, + logRetention: logs.RetentionDays.TWO_MONTHS, }); + internalApiKeySecret.grantRead(pdfGenerateFunction); props.generatedBucket.grantWrite(pdfGenerateFunction); // Python Lambda: Library Ingest @@ -151,20 +312,31 @@ export class ComputeStack extends cdk.Stack { securityGroups: [props.lambdaSecurityGroup], environment: { LIBRARY_BUCKET: props.libraryBucket.bucketName, + KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId, + DATA_SOURCE_ID: dataSource.attrDataSourceId, API_BASE_URL: httpApi.apiEndpoint, + INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, }, + logRetention: logs.RetentionDays.TWO_MONTHS, }); + internalApiKeySecret.grantRead(libraryIngestFunction); props.libraryBucket.grantWrite(libraryIngestFunction); libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({ actions: ['bedrock:StartIngestionJob'], - resources: ['*'], + resources: [knowledgeBase.attrKnowledgeBaseArn], + })); + + // SQS Event Sources with message filtering + suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { + batchSize: 1, + filters: [ + lambda.FilterCriteria.filter({ + body: { jobType: lambda.FilterRule.isEqual('suggestions') }, + }), + ], })); - // SQS Event Source for Python Lambdas - // All three consume from the same queue, routed by message attributes - // For now, use a single consumer that routes internally - // TODO: Phase 4 will refine this to use message filtering or separate queues per function pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, { batchSize: 1, filters: [ @@ -195,5 +367,7 @@ export class ComputeStack extends cdk.Stack { // Outputs new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint }); new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn }); + new cdk.CfnOutput(this, 'KnowledgeBaseId', { value: knowledgeBase.attrKnowledgeBaseId }); + new cdk.CfnOutput(this, 'DataSourceId', { value: dataSource.attrDataSourceId }); } } diff --git a/lambdas/library-ingest/app.py b/lambdas/library-ingest/app.py index 6c4eaee..7e795f2 100644 --- a/lambdas/library-ingest/app.py +++ b/lambdas/library-ingest/app.py @@ -1,14 +1,196 @@ """Proposal System - Library Ingest Lambda. -Processes approved proposals into the Bedrock Knowledge Base. -Implementation in Phase 4. +Processes approved/sent proposals into the Bedrock Knowledge Base library. +Formats proposal data as structured markdown and uploads to the library bucket, +then triggers a KB sync. """ import json +import os +from datetime import datetime + +import boto3 +import httpx + +LIBRARY_BUCKET = os.environ.get("LIBRARY_BUCKET", "") +KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "") +DATA_SOURCE_ID = os.environ.get("DATA_SOURCE_ID", "") +API_BASE_URL = os.environ.get("API_BASE_URL", "") +INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "") + +s3 = boto3.client("s3") +bedrock_agent = boto3.client("bedrock-agent") +secrets_client = boto3.client("secretsmanager") + +_cached_api_key: str | None = None + + +def _get_api_key() -> str: + global _cached_api_key + if _cached_api_key is None: + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + return _cached_api_key def handler(event, context): for record in event.get("Records", []): body = json.loads(record["body"]) - print(f"Processing library-ingest job: {body}") + payload = body.get("payload", body) + proposal_id = payload["proposalId"] + process_ingestion(proposal_id) return {"statusCode": 200} + + +def process_ingestion(proposal_id: str): + proposal = fetch_proposal(proposal_id) + if not proposal: + print(f"Proposal {proposal_id} not found") + return + + line_items = fetch_line_items(proposal_id) + + document = format_proposal_document(proposal, line_items) + + s3_key = upload_to_library(proposal, document) + + if s3_key: + trigger_kb_sync() + + +def fetch_proposal(proposal_id: str) -> dict | None: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching proposal: {e}") + return None + + +def fetch_line_items(proposal_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching line items: {e}") + return [] + + +def format_proposal_document(proposal: dict, line_items: list[dict]) -> str: + total = sum(li.get("totalPrice", 0) for li in line_items) + submitted_at = proposal.get("submittedAt", "") + if submitted_at: + try: + dt = datetime.fromisoformat(submitted_at.replace("Z", "+00:00")) + submitted_at = dt.strftime("%Y-%m-%d") + except (ValueError, TypeError): + pass + + lines = [ + f"# Proposal: {proposal['proposalNumber']}", + "", + f"**Customer:** {proposal['customerName']}", + f"**Address:** {proposal.get('customerAddress', '')}", + f"**Service Category:** {proposal['serviceCategory']}", + f"**Priority:** {proposal['priority']}", + f"**Date:** {submitted_at}", + f"**Total Bid Amount:** ${total:,.2f}", + f"**Work Order:** {proposal.get('workOrderNumber', '')}", + "", + "## Scope of Work", + "", + proposal.get("refinedScope") or proposal.get("scopeOfWork", ""), + "", + "## Line Items", + "", + "| # | Description | Qty | Unit | Unit Price | Total |", + "|---|---|---|---|---|---|", + ] + + for i, li in enumerate(line_items, 1): + desc = li.get("description", "") + qty = li.get("quantity", "") + unit = li.get("unit", "") + unit_price = li.get("unitPrice") + total_price = li.get("totalPrice", 0) + + up_str = f"${unit_price:,.2f}" if unit_price else "-" + tp_str = f"${total_price:,.2f}" + + lines.append(f"| {i} | {desc} | {qty} | {unit} | {up_str} | {tp_str} |") + + lines.extend( + [ + "", + f"**Total: ${total:,.2f}**", + ] + ) + + return "\n".join(lines) + + +def upload_to_library(proposal: dict, document: str) -> str | None: + if not LIBRARY_BUCKET: + print("No library bucket configured") + return None + + proposal_number = proposal["proposalNumber"] + category = proposal.get("serviceCategory", "General") + s3_key = f"proposals/{category.lower()}/{proposal_number}.md" + + try: + s3.put_object( + Bucket=LIBRARY_BUCKET, + Key=s3_key, + Body=document.encode("utf-8"), + ContentType="text/markdown", + Metadata={ + "service-category": category, + "proposal-number": proposal_number, + "customer-name": proposal.get("customerName", ""), + "total-amount": str(proposal.get("totalBidAmount", 0)), + "date-submitted": proposal.get("submittedAt", ""), + }, + ) + print(f"Uploaded {s3_key} to library bucket") + return s3_key + except Exception as e: + print(f"Error uploading to library: {e}") + return None + + +def trigger_kb_sync(): + if not KNOWLEDGE_BASE_ID or not DATA_SOURCE_ID: + print("KB or data source ID not configured, skipping sync") + return + + try: + response = bedrock_agent.start_ingestion_job( + knowledgeBaseId=KNOWLEDGE_BASE_ID, + dataSourceId=DATA_SOURCE_ID, + ) + job_id = response.get("ingestionJob", {}).get("ingestionJobId", "") + print(f"Started KB ingestion job: {job_id}") + except Exception as e: + print(f"Error triggering KB sync: {e}") + + +def _api_headers() -> dict: + headers = {"Content-Type": "application/json"} + api_key = _get_api_key() + if api_key: + headers["X-Internal-Api-Key"] = api_key + return headers diff --git a/lambdas/library-ingest/batch_ingest.py b/lambdas/library-ingest/batch_ingest.py new file mode 100644 index 0000000..b1dc6ba --- /dev/null +++ b/lambdas/library-ingest/batch_ingest.py @@ -0,0 +1,411 @@ +"""Batch ingestion CLI for bootstrapping the Bedrock Knowledge Base. + +Processes historical proposal PDFs from a local directory, extracts structured +data using pdfplumber + Claude, formats as markdown documents, and uploads to +the proposal-system-library S3 bucket. Triggers a KB sync after all uploads. + +Usage: + python batch_ingest.py --input-dir ./historical-pdfs --bucket proposal-system-library-328440206208 --kb-id --ds-id + +Prerequisites: + pip install pdfplumber boto3 rich + AWS credentials configured with access to S3, Bedrock, and the KB. +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +import boto3 +import pdfplumber + +try: + from rich.console import Console + from rich.progress import ( + Progress, + SpinnerColumn, + TextColumn, + BarColumn, + TaskProgressColumn, + ) + + console = Console() + HAS_RICH = True +except ImportError: + HAS_RICH = False + +s3 = boto3.client("s3") +bedrock_runtime = boto3.client("bedrock-runtime") +bedrock_agent = boto3.client("bedrock-agent") + +MODEL_ID = "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + +SERVICE_CATEGORIES = ["HVAC", "Plumbing", "Electrical", "General", "Renovation"] + + +def main(): + parser = argparse.ArgumentParser( + description="Batch ingest historical proposals into Bedrock KB" + ) + parser.add_argument( + "--input-dir", + required=True, + help="Directory containing historical proposal PDFs", + ) + parser.add_argument("--bucket", required=True, help="S3 library bucket name") + parser.add_argument("--kb-id", required=True, help="Bedrock Knowledge Base ID") + parser.add_argument("--ds-id", required=True, help="Bedrock KB Data Source ID") + parser.add_argument( + "--dry-run", action="store_true", help="Parse and extract only, don't upload" + ) + args = parser.parse_args() + + input_dir = Path(args.input_dir) + if not input_dir.exists(): + print(f"Error: {input_dir} does not exist") + sys.exit(1) + + pdf_files = sorted(input_dir.glob("*.pdf")) + if not pdf_files: + print(f"No PDF files found in {input_dir}") + sys.exit(1) + + print(f"Found {len(pdf_files)} PDF files to process") + + results = {"success": 0, "failed": 0, "skipped": 0} + + if HAS_RICH: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=console, + ) as progress: + task = progress.add_task("Processing PDFs...", total=len(pdf_files)) + for pdf_path in pdf_files: + status = process_pdf(pdf_path, args.bucket, args.dry_run) + results[status] += 1 + progress.update( + task, + advance=1, + description=f"[{'green' if status == 'success' else 'red'}]{pdf_path.name}", + ) + else: + for i, pdf_path in enumerate(pdf_files, 1): + print(f"[{i}/{len(pdf_files)}] Processing {pdf_path.name}...", end=" ") + status = process_pdf(pdf_path, args.bucket, args.dry_run) + results[status] += 1 + print(f"[{status.upper()}]") + + print( + f"\nResults: {results['success']} succeeded, {results['failed']} failed, {results['skipped']} skipped" + ) + + if not args.dry_run and results["success"] > 0: + print("\nTriggering Knowledge Base sync...") + trigger_kb_sync(args.kb_id, args.ds_id) + print( + "Done. KB ingestion job started — check AWS console for completion status." + ) + + +def process_pdf(pdf_path: Path, bucket: str, dry_run: bool) -> str: + try: + extracted = extract_proposal_data(pdf_path) + + if not extracted.get("lineItems"): + extracted = extract_with_claude(pdf_path) + + if not extracted.get("lineItems"): + print(f" Warning: No line items extracted from {pdf_path.name}") + return "skipped" + + document = format_as_markdown(extracted, pdf_path.name) + + if dry_run: + print(f"\n--- {pdf_path.name} ---") + print(f" Customer: {extracted.get('customerName', 'Unknown')}") + print(f" Category: {extracted.get('serviceCategory', 'General')}") + print(f" Line items: {len(extracted.get('lineItems', []))}") + print(f" Total: ${extracted.get('totalAmount', 0):,.2f}") + return "success" + + s3_key = upload_to_library(bucket, extracted, document) + if s3_key: + return "success" + return "failed" + + except Exception as e: + print(f" Error processing {pdf_path.name}: {e}") + return "failed" + + +def extract_proposal_data(pdf_path: Path) -> dict: + result = { + "customerName": "", + "serviceCategory": "General", + "lineItems": [], + "totalAmount": 0.0, + "scopeOfWork": "", + "date": "", + "proposalNumber": "", + } + + with pdfplumber.open(str(pdf_path)) as pdf: + all_text = "" + all_tables = [] + + for page in pdf.pages: + text = page.extract_text() or "" + all_text += text + "\n" + tables = page.extract_tables() + all_tables.extend(tables) + + result["scopeOfWork"] = all_text[:2000] + + if all_tables: + result["lineItems"] = parse_tables(all_tables) + result["totalAmount"] = sum( + li.get("totalPrice", 0) for li in result["lineItems"] + ) + + return result + + +def extract_with_claude(pdf_path: Path) -> dict: + import base64 + + with open(pdf_path, "rb") as f: + pdf_b64 = base64.standard_b64encode(f.read()).decode("utf-8") + + try: + response = bedrock_runtime.invoke_model( + modelId=MODEL_ID, + contentType="application/json", + accept="application/json", + body=json.dumps( + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": pdf_b64, + }, + }, + { + "type": "text", + "text": f"""Extract structured data from this proposal PDF. +Return a JSON object with: +- customerName: the customer/client name +- serviceCategory: one of {SERVICE_CATEGORIES} +- proposalNumber: the proposal/quote number if visible +- date: the date on the proposal (YYYY-MM-DD format) +- scopeOfWork: brief description of the work (1-2 sentences) +- lineItems: array of objects with: description (string), quantity (number), unit (string), unitPrice (number or null), totalPrice (number) +- totalAmount: the grand total + +Respond ONLY with the JSON object.""", + }, + ], + } + ], + "temperature": 0.1, + } + ), + ) + + response_body = json.loads(response["body"].read()) + content = response_body["content"][0]["text"].strip() + if content.startswith("```"): + content = content.split("\n", 1)[1] + content = content.rsplit("```", 1)[0] + + return json.loads(content) + + except Exception as e: + print(f" Claude extraction failed: {e}") + return {} + + +def parse_tables(tables: list) -> list[dict]: + line_items = [] + + for table in tables: + if not table or len(table) < 2: + continue + + header = [str(cell).lower().strip() if cell else "" for cell in table[0]] + + desc_col = _find_column( + header, ["description", "item", "service", "work", "scope"] + ) + qty_col = _find_column(header, ["qty", "quantity", "count"]) + price_col = _find_column( + header, ["unit price", "rate", "price/unit", "unit cost"] + ) + total_col = _find_column( + header, ["total", "amount", "ext", "extended", "line total"] + ) + + if desc_col is None: + continue + + for row in table[1:]: + if not row or len(row) <= desc_col: + continue + + description = str(row[desc_col]).strip() if row[desc_col] else "" + if not description or description.lower() in ( + "", + "total", + "subtotal", + "grand total", + ): + continue + + quantity = ( + _parse_number(row[qty_col]) + if qty_col is not None and qty_col < len(row) + else None + ) + unit_price = ( + _parse_number(row[price_col]) + if price_col is not None and price_col < len(row) + else None + ) + total = ( + _parse_number(row[total_col]) + if total_col is not None and total_col < len(row) + else None + ) + + if total is None and quantity and unit_price: + total = quantity * unit_price + + if description and (total or unit_price): + line_items.append( + { + "description": description, + "quantity": quantity or 1, + "unit": "each", + "unitPrice": unit_price, + "totalPrice": total or 0, + } + ) + + return line_items + + +def _find_column(header: list[str], keywords: list[str]) -> int | None: + for i, col in enumerate(header): + for kw in keywords: + if kw in col: + return i + return None + + +def _parse_number(value) -> float | None: + if value is None: + return None + try: + cleaned = str(value).replace("$", "").replace(",", "").strip() + if not cleaned or cleaned == "-": + return None + return float(cleaned) + except (ValueError, TypeError): + return None + + +def format_as_markdown(extracted: dict, source_filename: str) -> str: + customer = extracted.get("customerName", "Unknown Customer") + category = extracted.get("serviceCategory", "General") + proposal_num = extracted.get("proposalNumber", source_filename.replace(".pdf", "")) + date = extracted.get("date", "") + scope = extracted.get("scopeOfWork", "") + total = extracted.get("totalAmount", 0) + line_items = extracted.get("lineItems", []) + + lines = [ + f"# Proposal: {proposal_num}", + "", + f"**Customer:** {customer}", + f"**Service Category:** {category}", + f"**Date:** {date}", + f"**Total Bid Amount:** ${total:,.2f}", + f"**Source File:** {source_filename}", + "", + "## Scope of Work", + "", + scope[:1000] if scope else "Not specified", + "", + "## Line Items", + "", + "| # | Description | Qty | Unit | Unit Price | Total |", + "|---|---|---|---|---|---|", + ] + + for i, li in enumerate(line_items, 1): + desc = li.get("description", "") + qty = li.get("quantity", "") + unit = li.get("unit", "each") + up = li.get("unitPrice") + tp = li.get("totalPrice", 0) + + up_str = f"${up:,.2f}" if up else "-" + tp_str = f"${tp:,.2f}" + lines.append(f"| {i} | {desc} | {qty} | {unit} | {up_str} | {tp_str} |") + + lines.extend(["", f"**Total: ${total:,.2f}**"]) + return "\n".join(lines) + + +def upload_to_library(bucket: str, extracted: dict, document: str) -> str | None: + category = extracted.get("serviceCategory", "General") + proposal_num = extracted.get("proposalNumber", f"historical-{os.urandom(4).hex()}") + s3_key = f"proposals/{category.lower()}/{proposal_num}.md" + + try: + s3.put_object( + Bucket=bucket, + Key=s3_key, + Body=document.encode("utf-8"), + ContentType="text/markdown", + Metadata={ + "service-category": category, + "proposal-number": proposal_num, + "customer-name": extracted.get("customerName", ""), + "total-amount": str(extracted.get("totalAmount", 0)), + "date-submitted": extracted.get("date", ""), + "source": "batch-ingest", + }, + ) + return s3_key + except Exception as e: + print(f" Upload failed: {e}") + return None + + +def trigger_kb_sync(kb_id: str, ds_id: str): + try: + response = bedrock_agent.start_ingestion_job( + knowledgeBaseId=kb_id, + dataSourceId=ds_id, + ) + job_id = response.get("ingestionJob", {}).get("ingestionJobId", "") + print(f" Ingestion job started: {job_id}") + except Exception as e: + print(f" Error triggering KB sync: {e}") + + +if __name__ == "__main__": + main() diff --git a/lambdas/library-ingest/requirements.txt b/lambdas/library-ingest/requirements.txt index 97c4921..996956a 100644 --- a/lambdas/library-ingest/requirements.txt +++ b/lambdas/library-ingest/requirements.txt @@ -1 +1,2 @@ boto3>=1.35.0,<2.0 +httpx>=0.27.0,<1.0 diff --git a/lambdas/pdf-extract/app.py b/lambdas/pdf-extract/app.py index 42430aa..4a789eb 100644 --- a/lambdas/pdf-extract/app.py +++ b/lambdas/pdf-extract/app.py @@ -1,14 +1,334 @@ """Proposal System - PDF Extract Lambda. Parses vendor proposal PDFs and extracts structured line item data. -Implementation in Phase 4. +Falls back to Claude multimodal for scanned/image-based PDFs. """ import json +import os +import tempfile +import base64 + +import boto3 +import httpx +import pdfplumber + +UPLOADS_BUCKET = os.environ.get("UPLOADS_BUCKET", "") +API_BASE_URL = os.environ.get("API_BASE_URL", "") +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0") +INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "") + +s3 = boto3.client("s3") +bedrock_runtime = boto3.client("bedrock-runtime") +secrets_client = boto3.client("secretsmanager") + +_cached_api_key: str | None = None + + +def _get_api_key() -> str: + global _cached_api_key + if _cached_api_key is None: + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + return _cached_api_key def handler(event, context): for record in event.get("Records", []): body = json.loads(record["body"]) - print(f"Processing pdf-extract job: {body}") + payload = body.get("payload", body) + proposal_id = payload["proposalId"] + s3_key = payload.get("s3Key", "") + vendor_proposal_id = payload.get("vendorProposalId", "") + + if not s3_key: + print(f"No s3Key in payload for proposal {proposal_id}") + continue + + process_pdf(proposal_id, s3_key, vendor_proposal_id) return {"statusCode": 200} + + +def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str): + update_processing_status(vendor_proposal_id, "Processing") + + pdf_path = None + try: + pdf_path = download_pdf(s3_key) + extracted = extract_with_pdfplumber(pdf_path) + + if not extracted["lineItems"] and extracted["rawText"].strip(): + extracted = extract_with_claude_multimodal(pdf_path) + + if not extracted["lineItems"] and not extracted["rawText"].strip(): + extracted = extract_with_claude_multimodal(pdf_path) + + save_extraction(vendor_proposal_id, extracted) + + except Exception as e: + print(f"Error processing PDF: {e}") + update_processing_status(vendor_proposal_id, "Failed") + finally: + if pdf_path: + try: + os.unlink(pdf_path) + except Exception: + pass + + +def download_pdf(s3_key: str) -> str: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") + s3.download_file(UPLOADS_BUCKET, s3_key, tmp.name) + tmp.close() + return tmp.name + + +def extract_with_pdfplumber(pdf_path: str) -> dict: + result = { + "vendorName": "", + "lineItems": [], + "rawText": "", + "totalVendorCost": 0.0, + } + + try: + with pdfplumber.open(pdf_path) as pdf: + all_text = "" + all_tables = [] + + for page in pdf.pages: + text = page.extract_text() or "" + all_text += text + "\n" + + tables = page.extract_tables() + for table in tables: + all_tables.append(table) + + result["rawText"] = all_text.strip() + + if all_tables: + result["lineItems"] = parse_tables(all_tables) + result["totalVendorCost"] = sum( + li.get("total", 0) for li in result["lineItems"] + ) + + if not result["vendorName"] and all_text: + lines = all_text.split("\n") + for line in lines[:5]: + stripped = line.strip() + if stripped and len(stripped) > 3 and not stripped[0].isdigit(): + result["vendorName"] = stripped + break + + except Exception as e: + print(f"pdfplumber extraction failed: {e}") + + return result + + +def parse_tables(tables: list) -> list[dict]: + line_items = [] + + for table in tables: + if not table or len(table) < 2: + continue + + header = [str(cell).lower().strip() if cell else "" for cell in table[0]] + + desc_col = find_column( + header, ["description", "item", "service", "work", "scope"] + ) + qty_col = find_column(header, ["qty", "quantity", "count"]) + price_col = find_column( + header, ["unit price", "rate", "price/unit", "unit cost"] + ) + total_col = find_column( + header, ["total", "amount", "ext", "extended", "line total"] + ) + + if desc_col is None: + continue + + for row in table[1:]: + if not row or len(row) <= desc_col: + continue + + description = str(row[desc_col]).strip() if row[desc_col] else "" + if not description or description.lower() in ( + "", + "total", + "subtotal", + "grand total", + ): + continue + + quantity = ( + parse_number(row[qty_col]) + if qty_col is not None and qty_col < len(row) + else None + ) + unit_price = ( + parse_number(row[price_col]) + if price_col is not None and price_col < len(row) + else None + ) + total = ( + parse_number(row[total_col]) + if total_col is not None and total_col < len(row) + else None + ) + + if total is None and quantity and unit_price: + total = quantity * unit_price + + if description and (total or unit_price): + line_items.append( + { + "description": description, + "quantity": quantity, + "unitPrice": unit_price, + "total": total, + } + ) + + return line_items + + +def find_column(header: list[str], keywords: list[str]) -> int | None: + for i, col in enumerate(header): + for kw in keywords: + if kw in col: + return i + return None + + +def parse_number(value) -> float | None: + if value is None: + return None + try: + cleaned = str(value).replace("$", "").replace(",", "").strip() + if not cleaned or cleaned == "-": + return None + return float(cleaned) + except (ValueError, TypeError): + return None + + +def extract_with_claude_multimodal(pdf_path: str) -> dict: + try: + with open(pdf_path, "rb") as f: + pdf_bytes = f.read() + + pdf_b64 = base64.standard_b64encode(pdf_bytes).decode("utf-8") + + response = bedrock_runtime.invoke_model( + modelId=MODEL_ID, + contentType="application/json", + accept="application/json", + body=json.dumps( + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": pdf_b64, + }, + }, + { + "type": "text", + "text": """Extract all line items from this vendor proposal PDF. +Return a JSON object with these fields: +- vendorName: the vendor/company name +- lineItems: array of objects with: description, quantity (number or null), unitPrice (number or null), total (number or null) +- totalVendorCost: the grand total amount + +Respond ONLY with the JSON object, no additional text.""", + }, + ], + } + ], + "temperature": 0.1, + } + ), + ) + + response_body = json.loads(response["body"].read()) + content = response_body["content"][0]["text"] + + content = content.strip() + if content.startswith("```"): + content = content.split("\n", 1)[1] + content = content.rsplit("```", 1)[0] + + parsed = json.loads(content) + return { + "vendorName": parsed.get("vendorName", ""), + "lineItems": parsed.get("lineItems", []), + "rawText": "", + "totalVendorCost": float(parsed.get("totalVendorCost", 0)), + } + + except Exception as e: + print(f"Claude multimodal extraction failed: {e}") + return { + "vendorName": "", + "lineItems": [], + "rawText": "", + "totalVendorCost": 0.0, + } + + +def save_extraction(vendor_proposal_id: str, extracted: dict): + extracted_data = { + "lineItems": extracted["lineItems"], + "vendorName": extracted["vendorName"], + } + + try: + resp = httpx.put( + f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}", + json={ + "vendorName": extracted["vendorName"], + "extractedData": json.dumps(extracted_data), + "totalVendorCost": extracted["totalVendorCost"], + "processingStatus": "Complete", + }, + headers=_api_headers(), + timeout=10, + ) + if resp.status_code not in (200, 204): + print(f"Failed to save extraction: {resp.status_code} {resp.text}") + except Exception as e: + print(f"Error saving extraction: {e}") + + +def update_processing_status(vendor_proposal_id: str, status: str): + if not vendor_proposal_id: + return + try: + httpx.put( + f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status", + json={"processingStatus": status}, + headers=_api_headers(), + timeout=10, + ) + except Exception as e: + print(f"Error updating status: {e}") + + +def _api_headers() -> dict: + headers = {"Content-Type": "application/json"} + api_key = _get_api_key() + if api_key: + headers["X-Internal-Api-Key"] = api_key + return headers diff --git a/lambdas/pdf-extract/requirements.txt b/lambdas/pdf-extract/requirements.txt index 1d64c16..8545875 100644 --- a/lambdas/pdf-extract/requirements.txt +++ b/lambdas/pdf-extract/requirements.txt @@ -1,2 +1,3 @@ pdfplumber>=0.11.0,<1.0 boto3>=1.35.0,<2.0 +httpx>=0.27.0,<1.0 diff --git a/lambdas/pdf-generate/app.py b/lambdas/pdf-generate/app.py index b59ee6f..ed4ae98 100644 --- a/lambdas/pdf-generate/app.py +++ b/lambdas/pdf-generate/app.py @@ -1,14 +1,540 @@ """Proposal System - PDF Generate Lambda. -Generates professional branded proposal PDFs from approved proposals. -Implementation in Phase 5. +Generates professional branded proposal PDFs using reportlab Platypus. +Triggered via SQS when an admin requests PDF generation. """ import json +import os +from datetime import datetime +from io import BytesIO + +import boto3 +import httpx +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_RIGHT +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +GENERATED_BUCKET = os.environ.get("GENERATED_BUCKET", "") +API_BASE_URL = os.environ.get("API_BASE_URL", "") +INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "") + +s3 = boto3.client("s3") +secrets_client = boto3.client("secretsmanager") + +_cached_api_key: str | None = None + +COMPANY_NAME = "Sea Haven Industries" +COMPANY_ADDRESS = "Sea Haven Industries LLC" +COMPANY_PHONE = "" +COMPANY_EMAIL = "info@seahavenind.com" + +TERMS_AND_CONDITIONS = """ +1. This proposal is valid for 30 days from the date of issue. +2. Payment terms: Net 30 days from invoice date. +3. Any changes to the scope of work may result in additional charges. +4. Work will be scheduled upon acceptance of this proposal. +5. All materials and workmanship are guaranteed for one (1) year from completion. +6. Client is responsible for providing access to the work area. +7. This proposal does not include permits unless specifically noted in the line items. +""".strip() + + +def _get_api_key() -> str: + global _cached_api_key + if _cached_api_key is None: + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + return _cached_api_key def handler(event, context): for record in event.get("Records", []): body = json.loads(record["body"]) - print(f"Processing pdf-generate job: {body}") + payload = body.get("payload", body) + proposal_id = payload["proposalId"] + generate_pdf(proposal_id) return {"statusCode": 200} + + +def generate_pdf(proposal_id: str): + proposal = fetch_proposal(proposal_id) + if not proposal: + print(f"Proposal {proposal_id} not found") + return + + line_items = fetch_line_items(proposal_id) + + pdf_bytes = build_pdf(proposal, line_items) + + proposal_number = proposal["proposalNumber"] + revision = proposal.get("currentRevision", 1) + s3_key = f"{proposal_number}/rev-{revision}.pdf" + + upload_pdf(s3_key, pdf_bytes) + + register_pdf(proposal_id, s3_key) + + print(f"Generated PDF: {s3_key} ({len(pdf_bytes)} bytes)") + + +def fetch_proposal(proposal_id: str) -> dict | None: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching proposal: {e}") + return None + + +def fetch_line_items(proposal_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching line items: {e}") + return [] + + +def build_pdf(proposal: dict, line_items: list[dict]) -> bytes: + buffer = BytesIO() + + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + leftMargin=0.75 * inch, + rightMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, + ) + + styles = _get_styles() + elements = [] + + # Header + elements.extend(_build_header(proposal, styles)) + elements.append(Spacer(1, 0.3 * inch)) + + # Proposal metadata + elements.extend(_build_metadata(proposal, styles)) + elements.append(Spacer(1, 0.3 * inch)) + + # Scope of work + elements.extend(_build_scope(proposal, styles)) + elements.append(Spacer(1, 0.3 * inch)) + + # Line items table + elements.extend(_build_line_items_table(line_items, styles)) + elements.append(Spacer(1, 0.4 * inch)) + + # Terms and conditions + elements.extend(_build_terms(styles)) + + doc.build(elements, onFirstPage=_page_footer, onLaterPages=_page_footer) + + return buffer.getvalue() + + +def _get_styles(): + styles = getSampleStyleSheet() + + styles.add( + ParagraphStyle( + "CompanyName", + parent=styles["Heading1"], + fontSize=18, + leading=22, + textColor=colors.HexColor("#1a237e"), + spaceAfter=2, + ) + ) + + styles.add( + ParagraphStyle( + "CompanyInfo", + parent=styles["Normal"], + fontSize=9, + leading=12, + textColor=colors.HexColor("#555555"), + ) + ) + + styles.add( + ParagraphStyle( + "ProposalTitle", + parent=styles["Heading2"], + fontSize=14, + leading=18, + textColor=colors.HexColor("#1a237e"), + spaceBefore=6, + spaceAfter=12, + ) + ) + + styles.add( + ParagraphStyle( + "SectionHeader", + parent=styles["Heading3"], + fontSize=11, + leading=14, + textColor=colors.HexColor("#1a237e"), + spaceBefore=8, + spaceAfter=6, + borderWidth=0, + ) + ) + + styles.add( + ParagraphStyle( + "MetaLabel", + parent=styles["Normal"], + fontSize=9, + leading=12, + textColor=colors.HexColor("#666666"), + ) + ) + + styles.add( + ParagraphStyle( + "MetaValue", + parent=styles["Normal"], + fontSize=10, + leading=13, + fontName="Helvetica-Bold", + ) + ) + + styles.add( + ParagraphStyle( + "ScopeText", + parent=styles["Normal"], + fontSize=10, + leading=14, + spaceBefore=4, + ) + ) + + styles.add( + ParagraphStyle( + "TermsText", + parent=styles["Normal"], + fontSize=8, + leading=11, + textColor=colors.HexColor("#555555"), + ) + ) + + styles.add( + ParagraphStyle( + "TotalLabel", + parent=styles["Normal"], + fontSize=11, + leading=14, + fontName="Helvetica-Bold", + alignment=TA_RIGHT, + ) + ) + + styles.add( + ParagraphStyle( + "FooterText", + parent=styles["Normal"], + fontSize=8, + leading=10, + textColor=colors.HexColor("#888888"), + alignment=TA_CENTER, + ) + ) + + return styles + + +def _build_header(proposal: dict, styles) -> list: + revision = proposal.get("currentRevision", 1) + revision_text = f" | Rev {revision}" if revision > 1 else "" + + header_data = [ + [ + Paragraph(COMPANY_NAME, styles["CompanyName"]), + Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]), + ], + [ + Paragraph(f"{COMPANY_EMAIL}", styles["CompanyInfo"]), + Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]), + ], + ] + + header_table = Table(header_data, colWidths=[3.5 * inch, 3.5 * inch]) + header_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (1, 0), (1, -1), "RIGHT"), + ("LINEBELOW", (0, -1), (-1, -1), 1.5, colors.HexColor("#1a237e")), + ("BOTTOMPADDING", (0, -1), (-1, -1), 8), + ] + ) + ) + + return [header_table] + + +def _build_metadata(proposal: dict, styles) -> list: + submitted_at = proposal.get("submittedAt", "") + if submitted_at: + try: + dt = datetime.fromisoformat(submitted_at.replace("Z", "+00:00")) + submitted_at = dt.strftime("%B %d, %Y") + except (ValueError, TypeError): + pass + + approved_at = proposal.get("approvedAt", "") + if approved_at: + try: + dt = datetime.fromisoformat(approved_at.replace("Z", "+00:00")) + approved_at = dt.strftime("%B %d, %Y") + except (ValueError, TypeError): + pass + + meta_data = [ + [ + Paragraph("Customer", styles["MetaLabel"]), + Paragraph("Site Address", styles["MetaLabel"]), + ], + [ + Paragraph(proposal.get("customerName", ""), styles["MetaValue"]), + Paragraph(proposal.get("customerAddress", ""), styles["MetaValue"]), + ], + [ + Paragraph("Work Order #", styles["MetaLabel"]), + Paragraph("Date", styles["MetaLabel"]), + ], + [ + Paragraph(proposal.get("workOrderNumber", ""), styles["MetaValue"]), + Paragraph(approved_at or submitted_at, styles["MetaValue"]), + ], + [ + Paragraph("Category", styles["MetaLabel"]), + Paragraph("Priority", styles["MetaLabel"]), + ], + [ + Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]), + Paragraph(proposal.get("priority", ""), styles["MetaValue"]), + ], + ] + + meta_table = Table(meta_data, colWidths=[3.5 * inch, 3.5 * inch]) + meta_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("TOPPADDING", (0, 0), (-1, -1), 2), + ("BOTTOMPADDING", (0, 0), (-1, -1), 2), + ] + ) + ) + + return [meta_table] + + +def _build_scope(proposal: dict, styles) -> list: + scope = proposal.get("refinedScope") or proposal.get("scopeOfWork", "") + if not scope: + return [] + + return [ + Paragraph("Scope of Work", styles["SectionHeader"]), + Paragraph(scope, styles["ScopeText"]), + ] + + +def _build_line_items_table(line_items: list[dict], styles) -> list: + if not line_items: + return [Paragraph("No line items", styles["Normal"])] + + elements = [Paragraph("Itemized Pricing", styles["SectionHeader"])] + + header = ["#", "Description", "Qty", "Unit", "Unit Price", "Total"] + + table_data = [header] + subtotal = 0.0 + + for i, li in enumerate(line_items, 1): + qty = li.get("quantity", "") + unit = li.get("unit", "") + unit_price = li.get("unitPrice") + total_price = li.get("totalPrice", 0) + pricing_mode = li.get("pricingMode", "TotalPrice") + + subtotal += float(total_price or 0) + + if pricing_mode == "TotalPrice": + up_str = "-" + elif unit_price is not None: + up_str = f"${float(unit_price):,.2f}" + else: + up_str = "-" + + tp_str = f"${float(total_price):,.2f}" if total_price else "-" + + row = [ + str(i), + Paragraph(li.get("description", ""), styles["Normal"]), + str(qty) if qty else "", + unit, + up_str, + tp_str, + ] + table_data.append(row) + + col_widths = [ + 0.35 * inch, + 3.15 * inch, + 0.55 * inch, + 0.7 * inch, + 1.0 * inch, + 1.0 * inch, + ] + table = Table(table_data, colWidths=col_widths, repeatRows=1) + + table.setStyle( + TableStyle( + [ + # Header row + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a237e")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 9), + ("BOTTOMPADDING", (0, 0), (-1, 0), 6), + ("TOPPADDING", (0, 0), (-1, 0), 6), + # Data rows + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("TOPPADDING", (0, 1), (-1, -1), 4), + ("BOTTOMPADDING", (0, 1), (-1, -1), 4), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + # Alignment + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (2, 0), (2, -1), "CENTER"), + ("ALIGN", (3, 0), (3, -1), "CENTER"), + ("ALIGN", (4, 0), (4, -1), "RIGHT"), + ("ALIGN", (5, 0), (5, -1), "RIGHT"), + # Grid + ("LINEBELOW", (0, 0), (-1, 0), 1, colors.HexColor("#1a237e")), + ("LINEBELOW", (0, 1), (-1, -2), 0.5, colors.HexColor("#e0e0e0")), + ("LINEBELOW", (0, -1), (-1, -1), 1, colors.HexColor("#1a237e")), + # Alternating row colors + *[ + ("BACKGROUND", (0, i), (-1, i), colors.HexColor("#f5f5f5")) + for i in range(2, len(table_data), 2) + ], + ] + ) + ) + + elements.append(table) + elements.append(Spacer(1, 0.15 * inch)) + + # Total row + total_data = [ + ["", "", "", "", "TOTAL:", f"${subtotal:,.2f}"], + ] + total_table = Table(total_data, colWidths=col_widths) + total_table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("ALIGN", (4, 0), (4, 0), "RIGHT"), + ("ALIGN", (5, 0), (5, 0), "RIGHT"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("LINEABOVE", (4, 0), (5, 0), 1.5, colors.HexColor("#1a237e")), + ] + ) + ) + elements.append(total_table) + + return elements + + +def _build_terms(styles) -> list: + elements = [ + Spacer(1, 0.2 * inch), + Paragraph("Terms & Conditions", styles["SectionHeader"]), + ] + + for line in TERMS_AND_CONDITIONS.split("\n"): + elements.append(Paragraph(line, styles["TermsText"])) + + return elements + + +def _page_footer(canvas, doc): + canvas.saveState() + page_num = canvas.getPageNumber() + footer_text = f"Page {page_num}" + canvas.setFont("Helvetica", 8) + canvas.setFillColor(colors.HexColor("#888888")) + canvas.drawCentredString(letter[0] / 2, 0.4 * inch, footer_text) + canvas.drawString( + 0.75 * inch, + 0.4 * inch, + f"{COMPANY_NAME} — Confidential", + ) + canvas.restoreState() + + +def upload_pdf(s3_key: str, pdf_bytes: bytes): + try: + s3.put_object( + Bucket=GENERATED_BUCKET, + Key=s3_key, + Body=pdf_bytes, + ContentType="application/pdf", + ) + except Exception as e: + print(f"Error uploading PDF: {e}") + raise + + +def register_pdf(proposal_id: str, s3_key: str): + try: + resp = httpx.post( + f"{API_BASE_URL}/api/generated-pdfs", + json={"proposalId": proposal_id, "s3Key": s3_key}, + headers=_api_headers(), + timeout=10, + ) + if resp.status_code not in (200, 201): + print(f"Failed to register PDF: {resp.status_code} {resp.text}") + except Exception as e: + print(f"Error registering PDF: {e}") + + +def _api_headers() -> dict: + headers = {"Content-Type": "application/json"} + api_key = _get_api_key() + if api_key: + headers["X-Internal-Api-Key"] = api_key + return headers diff --git a/lambdas/pdf-generate/requirements.txt b/lambdas/pdf-generate/requirements.txt index 64cd117..82cc9af 100644 --- a/lambdas/pdf-generate/requirements.txt +++ b/lambdas/pdf-generate/requirements.txt @@ -1,2 +1,3 @@ reportlab>=4.2.0,<5.0 boto3>=1.35.0,<2.0 +httpx>=0.27.0,<1.0 diff --git a/lambdas/suggestions/app.py b/lambdas/suggestions/app.py new file mode 100644 index 0000000..b8997e6 --- /dev/null +++ b/lambdas/suggestions/app.py @@ -0,0 +1,315 @@ +"""Proposal System - Suggestion Engine Lambda. + +Queries Bedrock Knowledge Base for similar proposals and invokes Claude +to generate line item suggestions for new proposals. +""" + +import json +import os + +import boto3 +import httpx + +KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "") +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0") +API_BASE_URL = os.environ.get("API_BASE_URL", "") +INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "") + +bedrock_agent = boto3.client("bedrock-agent-runtime") +bedrock_runtime = boto3.client("bedrock-runtime") +secrets_client = boto3.client("secretsmanager") + +_cached_api_key: str | None = None + + +def _get_api_key() -> str: + global _cached_api_key + if _cached_api_key is None: + if INTERNAL_API_KEY_SECRET_ARN: + resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN) + _cached_api_key = resp["SecretString"] + else: + _cached_api_key = "" + return _cached_api_key + + +def handler(event, context): + for record in event.get("Records", []): + body = json.loads(record["body"]) + payload = body.get("payload", body) + proposal_id = payload["proposalId"] + trigger = payload.get("trigger", "generate") + process_suggestion(proposal_id, trigger) + return {"statusCode": 200} + + +def process_suggestion(proposal_id: str, trigger: str): + proposal = fetch_proposal(proposal_id) + if not proposal: + print(f"Proposal {proposal_id} not found") + return + + scope = proposal.get("refinedScope") or proposal.get("scopeOfWork", "") + category = proposal.get("serviceCategory", "") + priority = proposal.get("priority", "") + + existing_items = fetch_line_items(proposal_id) + + similar_proposals = retrieve_similar(scope, category) + + suggested_items = generate_line_items(scope, category, priority, similar_proposals) + + post_line_items(proposal_id, suggested_items, existing_items) + + store_similar_references(proposal_id, similar_proposals) + + update_status_to_in_review(proposal_id) + + +def fetch_line_items(proposal_id: str) -> list[dict]: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching line items: {e}") + return [] + + +def fetch_proposal(proposal_id: str) -> dict | None: + try: + resp = httpx.get( + f"{API_BASE_URL}/api/proposals/{proposal_id}", + headers=_api_headers(), + timeout=10, + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"Error fetching proposal: {e}") + return None + + +def retrieve_similar(scope: str, category: str) -> list[dict]: + if not KNOWLEDGE_BASE_ID: + print("No Knowledge Base configured, skipping retrieval") + return [] + + try: + filter_config = ( + {"equals": {"key": "service_category", "value": category}} + if category + else None + ) + + params = { + "knowledgeBaseId": KNOWLEDGE_BASE_ID, + "retrievalQuery": {"text": scope}, + "retrievalConfiguration": { + "vectorSearchConfiguration": { + "numberOfResults": 10, + } + }, + } + + if filter_config: + params["retrievalConfiguration"]["vectorSearchConfiguration"]["filter"] = ( + filter_config + ) + + response = bedrock_agent.retrieve(**params) + + results = [] + for result in response.get("retrievalResults", []): + content = result.get("content", {}).get("text", "") + score = result.get("score", 0.0) + metadata = result.get("metadata", {}) + source_uri = result.get("location", {}).get("s3Location", {}).get("uri", "") + + results.append( + { + "content": content, + "score": score, + "metadata": metadata, + "sourceUri": source_uri, + } + ) + + return results + + except Exception as e: + print(f"Error retrieving from KB: {e}") + return [] + + +def generate_line_items( + scope: str, + category: str, + priority: str, + similar_proposals: list[dict], +) -> list[dict]: + context_block = "" + if similar_proposals: + context_block = "Here are similar historical proposals and their line items for reference:\n\n" + for i, sp in enumerate(similar_proposals[:5], 1): + context_block += ( + f"--- Similar Proposal {i} (relevance: {sp['score']:.2f}) ---\n" + ) + context_block += sp["content"] + "\n\n" + + prompt = f"""You are a construction/facilities proposal estimator for Sea Haven Industries. +Based on the scope of work and similar historical proposals, generate a detailed list of line items +with quantities, units, and estimated pricing. + +Service Category: {category} +Priority: {priority} + +Scope of Work: +{scope} + +{context_block} + +Generate line items as a JSON array. Each item should have: +- description: clear description of the work/material +- quantity: numeric quantity +- unit: unit of measurement (e.g., "sq ft", "hours", "each", "linear ft") +- unitPrice: price per unit in dollars (or null if lump sum) +- totalPrice: total price for this line item in dollars +- pricingMode: "UnitPrice" if unit price provided, "TotalPrice" if lump sum + +Respond ONLY with the JSON array, no additional text.""" + + try: + response = bedrock_runtime.invoke_model( + modelId=MODEL_ID, + contentType="application/json", + accept="application/json", + body=json.dumps( + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 4096, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.3, + } + ), + ) + + response_body = json.loads(response["body"].read()) + content = response_body["content"][0]["text"] + + content = content.strip() + if content.startswith("```"): + content = content.split("\n", 1)[1] + content = content.rsplit("```", 1)[0] + + line_items = json.loads(content) + return line_items if isinstance(line_items, list) else [] + + except Exception as e: + print(f"Error generating line items: {e}") + return [] + + +def post_line_items(proposal_id: str, items: list[dict], existing_items: list[dict]): + if not items and not existing_items: + return + + line_items_payload = [] + + # Preserve non-AI items (Manual, Vendor, Historical) + preserved = [li for li in existing_items if li.get("source") != "AI"] + for i, li in enumerate(preserved): + line_items_payload.append( + { + "id": li.get("id"), + "description": li["description"], + "quantity": float(li.get("quantity", 1)), + "unit": li.get("unit", "each"), + "unitPrice": li.get("unitPrice"), + "totalPrice": float(li.get("totalPrice", 0)), + "pricingMode": li.get("pricingMode", "TotalPrice"), + "sortOrder": i + 1, + "source": li.get("source", "Manual"), + } + ) + + # Add new AI-generated items after preserved ones + offset = len(line_items_payload) + for i, item in enumerate(items): + pricing_mode = item.get("pricingMode", "TotalPrice") + if pricing_mode not in ("UnitPrice", "TotalPrice", "Both"): + pricing_mode = "UnitPrice" if item.get("unitPrice") else "TotalPrice" + + line_items_payload.append( + { + "id": None, + "description": item["description"], + "quantity": float(item.get("quantity", 1)), + "unit": item.get("unit", "each"), + "unitPrice": item.get("unitPrice"), + "totalPrice": float(item.get("totalPrice", 0)), + "pricingMode": pricing_mode, + "sortOrder": offset + i + 1, + "source": "AI", + } + ) + + try: + resp = httpx.put( + f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items", + json={"lineItems": line_items_payload}, + headers=_api_headers(), + timeout=15, + ) + if resp.status_code not in (200, 201): + print(f"Failed to post line items: {resp.status_code} {resp.text}") + except Exception as e: + print(f"Error posting line items: {e}") + + +def store_similar_references(proposal_id: str, similar_proposals: list[dict]): + if not similar_proposals: + return + + for sp in similar_proposals[:5]: + source_uri = sp.get("sourceUri", "") + library_item_id = source_uri.split("/")[-1] if source_uri else "" + if not library_item_id: + continue + + try: + httpx.post( + f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references", + json={ + "referencedLibraryItemId": library_item_id, + "similarityScore": sp["score"], + }, + headers=_api_headers(), + timeout=10, + ) + except Exception as e: + print(f"Error storing similar reference: {e}") + + +def update_status_to_in_review(proposal_id: str): + try: + httpx.put( + f"{API_BASE_URL}/api/proposals/{proposal_id}", + json={"status": "InReview"}, + headers=_api_headers(), + timeout=10, + ) + except Exception as e: + print(f"Error updating status: {e}") + + +def _api_headers() -> dict: + headers = {"Content-Type": "application/json"} + api_key = _get_api_key() + if api_key: + headers["X-Internal-Api-Key"] = api_key + return headers diff --git a/lambdas/suggestions/requirements.txt b/lambdas/suggestions/requirements.txt new file mode 100644 index 0000000..996956a --- /dev/null +++ b/lambdas/suggestions/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.35.0,<2.0 +httpx>=0.27.0,<1.0 diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9084357 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,6 @@ +# Cognito Configuration +VITE_COGNITO_DOMAIN=proposal-system-seahaven.auth.us-east-1.amazoncognito.com +VITE_COGNITO_CLIENT_ID=your-cognito-web-client-id + +# API Base URL (only needed if not using Vite proxy in dev) +VITE_API_URL=/api diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..db5f064 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3121 @@ +{ + "name": "proposal-system-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proposal-system-web", + "version": "0.1.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.3.1", + "@mui/material": "^7.3.1", + "@reduxjs/toolkit": "^2.11.2", + "@tanstack/react-query": "^5.100.10", + "axios": "^1.16.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-redux": "^9.2.0", + "react-router-dom": "^7.9.4", + "react-toastify": "^11.0.5" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "~5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz", + "integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz", + "integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.11", + "@mui/system": "^7.3.11", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-toastify": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.1.0.tgz", + "integrity": "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/web/package.json b/web/package.json index b1d81a9..e45f242 100644 --- a/web/package.json +++ b/web/package.json @@ -11,12 +11,16 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@mui/material": "^7.0.0", - "@tanstack/react-query": "^5.60.0", - "axios": "^1.7.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router": "^7.0.0" + "@mui/icons-material": "^7.3.1", + "@mui/material": "^7.3.1", + "@reduxjs/toolkit": "^2.11.2", + "@tanstack/react-query": "^5.100.10", + "axios": "^1.16.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-redux": "^9.2.0", + "react-router-dom": "^7.9.4", + "react-toastify": "^11.0.5" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..99a77e4 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,67 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { Box, Toolbar, Typography } from '@mui/material'; +import { selectSidebarOpen } from './app/slices/uiSlice'; +import type { RootState } from './app/store'; +import ProtectedRoute from './components/ProtectedRoute'; +import Topbar from './components/Topbar'; +import Sidebar from './components/Sidebar'; +import LoginPage from './pages/auth/LoginPage'; +import AuthCallback from './pages/auth/AuthCallback'; +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 = 220; + +export default function App() { + const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state)); + + return ( + + } /> + } /> + + + + + + + + + } /> + } /> + } /> + } /> + + {/* Admin Routes */} + } /> + } /> + } /> + User Management — Coming Soon} /> + + } /> + + + + + } + /> + + ); +} diff --git a/web/src/app/slices/authSlice.ts b/web/src/app/slices/authSlice.ts new file mode 100644 index 0000000..2b03f92 --- /dev/null +++ b/web/src/app/slices/authSlice.ts @@ -0,0 +1,87 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { STORAGE_KEY_TOKEN } from '../../constants'; + +interface AuthUser { + id: string; + email: string; + displayName: string; + role: 'Dispatcher' | 'Admin' | 'SysAdmin'; + token: string; +} + +interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; + loading: boolean; + error: string | null; +} + +const getUserFromStorage = (): AuthUser | null => { + try { + const result = window.localStorage.getItem(STORAGE_KEY_TOKEN); + return result ? JSON.parse(result) : null; + } catch { + return null; + } +}; + +const isTokenValid = (token: string): boolean => { + if (!token) return false; + try { + const part = token.split('.')[1]; + if (!part) return false; + const payload = JSON.parse(atob(part)); + return new Date(payload.exp * 1000) > new Date(); + } catch { + return false; + } +}; + +const storedUser = getUserFromStorage(); + +const initialState: AuthState = { + user: storedUser, + isAuthenticated: storedUser?.token ? isTokenValid(storedUser.token) : false, + loading: false, + error: null, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + state.isAuthenticated = action.payload.token ? isTokenValid(action.payload.token) : false; + state.loading = false; + state.error = null; + window.localStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload)); + }, + logout: (state) => { + state.user = null; + state.isAuthenticated = false; + state.error = null; + window.localStorage.removeItem(STORAGE_KEY_TOKEN); + }, + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, + clearError: (state) => { + state.error = null; + }, + }, +}); + +export const { setUser, logout, setLoading, setError, clearError } = authSlice.actions; + +export const selectUser = (state: { auth: AuthState }) => state.auth.user; +export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated; +export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading; +export const selectAuthError = (state: { auth: AuthState }) => state.auth.error; +export const selectToken = (state: { auth: AuthState }) => state.auth.user?.token; + +export default authSlice.reducer; diff --git a/web/src/app/slices/uiSlice.ts b/web/src/app/slices/uiSlice.ts new file mode 100644 index 0000000..0585f4d --- /dev/null +++ b/web/src/app/slices/uiSlice.ts @@ -0,0 +1,29 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { STORAGE_KEY_SIDEBAR } from '../../constants'; + +interface UiState { + sidebarOpen: boolean; +} + +const initialState: UiState = { + sidebarOpen: window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false', +}; + +const uiSlice = createSlice({ + name: 'ui', + initialState, + reducers: { + toggleSidebar: (state) => { + state.sidebarOpen = !state.sidebarOpen; + window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(state.sidebarOpen)); + }, + setSidebarOpen: (state, action: PayloadAction) => { + state.sidebarOpen = action.payload; + window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(action.payload)); + }, + }, +}); + +export const { toggleSidebar, setSidebarOpen } = uiSlice.actions; +export const selectSidebarOpen = (state: { ui: UiState }) => state.ui.sidebarOpen; +export default uiSlice.reducer; diff --git a/web/src/app/store.ts b/web/src/app/store.ts new file mode 100644 index 0000000..3a480cd --- /dev/null +++ b/web/src/app/store.ts @@ -0,0 +1,19 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './slices/authSlice'; +import uiReducer from './slices/uiSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + ui: uiReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['auth/setUser'], + }, + }), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..f0a5824 --- /dev/null +++ b/web/src/components/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +export default function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..e42d026 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,154 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Box, + 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'; + +const DRAWER_WIDTH = 220; + +const dispatcherNav = [ + { label: 'Dashboard', path: '/', icon: }, + { label: 'My Proposals', path: '/proposals', icon: }, + { 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: }, +]; + +const ALL_NAV_PATHS = new Set( + [...dispatcherNav, ...adminNav, ...sysadminNav].map(n => n.path) +); + +function isNavActive(pathname: string, itemPath: string): boolean { + if (pathname === itemPath) return true; + if (ALL_NAV_PATHS.has(pathname)) return false; + if (itemPath === '/' || itemPath === '/admin') return false; + return pathname.startsWith(itemPath + '/'); +} + +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 ( + + + + + Proposals + + + {navItems.map((item) => ( + navigate(item.path)} + sx={{ py: 1 }} + > + {item.icon} + + + ))} + + + {isAdmin && ( + <> + + Admin + + + {adminNav.map((item) => ( + navigate(item.path)} + sx={{ py: 1 }} + > + {item.icon} + + + ))} + {isSysAdmin && sysadminNav.map((item) => ( + navigate(item.path)} + sx={{ py: 1 }} + > + {item.icon} + + + ))} + + + )} + + {user && ( + + {user.displayName} + {user.role} + + )} + + ); +} diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx new file mode 100644 index 0000000..e384227 --- /dev/null +++ b/web/src/components/Topbar.tsx @@ -0,0 +1,58 @@ +import { useDispatch } from 'react-redux'; +import { AppBar, Toolbar, IconButton, Typography, Box, Avatar } from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import LogoutIcon from '@mui/icons-material/Logout'; +import { toggleSidebar } from '../app/slices/uiSlice'; +import { useAuth } from '../hooks/useAuth'; + +export default function Topbar() { + const dispatch = useDispatch(); + const { user, logout } = useAuth(); + + const initials = user?.displayName + ? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) + : ''; + + return ( + theme.zIndex.drawer + 1, height: 64 }}> + + dispatch(toggleSidebar())} + sx={{ mr: 2 }} + > + + + + Proposal System + + {user && ( + + + + {initials} + + + + {user.displayName} + + + {user.role} + + + + + + + + + )} + + + ); +} 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 && ( + + )} + + + + + + + # + 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) => ( + {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/constants/index.ts b/web/src/constants/index.ts new file mode 100644 index 0000000..ac30c2d --- /dev/null +++ b/web/src/constants/index.ts @@ -0,0 +1,21 @@ +export const API_URL = import.meta.env.VITE_API_URL || '/api'; + +export const CACHE_TIME_SHORT = 2 * 60 * 1000; +export const CACHE_TIME_MEDIUM = 5 * 60 * 1000; +export const CACHE_TIME_LONG = 10 * 60 * 1000; + +export const STALE_TIME_SHORT = 1 * 60 * 1000; +export const STALE_TIME_MEDIUM = 5 * 60 * 1000; +export const STALE_TIME_LONG = 10 * 60 * 1000; + +export const DEFAULT_PAGE_SIZE = 12; +export const DEFAULT_PAGE = 1; + +export const DEBOUNCE_SEARCH = 600; + +export const STORAGE_KEY_TOKEN = 'proposal_system_token'; +export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; + +export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const; +export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const; +export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const; diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts new file mode 100644 index 0000000..08cfb08 --- /dev/null +++ b/web/src/constants/queryKeys.ts @@ -0,0 +1,9 @@ +export const QUERY_KEYS = { + proposals: 'proposals', + proposal: 'proposal', + proposalLineItems: 'proposalLineItems', + customers: 'customers', + users: 'users', + dashboard: 'dashboard', + auditTrail: 'auditTrail', +} as const; diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts new file mode 100644 index 0000000..81e4148 --- /dev/null +++ b/web/src/hooks/useAuth.ts @@ -0,0 +1,35 @@ +import { useSelector, useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + selectUser, + selectIsAuthenticated, + selectAuthLoading, + selectAuthError, + logout as logoutAction, +} from '../app/slices/authSlice'; +import { toast } from 'react-toastify'; +import type { RootState } from '../app/store'; + +export function useAuth() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const user = useSelector((state: RootState) => selectUser(state)); + const isAuthenticated = useSelector((state: RootState) => selectIsAuthenticated(state)); + const loading = useSelector((state: RootState) => selectAuthLoading(state)); + const error = useSelector((state: RootState) => selectAuthError(state)); + + const logout = () => { + dispatch(logoutAction()); + toast.info('You have been logged out'); + navigate('/login'); + }; + + return { + user, + isAuthenticated, + loading, + error, + logout, + }; +} diff --git a/web/src/hooks/usePaginatedList.ts b/web/src/hooks/usePaginatedList.ts new file mode 100644 index 0000000..a3fc19c --- /dev/null +++ b/web/src/hooks/usePaginatedList.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants'; + +interface PaginatedResult { + items: T[]; + totalCount: number; +} + +type FetchFn = (params: Record) => Promise>; + +export function usePaginatedList(fetchFn: FetchFn, extraParams: Record = {}) { + const [rows, setRows] = useState([]); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + const debounceRef = useRef | null>(null); + + const extraKey = JSON.stringify(extraParams); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, DEBOUNCE_SEARCH); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search]); + + const reload = useCallback(() => { + setLoading(true); + setErr(''); + fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams }) + .then(({ items, totalCount }) => { + setRows(items); + setTotalCount(totalCount); + }) + .catch((e: Error) => setErr(e.message || 'Failed to load')) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchFn, debouncedSearch, page, pageSize, extraKey]); + + useEffect(() => { + reload(); + }, [reload]); + + return useMemo( + () => ({ + rows, + search, + setSearch, + page, + setPage, + pageSize, + setPageSize, + totalCount, + loading, + err, + reload, + }), + [rows, search, page, pageSize, totalCount, loading, err, reload] + ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..6ad3d12 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,27 @@ +@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;1,400&display=swap"); + +* { + box-sizing: border-box; +} + +html, body, #root { + height: 100%; +} + +body { + margin: 0; + font-family: "Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; + background: #f4f6f7; + color: #333; + line-height: 1.5; +} + +a { + color: #007bff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts new file mode 100644 index 0000000..f7dfc06 --- /dev/null +++ b/web/src/lib/api/admin.ts @@ -0,0 +1,76 @@ +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; + }, + + getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => { + try { + const res = await apiClient.get(`/proposals/${id}/pdf`); + return res.data; + } catch { + return null; + } + }, +}; diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts new file mode 100644 index 0000000..b964dc2 --- /dev/null +++ b/web/src/lib/api/auth.ts @@ -0,0 +1,33 @@ +import apiClient from './client'; + +export interface AuthUser { + id: string; + email: string; + displayName: string; + role: 'Dispatcher' | 'Admin' | 'SysAdmin'; + token: string; +} + +export interface UserProfile { + id: string; + email: string; + displayName: string; + role: 'Dispatcher' | 'Admin' | 'SysAdmin'; +} + +export const authApi = { + exchangeCode: async (code: string, redirectUri: string): Promise => { + const res = await apiClient.post('/auth/callback', { code, redirectUri }); + return res.data; + }, + + devLogin: async (email: string, displayName: string, role: string): Promise => { + const res = await apiClient.post('/auth/dev-login', { email, displayName, role }); + return res.data; + }, + + getMe: async (): Promise => { + const res = await apiClient.get('/users/me'); + return res.data; + }, +}; diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 0000000..02db718 --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,62 @@ +import axios from 'axios'; +import { API_URL, STORAGE_KEY_TOKEN } from '../../constants'; + +const apiClient = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +}); + +apiClient.interceptors.request.use( + (config) => { + const tokenData = window.localStorage.getItem(STORAGE_KEY_TOKEN); + if (tokenData) { + try { + const parsed = JSON.parse(tokenData); + if (parsed?.token) { + config.headers.Authorization = `Bearer ${parsed.token}`; + } + } catch { + // Invalid token data + } + } + return config; + }, + (error) => Promise.reject(error) +); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + const { status, data } = error.response; + + if (status === 401) { + window.localStorage.removeItem(STORAGE_KEY_TOKEN); + window.location.href = '/login'; + return Promise.reject(new Error('Session expired. Please log in again.')); + } + + if (status === 403) { + return Promise.reject(new Error('You do not have permission to perform this action.')); + } + + if (status === 404) { + return Promise.reject(new Error('The requested resource was not found.')); + } + + const message = data?.detail || data?.title || data?.message || 'An error occurred'; + return Promise.reject(new Error(message)); + } + + if (error.request) { + return Promise.reject(new Error('No response from server. Please check your connection.')); + } + + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts new file mode 100644 index 0000000..95d5f54 --- /dev/null +++ b/web/src/lib/api/customers.ts @@ -0,0 +1,21 @@ +import apiClient from './client'; + +export interface Customer { + id: string; + name: string; + addresses: string[]; + createdAt: string; +} + +export const customersApi = { + search: async (query?: string): Promise => { + const params = query ? `?query=${encodeURIComponent(query)}` : ''; + const res = await apiClient.get(`/customers${params}`); + return res.data; + }, + + create: async (data: { name: string; addresses?: string[] }): Promise => { + const res = await apiClient.post('/customers', data); + 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/lib/api/proposals.ts b/web/src/lib/api/proposals.ts new file mode 100644 index 0000000..09fdbb9 --- /dev/null +++ b/web/src/lib/api/proposals.ts @@ -0,0 +1,112 @@ +import apiClient from './client'; + +export interface CreateProposalRequest { + workOrderNumber: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + serviceCategory: string; + priority: string; + notes?: string; +} + +export interface ProposalListItem { + id: string; + proposalNumber: string; + customerName: string; + workOrderNumber: string; + serviceCategory: string; + priority: string; + status: string; + totalBidAmount: number; + submittedAt: string; + submittedByName: string | null; + assignedAdminName: string | null; +} + +export interface ProposalDetail { + id: string; + proposalNumber: string; + workOrderNumber: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + refinedScope: string | null; + serviceCategory: string; + priority: string; + status: string; + totalBidAmount: number; + vendorTotalCost: number | null; + notes: string; + submittedById: string; + submittedByName: string | null; + submittedAt: string; + assignedAdminId: string | null; + approvedById: string | null; + approvedAt: string | null; + sentAt: string | null; + currentRevision: number; + parentProposalId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PagedResponse { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface ProposalFilters { + search?: string; + page?: number; + pageSize?: number; + status?: string; + serviceCategory?: string; + priority?: string; + fromDate?: string; + toDate?: string; +} + +export const proposalsApi = { + create: async (data: CreateProposalRequest): Promise => { + const res = await apiClient.post('/proposals', data); + return res.data; + }, + + getAll: async (filters: ProposalFilters = {}): Promise> => { + const params = new URLSearchParams(); + if (filters.search) params.append('search', filters.search); + if (filters.page) params.append('page', String(filters.page)); + if (filters.pageSize) params.append('pageSize', String(filters.pageSize)); + if (filters.status) params.append('status', filters.status); + if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory); + if (filters.priority) params.append('priority', filters.priority); + + const res = await apiClient.get(`/proposals?${params.toString()}`); + return res.data; + }, + + getById: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}`); + return res.data; + }, + + uploadAttachment: async (proposalId: string, fileName: string): Promise<{ uploadUrl: string; s3Key: string }> => { + const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`); + return res.data; + }, + + getStats: async (): Promise => { + const res = await apiClient.get('/proposals/stats'); + return res.data; + }, +}; + +export interface ProposalStats { + totalCount: number; + inReviewCount: number; + approvedCount: number; + sentCount: number; +} diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts new file mode 100644 index 0000000..1ef9f4a --- /dev/null +++ b/web/src/lib/queryClient.ts @@ -0,0 +1,19 @@ +import { QueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + onError: (error) => { + const message = error instanceof Error ? error.message : 'An error occurred'; + toast.error(message); + }, + }, + }, +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index 7c13e11..d284c5d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,12 +1,29 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; - -function App() { - return
Proposal System
; -} +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider, CssBaseline } from '@mui/material'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import './index.css'; +import { store } from './app/store'; +import { queryClient } from './lib/queryClient'; +import { theme } from './theme'; +import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + + + + + + + + + ); diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx new file mode 100644 index 0000000..bdc54a8 --- /dev/null +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -0,0 +1,268 @@ +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} + + {label} + + + {value} + + + + ); +} + +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: ( + + + + ), + }, + }} + /> + {}} + > + All + {SERVICE_CATEGORIES.map((c) => ( + {c} + ))} + + {}} + > + All + {PRIORITIES.map((p) => ( + {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..680d916 --- /dev/null +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -0,0 +1,423 @@ +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 pdfMutation = useMutation({ + mutationFn: () => adminApi.getPdf(id!), + onSuccess: (data) => { + if (data?.downloadUrl) { + window.open(data.downloadUrl, '_blank'); + toast.success('PDF ready — downloading'); + } else { + toast.info('PDF generation queued — try again in a few seconds'); + } + }, + onError: () => { + toast.error('Failed to generate PDF'); + }, + }); + + const handleLineItemsChange = (updated: EditableLineItem[]) => { + setLineItems(updated); + setDirty(true); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (!proposal) { + return ( + + Proposal not found + + + ); + } + + 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 */} + + + + {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 && ( + <> + + + + + )} + + {isApproved && ( + <> + + + + )} + + {isSent && ( + + )} + + + + {/* Approve Confirmation Dialog */} + setApproveDialogOpen(false)}> + Approve Proposal + + + Approve {proposal.proposalNumber} for {proposal.customerName}? + + + Line Items: {lineItems.length} + + Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)} + + {proposal.vendorTotalCost != null && ( + + Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)} + + )} + + + + + + + + + ); +} diff --git a/web/src/pages/auth/AuthCallback.tsx b/web/src/pages/auth/AuthCallback.tsx new file mode 100644 index 0000000..8556613 --- /dev/null +++ b/web/src/pages/auth/AuthCallback.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import { setUser, setError, setLoading } from '../../app/slices/authSlice'; +import { authApi } from '../../lib/api/auth'; + +export default function AuthCallback() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const [searchParams] = useSearchParams(); + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + if (error) { + dispatch(setError(error)); + navigate('/login', { replace: true }); + return; + } + + if (!code) { + dispatch(setError('No authorization code received')); + navigate('/login', { replace: true }); + return; + } + + dispatch(setLoading(true)); + authApi + .exchangeCode(code, `${window.location.origin}/callback`) + .then((user) => { + dispatch(setUser(user)); + navigate('/', { replace: true }); + }) + .catch((err) => { + dispatch(setError(err.message || 'Authentication failed')); + navigate('/login', { replace: true }); + }); + }, [searchParams, dispatch, navigate]); + + return ( + + + Completing sign-in... + + ); +} diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..2ea1e04 --- /dev/null +++ b/web/src/pages/auth/LoginPage.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material'; +import GoogleIcon from '@mui/icons-material/Google'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import EngineeringIcon from '@mui/icons-material/Engineering'; +import PersonIcon from '@mui/icons-material/Person'; +import { useAuth } from '../../hooks/useAuth'; +import { authApi } from '../../lib/api/auth'; +import { setUser, setError } from '../../app/slices/authSlice'; + +const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || ''; +const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || ''; +const REDIRECT_URI = `${window.location.origin}/callback`; + +function buildLoginUrl(): string { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + response_type: 'code', + scope: 'openid email profile', + redirect_uri: REDIRECT_URI, + identity_provider: 'Google', + }); + return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`; +} + +export default function LoginPage() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { isAuthenticated } = useAuth(); + const [loading, setLoading] = useState(false); + + const isDevMode = !CLIENT_ID; + + useEffect(() => { + if (isAuthenticated) { + navigate('/', { replace: true }); + } + }, [isAuthenticated, navigate]); + + const handleDevLogin = async (role: string) => { + setLoading(true); + try { + const user = await authApi.devLogin('adam@seahavenind.com', 'Adam Moussa', role); + dispatch(setUser(user)); + navigate('/', { replace: true }); + } catch (err) { + dispatch(setError(err instanceof Error ? err.message : 'Dev login failed')); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Sea Haven Industries + + + + + Proposal System + + + + {isDevMode ? ( + <> + + Development Mode — Select a role to sign in as: + + + + + + + + + Dev login is only available when VITE_COGNITO_CLIENT_ID is not set + + + ) : ( + <> + + Sign in with your Sea Haven Google account to continue. + + + + )} + + + + Sea Haven Industries © {new Date().getFullYear()} + + + + ); +} diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx new file mode 100644 index 0000000..31a46dd --- /dev/null +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -0,0 +1,188 @@ +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + Button, + Skeleton, +} from '@mui/material'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import DescriptionIcon from '@mui/icons-material/Description'; +import HourglassTopIcon from '@mui/icons-material/HourglassTop'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import SendIcon from '@mui/icons-material/Send'; +import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals'; +import { QUERY_KEYS } from '../../constants/queryKeys'; + +const STATUS_COLORS: Record = { + Draft: 'default', + InReview: 'info', + Approved: 'success', + Sent: 'success', + Revised: 'warning', +}; + +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 KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( + + + {icon} + + {label} + + + {value} + + + + ); +} + +export default function Dashboard() { + const navigate = useNavigate(); + + const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({ + queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'], + queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5 }), + }); + + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: [QUERY_KEYS.proposals, 'stats'], + queryFn: () => proposalsApi.getStats(), + }); + + const isLoading = recentLoading || statsLoading; + const proposals = recentData?.items ?? []; + const total = stats?.totalCount ?? 0; + const counts = { + inReview: stats?.inReviewCount ?? 0, + approved: stats?.approvedCount ?? 0, + sent: stats?.sentCount ?? 0, + }; + + return ( + + + Dashboard + + + + + + {isLoading ? ( + + ) : ( + } label="Total Submitted" value={String(total)} /> + )} + + + {isLoading ? ( + + ) : ( + } label="In Review" value={String(counts.inReview)} /> + )} + + + {isLoading ? ( + + ) : ( + } label="Approved" value={String(counts.approved)} /> + )} + + + {isLoading ? ( + + ) : ( + } label="Sent" value={String(counts.sent)} /> + )} + + + + + + + Recent Proposals + + + + + + + + Proposal # + Customer + Category + Status + Bid Amount + Submitted + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + : proposals.map((row) => ( + navigate(`/proposals/${row.id}`)} + > + {row.proposalNumber} + {row.customerName} + {row.serviceCategory} + + + + {formatCurrency(row.totalBidAmount)} + {formatDate(row.submittedAt)} + + ))} + {!isLoading && proposals.length === 0 && ( + + + + No proposals yet. Submit your first one! + + + + )} + +
+
+
+
+
+ ); +} diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx new file mode 100644 index 0000000..cacbecc --- /dev/null +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -0,0 +1,178 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + Grid, + Chip, + Divider, + Button, + Skeleton, + Stepper, + Step, + StepLabel, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; + +const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent']; +const STATUS_COLORS: Record = { + Draft: 'default', + InReview: 'info', + Approved: 'success', + Sent: 'success', + Revised: 'warning', +}; + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function formatDateTime(iso: string | null): string { + if (!iso) return '-'; + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + {value || '-'} + + ); +} + +export default function ProposalDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: proposal, isLoading, error } = useQuery({ + queryKey: ['proposals', id], + queryFn: () => proposalsApi.getById(id!), + enabled: !!id, + }); + + if (isLoading) { + return ( + + + + + ); + } + + if (error || !proposal) { + return ( + + + {error instanceof Error ? error.message : 'Proposal not found'} + + + + ); + } + + const activeStep = STATUS_ORDER.indexOf(proposal.status); + + return ( + + + + + {proposal.proposalNumber} + + + + + + + + Status Timeline + + = 0 ? activeStep : 0} alternativeLabel> + {STATUS_ORDER.map((label) => ( + + {label} + + ))} + + + + + + + + Proposal Details + + + + + + + + + + + + + + + + + Scope of Work + + + {proposal.refinedScope || proposal.scopeOfWork} + + + {proposal.notes && ( + <> + + + Notes + + + {proposal.notes} + + + )} + + + + {(proposal.approvedAt || proposal.sentAt) && ( + + + + Approval & Delivery + + + + + {proposal.vendorTotalCost !== null && ( + + )} + + + + )} + + ); +} diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx new file mode 100644 index 0000000..a18f5e1 --- /dev/null +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -0,0 +1,274 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { + Box, + Typography, + Card, + CardContent, + TextField, + Button, + Grid, + MenuItem, + Autocomplete, + CircularProgress, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import { toast } from 'react-toastify'; +import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals'; +import { customersApi, type Customer } from '../../../lib/api/customers'; +import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants'; +import { queryClient } from '../../../lib/queryClient'; + +export default function ProposalFormPage() { + const navigate = useNavigate(); + + const [form, setForm] = useState({ + workOrderNumber: '', + customerName: '', + customerAddress: '', + scopeOfWork: '', + serviceCategory: '', + priority: 'Standard', + notes: '', + }); + + const [customers, setCustomers] = useState([]); + const [customerLoading, setCustomerLoading] = useState(false); + const [addresses, setAddresses] = useState([]); + const [vendorFile, setVendorFile] = useState(null); + + const handleChange = (field: keyof CreateProposalRequest, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const searchCustomers = useCallback(async (query: string) => { + if (query.length < 2) return; + setCustomerLoading(true); + try { + const results = await customersApi.search(query); + setCustomers(results); + } catch { + setCustomers([]); + } finally { + setCustomerLoading(false); + } + }, []); + + const handleCustomerSelect = (_: unknown, value: Customer | string | null) => { + if (!value) { + setForm((prev) => ({ ...prev, customerName: '', customerAddress: '' })); + setAddresses([]); + return; + } + if (typeof value === 'string') { + setForm((prev) => ({ ...prev, customerName: value, customerAddress: '' })); + setAddresses([]); + } else { + setForm((prev) => ({ ...prev, customerName: value.name, customerAddress: value.addresses[0] || '' })); + setAddresses(value.addresses); + } + }; + + const createMutation = useMutation({ + mutationFn: async () => { + const proposal = await proposalsApi.create(form); + + if (vendorFile) { + const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name); + await fetch(uploadUrl, { + method: 'PUT', + body: vendorFile, + headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, + }); + } + + return proposal; + }, + onSuccess: (proposal) => { + queryClient.invalidateQueries({ queryKey: ['proposals'] }); + toast.success(`Proposal ${proposal.proposalNumber} submitted`); + navigate(`/proposals/${proposal.id}`); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate(); + }; + + const isValid = + form.workOrderNumber.trim() && + form.customerName.trim() && + form.customerAddress.trim() && + form.scopeOfWork.trim() && + form.serviceCategory; + + return ( + + + New Proposal Request + + + + + + + + handleChange('workOrderNumber', e.target.value)} + /> + + + + (typeof opt === 'string' ? opt : opt.name)} + loading={customerLoading} + onInputChange={(_, value) => searchCustomers(value)} + onChange={handleCustomerSelect} + renderInput={(params) => ( + + {customerLoading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + + + + {addresses.length > 1 ? ( + handleChange('customerAddress', e.target.value)} + > + {addresses.map((addr) => ( + + {addr} + + ))} + + ) : ( + handleChange('customerAddress', e.target.value)} + /> + )} + + + + handleChange('serviceCategory', e.target.value)} + > + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + + + + handleChange('priority', e.target.value)} + > + {PRIORITIES.map((p) => ( + + {p} + + ))} + + + + + handleChange('scopeOfWork', e.target.value)} + placeholder="Describe the work needed..." + /> + + + + handleChange('notes', e.target.value)} + /> + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx new file mode 100644 index 0000000..a2197d9 --- /dev/null +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -0,0 +1,168 @@ +import { useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Chip, + TextField, + InputAdornment, + Button, + Skeleton, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import { usePaginatedList } from '../../../hooks/usePaginatedList'; +import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; + +const STATUS_COLORS: Record = { + Draft: 'default', + InReview: 'info', + Approved: 'success', + Sent: 'success', + Revised: 'warning', +}; + +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' }); +} + +export default function ProposalListPage() { + const navigate = useNavigate(); + const { + rows, + search, + setSearch, + page, + setPage, + pageSize, + setPageSize, + totalCount, + loading, + err, + } = usePaginatedList(proposalsApi.getAll); + + return ( + + + My Proposals + + + + + + setSearch(e.target.value)} + sx={{ mb: 2, width: 320 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {err && ( + + {err} + + )} + + + + + + Proposal # + Customer + WO # + Category + Priority + Status + Bid Amount + Submitted + + + + {loading + ? Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + + + ))} + + )) + : rows.map((row) => ( + navigate(`/proposals/${row.id}`)} + > + {row.proposalNumber} + {row.customerName} + {row.workOrderNumber} + {row.serviceCategory} + {row.priority} + + + + {formatCurrency(row.totalBidAmount)} + {formatDate(row.submittedAt)} + + ))} + {!loading && rows.length === 0 && ( + + + + No proposals found. Create your first one! + + + + )} + +
+
+ + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))} + rowsPerPageOptions={[12, 24, 48]} + /> +
+
+
+ ); +} diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..5ac98ba --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,164 @@ +import { createTheme } from '@mui/material/styles'; + +export const theme = createTheme({ + palette: { + primary: { + main: '#0c4f6f', + light: '#1fa8de', + dark: '#083a52', + }, + secondary: { + main: '#007bff', + light: '#4da3ff', + dark: '#0056b3', + }, + background: { + default: '#f4f6f7', + paper: '#ffffff', + }, + text: { + primary: '#333333', + secondary: '#666666', + }, + divider: '#d9dde0', + }, + typography: { + fontFamily: '"Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + fontSize: 13, + h4: { fontWeight: 600, fontSize: '28px' }, + h5: { fontWeight: 600, fontSize: '22px' }, + h6: { fontWeight: 600, fontSize: '16px' }, + body1: { fontSize: '14px' }, + body2: { fontSize: '13px' }, + }, + shape: { + borderRadius: 4, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + borderRadius: 4, + }, + }, + }, + MuiAppBar: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + background: 'linear-gradient(#0c4f6f, #083a52)', + borderBottom: '1px solid #062c3e', + }, + }, + }, + MuiCard: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + border: '1px solid #ddd', + borderRadius: 4, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }, + }, + }, + MuiPaper: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + borderRadius: 4, + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + '& .MuiTableCell-head': { + fontWeight: 600, + backgroundColor: '#f9f9f9', + borderBottom: '2px solid #ddd', + }, + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + fontSize: '14px', + padding: '12px', + borderBottom: '1px solid #eee', + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + '&:hover': { + backgroundColor: '#f9f9f9', + }, + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + borderRadius: 4, + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 4, + fontWeight: 600, + fontSize: '12px', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + borderRight: '1px solid #d9dde0', + backgroundColor: '#fbfbfb', + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 6, + marginLeft: 8, + marginRight: 8, + marginBottom: 4, + '&.Mui-selected': { + backgroundColor: '#e6f1f7', + borderLeft: '3px solid #0b80b5', + '&:hover': { + backgroundColor: '#dbedf5', + }, + }, + '&:hover': { + backgroundColor: '#eef2f4', + }, + }, + }, + }, + MuiListItemIcon: { + styleOverrides: { + root: { + color: '#8c949a', + minWidth: 36, + }, + }, + }, + }, +}); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/vite.config.ts b/web/vite.config.ts index 5c59447..4953e6a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,5 +5,11 @@ export default defineConfig({ plugins: [react()], server: { port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5000', + changeOrigin: true, + }, + }, }, });