mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-05 07:12:01 +00:00
Implement Phases 2-5: Frontend, AI/RAG, PDF Generation (#22)
* Fix NuGet versions and add InitialCreate EF Core migration - Update AWSSDK.SQS and AWSSDK.SecretsManager to 3.7.500.0 (actual available versions) - Update AWSSDK.Extensions.NETCore.Setup to 3.7.400 - Generate InitialCreate migration for PostgreSQL (all 8 entities) - Build verified: 0 errors, 0 warnings * Implement Dispatcher Frontend (Phase 2) React 19 + MUI v7 + TypeScript + Vite SPA matching SHOC patterns: Redux Toolkit (auth/ui slices), TanStack React Query, axios interceptors, react-toastify, Cognito OAuth PKCE login flow, paginated proposal list, new proposal form with customer autocomplete and vendor PDF upload, read-only proposal detail with status stepper timeline. * Add AuthController for Cognito code exchange and .env.example Backend endpoint POST /api/auth/callback exchanges the OAuth authorization code with Cognito's token endpoint, auto-provisions the user in the DB, and returns the access token to the frontend. * Implement Admin Frontend Experience (Phase 3) Three-panel admin workspace: left reference panel (submission details, vendor data), center editor (refined scope, inline line item table with reorder/add/remove/pricing), right similar proposals panel (KB results with pull-to-editor). Admin dashboard with stats cards and proposal queue table. Approval flow with confirmation dialog, mark-as-sent, and create-revision actions. Role-based sidebar navigation. * Implement backend dev mode, internal API auth, and service layer enhancements - Add dev-login endpoint with local JWT signing for local development - Add InternalApiKeyMiddleware with timing-safe comparison for Lambda-to-API auth - Add DevS3Service and NoOpJobPublisher for running without AWS services - Implement CurrentUserService cascading user resolution (ID → sub → email → create) - Add async ResolveAsync() to avoid synchronous DB calls in request pipeline - Add /proposals/stats endpoint for efficient server-side status counts - Guard status transitions: only allow Draft → InReview via update endpoint - Add vendor proposals, generated PDFs, and similar proposals controllers - Add ISimilarProposalService and SimilarProposalService - Add [Authorize] to AddSimilarReference endpoint * Implement Lambda functions for PDF processing, suggestions, and library ingest - pdf-extract: Parse vendor PDFs with pdfplumber, fallback to Claude multimodal - pdf-generate: Generate branded proposal PDFs with reportlab Platypus - library-ingest: Format approved proposals as markdown and sync to Bedrock KB - suggestions: Query KB for similar proposals, generate line items via Claude - All Lambdas use internal API key auth and cold-start secret caching - Fix pdf_path unbound variable in pdf-extract error handling * Add Bedrock Knowledge Base, OpenSearch Serverless, and SQS message filtering - Provision OpenSearch Serverless collection for vector search - Create Bedrock Knowledge Base with Titan embedding model - Configure S3 data source with fixed-size chunking (512 tokens, 20% overlap) - Add suggestions Lambda with SQS event source filtering - Scope bedrock:InvokeModel IAM to specific model ARN patterns - Add internal API key secret in Secrets Manager - Add log retention (2 months) to all Lambda functions - Add docker-compose.yml for local PostgreSQL * Apply SHOC design system styling across frontend - Rewrite theme with SHOC palette (#0c4f6f primary, Nunito font, 4px radius) - Add global CSS with Google Fonts import for Nunito - Redesign Topbar with avatar initials, role subtitle, gradient header - Redesign Sidebar with 220px width, section headers, active state border - Restyle LoginPage with SHOC branded card and dev-mode role selector - Update AdminDashboard KPI cards to centered SHOC style - Add devLogin API method for local development auth flow * Fix frontend navigation bugs, differentiate Dashboard from Proposals list - Fix double nav selection by adding isNavActive() with ALL_NAV_PATHS set - Fix /admin/users routing to placeholder instead of redirect to / - Fix ProposalDetailPage Back button navigating to / instead of /proposals - Differentiate Dashboard (KPI cards + recent 5) from ProposalListPage (full paginated table) - Dashboard now uses dedicated /proposals/stats endpoint for accurate counts - Fix adminApi.getPdf dead code (axios rejects before status check) - Wire up PDF generation button in AdminWorkspace - Adjust layout: 220px drawer, 10px content padding, 64px toolbar height * Add appsettings.Development.json to gitignore Prevent dev-only signing keys and connection strings from being committed. * Fix CI failures: unused Python imports and CDK synth asset path CDK synth job needs the .NET API published first so the Lambda asset path exists. Python lint had 3 unused imports in pdf-generate. * Apply ruff formatting to all Lambda Python files
This commit is contained in:
parent
d2e12d3940
commit
ceefae2850
74 changed files with 10611 additions and 58 deletions
12
.github/workflows/ci.yaml
vendored
12
.github/workflows/ci.yaml
vendored
|
|
@ -60,12 +60,16 @@ jobs:
|
||||||
infra:
|
infra:
|
||||||
name: CDK Synth
|
name: CDK Synth
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: infra
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
@ -73,4 +77,6 @@ jobs:
|
||||||
cache-dependency-path: infra/package-lock.json
|
cache-dependency-path: infra/package-lock.json
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
working-directory: infra
|
||||||
- run: npx cdk synth
|
- run: npx cdk synth
|
||||||
|
working-directory: infra
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,6 +27,7 @@ cdk.out/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
appsettings.Development.json
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
195
api/src/ProposalSystem.Api/Controllers/AuthController.cs
Normal file
195
api/src/ProposalSystem.Api/Controllers/AuthController.cs
Normal file
|
|
@ -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<ActionResult<AuthResponse>> 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<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var devMode = _config.GetValue<bool>("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<UserRole>(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<Claim>
|
||||||
|
{
|
||||||
|
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<CognitoTokenResponse?> 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<string, string>
|
||||||
|
{
|
||||||
|
["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<CognitoTokenResponse>(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; }
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -111,4 +112,27 @@ public class FilesController : ControllerBase
|
||||||
|
|
||||||
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
|
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("vendors")]
|
||||||
|
[Authorize(Roles = "admins,sysadmins")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<VendorProposalResponse>>> 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<object>(v.ExtractedData)
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return Ok(vendors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<IActionResult> 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);
|
||||||
|
|
@ -12,11 +12,16 @@ public class ProposalsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IProposalService _proposalService;
|
private readonly IProposalService _proposalService;
|
||||||
private readonly IJobPublisher _jobPublisher;
|
private readonly IJobPublisher _jobPublisher;
|
||||||
|
private readonly ISimilarProposalService _similarService;
|
||||||
|
|
||||||
public ProposalsController(IProposalService proposalService, IJobPublisher jobPublisher)
|
public ProposalsController(
|
||||||
|
IProposalService proposalService,
|
||||||
|
IJobPublisher jobPublisher,
|
||||||
|
ISimilarProposalService similarService)
|
||||||
{
|
{
|
||||||
_proposalService = proposalService;
|
_proposalService = proposalService;
|
||||||
_jobPublisher = jobPublisher;
|
_jobPublisher = jobPublisher;
|
||||||
|
_similarService = similarService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
@ -95,6 +100,14 @@ public class ProposalsController : ControllerBase
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/similar")]
|
||||||
|
[Authorize(Roles = "admins,sysadmins")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<SimilarProposalResponse>>> GetSimilar(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _similarService.GetSimilarProposalsAsync(id, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/generate-suggestions")]
|
[HttpPost("{id:guid}/generate-suggestions")]
|
||||||
[Authorize(Roles = "admins,sysadmins")]
|
[Authorize(Roles = "admins,sysadmins")]
|
||||||
public async Task<IActionResult> GenerateSuggestions(Guid id, CancellationToken ct)
|
public async Task<IActionResult> GenerateSuggestions(Guid id, CancellationToken ct)
|
||||||
|
|
@ -110,4 +123,22 @@ public class ProposalsController : ControllerBase
|
||||||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct);
|
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct);
|
||||||
return Accepted();
|
return Accepted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<ActionResult<ProposalStatsResponse>> GetStats(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var stats = await _proposalService.GetStatsAsync(ct);
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/similar-references")]
|
||||||
|
[Authorize(Roles = "admins,sysadmins")]
|
||||||
|
public async Task<IActionResult> AddSimilarReference(
|
||||||
|
Guid id,
|
||||||
|
[FromBody] CreateSimilarReferenceRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _similarService.AddReferenceAsync(id, request, ct);
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<IActionResult> 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<ProcessingStatus>(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<IActionResult> 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<ProcessingStatus>(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);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text;
|
||||||
using Amazon.S3;
|
using Amazon.S3;
|
||||||
using Amazon.SecretsManager;
|
using Amazon.SecretsManager;
|
||||||
using Amazon.SQS;
|
using Amazon.SQS;
|
||||||
|
|
@ -14,11 +15,17 @@ using ProposalSystem.Infrastructure.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// AWS SDK clients
|
// Dev mode flag (read early for conditional setup)
|
||||||
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode");
|
||||||
builder.Services.AddAWSService<IAmazonS3>();
|
|
||||||
builder.Services.AddAWSService<IAmazonSQS>();
|
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
|
||||||
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
if (!devMode)
|
||||||
|
{
|
||||||
|
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
||||||
|
builder.Services.AddAWSService<IAmazonS3>();
|
||||||
|
builder.Services.AddAWSService<IAmazonSQS>();
|
||||||
|
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
||||||
|
}
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
|
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
|
||||||
|
|
@ -35,8 +42,21 @@ else
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
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
|
// Authentication
|
||||||
var cognitoAuthority = builder.Configuration["Auth:Authority"];
|
var cognitoAuthority = builder.Configuration["Auth:Authority"];
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(cognitoAuthority))
|
if (!string.IsNullOrEmpty(cognitoAuthority))
|
||||||
{
|
{
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
|
@ -49,6 +69,26 @@ if (!string.IsNullOrEmpty(cognitoAuthority))
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidateAudience = false,
|
ValidateAudience = false,
|
||||||
ValidateLifetime = true,
|
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<ILineItemService, LineItemService>();
|
||||||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||||
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
||||||
builder.Services.AddScoped<IS3Service, S3Service>();
|
if (devMode)
|
||||||
builder.Services.AddScoped<IJobPublisher>(sp =>
|
builder.Services.AddScoped<IS3Service, DevS3Service>();
|
||||||
|
else
|
||||||
|
builder.Services.AddScoped<IS3Service, S3Service>();
|
||||||
|
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
|
||||||
|
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
||||||
|
if (string.IsNullOrEmpty(jobsQueueUrl))
|
||||||
{
|
{
|
||||||
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
|
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
|
||||||
var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
}
|
||||||
return new SqsJobPublisher(sqsClient, queueUrl);
|
else
|
||||||
});
|
{
|
||||||
|
builder.Services.AddScoped<IJobPublisher>(sp =>
|
||||||
|
{
|
||||||
|
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
|
||||||
|
return new SqsJobPublisher(sqsClient, jobsQueueUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP client for Cognito token exchange
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
||||||
|
|
@ -112,8 +166,18 @@ var app = builder.Build();
|
||||||
|
|
||||||
app.UseMiddleware<GlobalExceptionHandler>();
|
app.UseMiddleware<GlobalExceptionHandler>();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseMiddleware<InternalApiKeyMiddleware>();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
var userService = context.RequestServices.GetRequiredService<ICurrentUserService>();
|
||||||
|
await userService.ResolveAsync();
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHealthChecks("/api/health");
|
app.MapHealthChecks("/api/health");
|
||||||
|
|
||||||
|
|
|
||||||
13
api/src/ProposalSystem.Api/Properties/launchSettings.json
Normal file
13
api/src/ProposalSystem.Api/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Amazon.Lambda.AspNetCoreServer.Hosting" Version="1.7.2" />
|
<PackageReference Include="Amazon.Lambda.AspNetCoreServer.Hosting" Version="1.7.2" />
|
||||||
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.302" />
|
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.400" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,46 @@ public class CurrentUserService : ICurrentUserService
|
||||||
public string? IpAddress =>
|
public string? IpAddress =>
|
||||||
_httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
_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)
|
var principal = _httpContext.HttpContext?.User
|
||||||
?? _httpContext.HttpContext?.User.FindFirstValue("sub")
|
|
||||||
?? throw new UnauthorizedAccessException("No authenticated user");
|
?? throw new UnauthorizedAccessException("No authenticated user");
|
||||||
|
|
||||||
_cachedUser = _db.Users
|
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
.FirstOrDefault(u => u.CognitoSub == cognitoSub);
|
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)
|
if (_cachedUser == null)
|
||||||
{
|
{
|
||||||
var email = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
var email = principal.FindFirstValue(ClaimTypes.Email)
|
||||||
?? _httpContext.HttpContext?.User.FindFirstValue("email")
|
?? principal.FindFirstValue("email")
|
||||||
?? "unknown@seahaven.com";
|
?? "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];
|
?? email.Split('@')[0];
|
||||||
|
|
||||||
var groups = _httpContext.HttpContext?.User.FindAll("cognito:groups")
|
var groups = principal.FindAll("cognito:groups")
|
||||||
.Select(c => c.Value).ToList() ?? new List<string>();
|
.Select(c => c.Value).ToList();
|
||||||
|
|
||||||
var role = groups.Contains("sysadmins") ? UserRole.SysAdmin
|
var role = groups.Contains("sysadmins") ? UserRole.SysAdmin
|
||||||
: groups.Contains("admins") ? UserRole.Admin
|
: groups.Contains("admins") ? UserRole.Admin
|
||||||
|
|
@ -55,7 +73,69 @@ public class CurrentUserService : ICurrentUserService
|
||||||
_cachedUser = new User
|
_cachedUser = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
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,
|
Email = email,
|
||||||
DisplayName = name,
|
DisplayName = name,
|
||||||
Role = role,
|
Role = role,
|
||||||
|
|
|
||||||
32
api/src/ProposalSystem.Api/Services/DevServices.cs
Normal file
32
api/src/ProposalSystem.Api/Services/DevServices.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using ProposalSystem.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Api.Services;
|
||||||
|
|
||||||
|
public class NoOpJobPublisher : IJobPublisher
|
||||||
|
{
|
||||||
|
private readonly ILogger<NoOpJobPublisher> _logger;
|
||||||
|
|
||||||
|
public NoOpJobPublisher(ILogger<NoOpJobPublisher> 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<string> 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<string> GeneratePresignedDownloadUrlAsync(string bucket, string key, int expirationMinutes = 60)
|
||||||
|
{
|
||||||
|
return Task.FromResult($"http://localhost:5000/dev-null/download?bucket={bucket}&key={key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
},
|
},
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Authority": "",
|
"Authority": "",
|
||||||
"Audience": ""
|
"Audience": "",
|
||||||
|
"CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com",
|
||||||
|
"ClientId": ""
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"DefaultConnection": ""
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,12 @@ public record PdfDownloadResponse(
|
||||||
string DownloadUrl,
|
string DownloadUrl,
|
||||||
DateTime ExpiresAt
|
DateTime ExpiresAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record VendorProposalResponse(
|
||||||
|
Guid Id,
|
||||||
|
string VendorName,
|
||||||
|
string FileName,
|
||||||
|
decimal TotalVendorCost,
|
||||||
|
string ProcessingStatus,
|
||||||
|
object? ExtractedData
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ public record CreateProposalRequest(
|
||||||
public record UpdateProposalRequest(
|
public record UpdateProposalRequest(
|
||||||
string? RefinedScope,
|
string? RefinedScope,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
Guid? AssignedAdminId
|
Guid? AssignedAdminId,
|
||||||
|
ProposalStatus? Status
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ProposalResponse(
|
public record ProposalResponse(
|
||||||
|
|
@ -76,3 +77,10 @@ public record PagedResponse<T>(
|
||||||
int Page,
|
int Page,
|
||||||
int PageSize
|
int PageSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record ProposalStatsResponse(
|
||||||
|
int TotalCount,
|
||||||
|
int InReviewCount,
|
||||||
|
int ApprovedCount,
|
||||||
|
int SentCount
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace ProposalSystem.Application.DTOs;
|
||||||
|
|
||||||
|
public record SimilarProposalResponse(
|
||||||
|
string ProposalNumber,
|
||||||
|
string CustomerName,
|
||||||
|
string ServiceCategory,
|
||||||
|
float SimilarityScore,
|
||||||
|
decimal TotalBidAmount,
|
||||||
|
IReadOnlyList<SimilarLineItemResponse> LineItems
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SimilarLineItemResponse(
|
||||||
|
string Description,
|
||||||
|
decimal Quantity,
|
||||||
|
string Unit,
|
||||||
|
decimal? UnitPrice,
|
||||||
|
decimal TotalPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
public record CreateSimilarReferenceRequest(
|
||||||
|
string ReferencedLibraryItemId,
|
||||||
|
float SimilarityScore
|
||||||
|
);
|
||||||
|
|
@ -8,4 +8,5 @@ public interface ICurrentUserService
|
||||||
string Email { get; }
|
string Email { get; }
|
||||||
UserRole Role { get; }
|
UserRole Role { get; }
|
||||||
string? IpAddress { get; }
|
string? IpAddress { get; }
|
||||||
|
Task ResolveAsync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ public interface IProposalService
|
||||||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default);
|
Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using ProposalSystem.Application.DTOs;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ISimilarProposalService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync(Guid proposalId, CancellationToken ct = default);
|
||||||
|
Task AddReferenceAsync(Guid proposalId, CreateSimilarReferenceRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
504
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs
generated
Normal file
504
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260516225740_InitialCreate.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Addresses")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Customers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("GeneratedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Revision")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PricingMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalPrice")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal?>("UnitPrice")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId");
|
||||||
|
|
||||||
|
b.ToTable("LineItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssignedAdminId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentRevision")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProposalNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefinedScope")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ScopeOfWork")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SentAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceCategory")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SubmittedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("SubmittedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalBidAmount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal?>("VendorTotalCost")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReferencedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ReferencedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReferencedLibraryItemId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<float>("SimilarityScore")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId");
|
||||||
|
|
||||||
|
b.HasIndex("ReferencedById");
|
||||||
|
|
||||||
|
b.ToTable("SimilarProposalReferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CognitoSub")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ExtractedData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProcessingStatus")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("S3Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalVendorCost")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ProposalSystem.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Customers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Addresses = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
CognitoSub = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Email = table.Column<string>(type: "text", nullable: false),
|
||||||
|
DisplayName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Role = table.Column<string>(type: "text", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalNumber = table.Column<string>(type: "text", nullable: false),
|
||||||
|
WorkOrderNumber = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CustomerName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CustomerAddress = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ScopeOfWork = table.Column<string>(type: "text", nullable: false),
|
||||||
|
RefinedScope = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ServiceCategory = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Priority = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
TotalBidAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||||
|
VendorTotalCost = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SubmittedById = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SubmittedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
AssignedAdminId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ApprovedById = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
ApprovedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CurrentRevision = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ParentProposalId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Action = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Details = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
IpAddress = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
S3Key = table.Column<string>(type: "text", nullable: false),
|
||||||
|
GeneratedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
GeneratedById = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
Unit = table.Column<string>(type: "text", nullable: false),
|
||||||
|
UnitPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
|
||||||
|
TotalPrice = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||||
|
PricingMode = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Source = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ReferencedLibraryItemId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SimilarityScore = table.Column<float>(type: "real", nullable: false),
|
||||||
|
ReferencedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ReferencedById = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
VendorName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
S3Key = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ExtractedData = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
TotalVendorCost = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||||
|
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ProcessingStatus = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Addresses")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Customers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("GeneratedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Revision")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PricingMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalPrice")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal?>("UnitPrice")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId");
|
||||||
|
|
||||||
|
b.ToTable("LineItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ApprovedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssignedAdminId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentRevision")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProposalNumber")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefinedScope")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ScopeOfWork")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SentAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceCategory")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SubmittedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("SubmittedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalBidAmount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal?>("VendorTotalCost")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ReferencedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ReferencedById")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReferencedLibraryItemId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<float>("SimilarityScore")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProposalId");
|
||||||
|
|
||||||
|
b.HasIndex("ReferencedById");
|
||||||
|
|
||||||
|
b.ToTable("SimilarProposalReferences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CognitoSub")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ExtractedData")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProcessingStatus")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("S3Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalVendorCost")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||||
<PackageReference Include="AWSSDK.S3" Version="3.7.405.0" />
|
<PackageReference Include="AWSSDK.S3" Version="3.7.405.0" />
|
||||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.401.0" />
|
<PackageReference Include="AWSSDK.SQS" Version="3.7.500.0" />
|
||||||
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.401.0" />
|
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.500.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ public class ProposalService : IProposalService
|
||||||
|
|
||||||
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
|
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);
|
return MapToResponse(proposal);
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +147,10 @@ public class ProposalService : IProposalService
|
||||||
if (request.AssignedAdminId.HasValue)
|
if (request.AssignedAdminId.HasValue)
|
||||||
proposal.AssignedAdminId = request.AssignedAdminId.Value;
|
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;
|
proposal.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
|
@ -296,6 +300,19 @@ public class ProposalService : IProposalService
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ProposalStatsResponse> 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(
|
private static ProposalResponse MapToResponse(Proposal p) => new(
|
||||||
p.Id,
|
p.Id,
|
||||||
p.ProposalNumber,
|
p.ProposalNumber,
|
||||||
|
|
|
||||||
|
|
@ -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<IReadOnlyList<SimilarProposalResponse>> 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<SimilarProposalResponse>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
|
|
@ -9,6 +9,9 @@ import * as sqs from 'aws-cdk-lib/aws-sqs';
|
||||||
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
||||||
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||||||
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
|
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';
|
import { Construct } from 'constructs';
|
||||||
|
|
||||||
export interface ComputeStackProps extends cdk.StackProps {
|
export interface ComputeStackProps extends cdk.StackProps {
|
||||||
|
|
@ -28,6 +31,123 @@ export class ComputeStack extends cdk.Stack {
|
||||||
|
|
||||||
const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
|
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
|
// .NET 8 API Lambda
|
||||||
const apiFunction = new lambda.Function(this, 'ApiFunction', {
|
const apiFunction = new lambda.Function(this, 'ApiFunction', {
|
||||||
functionName: 'proposal-system-api',
|
functionName: 'proposal-system-api',
|
||||||
|
|
@ -47,12 +167,15 @@ export class ComputeStack extends cdk.Stack {
|
||||||
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
||||||
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
||||||
JOBS_QUEUE_URL: props.jobsQueue.queueUrl,
|
JOBS_QUEUE_URL: props.jobsQueue.queueUrl,
|
||||||
|
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||||
},
|
},
|
||||||
tracing: lambda.Tracing.ACTIVE,
|
tracing: lambda.Tracing.ACTIVE,
|
||||||
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Lambda permissions
|
// API Lambda permissions
|
||||||
props.dbSecret.grantRead(apiFunction);
|
props.dbSecret.grantRead(apiFunction);
|
||||||
|
internalApiKeySecret.grantRead(apiFunction);
|
||||||
props.uploadsBucket.grantReadWrite(apiFunction);
|
props.uploadsBucket.grantReadWrite(apiFunction);
|
||||||
props.generatedBucket.grantRead(apiFunction);
|
props.generatedBucket.grantRead(apiFunction);
|
||||||
props.jobsQueue.grantSendMessages(apiFunction);
|
props.jobsQueue.grantSendMessages(apiFunction);
|
||||||
|
|
@ -93,6 +216,37 @@ export class ComputeStack extends cdk.Stack {
|
||||||
integration: apiIntegration,
|
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
|
// Python Lambda: PDF Extract
|
||||||
const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', {
|
const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', {
|
||||||
functionName: 'proposal-system-pdf-extract',
|
functionName: 'proposal-system-pdf-extract',
|
||||||
|
|
@ -107,14 +261,18 @@ export class ComputeStack extends cdk.Stack {
|
||||||
securityGroups: [props.lambdaSecurityGroup],
|
securityGroups: [props.lambdaSecurityGroup],
|
||||||
environment: {
|
environment: {
|
||||||
UPLOADS_BUCKET: props.uploadsBucket.bucketName,
|
UPLOADS_BUCKET: props.uploadsBucket.bucketName,
|
||||||
|
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
API_BASE_URL: httpApi.apiEndpoint,
|
API_BASE_URL: httpApi.apiEndpoint,
|
||||||
|
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||||
},
|
},
|
||||||
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internalApiKeySecret.grantRead(pdfExtractFunction);
|
||||||
props.uploadsBucket.grantRead(pdfExtractFunction);
|
props.uploadsBucket.grantRead(pdfExtractFunction);
|
||||||
pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({
|
pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||||
actions: ['bedrock:InvokeModel'],
|
actions: ['bedrock:InvokeModel'],
|
||||||
resources: ['*'],
|
resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Python Lambda: PDF Generate
|
// Python Lambda: PDF Generate
|
||||||
|
|
@ -132,9 +290,12 @@ export class ComputeStack extends cdk.Stack {
|
||||||
environment: {
|
environment: {
|
||||||
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
||||||
API_BASE_URL: httpApi.apiEndpoint,
|
API_BASE_URL: httpApi.apiEndpoint,
|
||||||
|
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||||
},
|
},
|
||||||
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internalApiKeySecret.grantRead(pdfGenerateFunction);
|
||||||
props.generatedBucket.grantWrite(pdfGenerateFunction);
|
props.generatedBucket.grantWrite(pdfGenerateFunction);
|
||||||
|
|
||||||
// Python Lambda: Library Ingest
|
// Python Lambda: Library Ingest
|
||||||
|
|
@ -151,20 +312,31 @@ export class ComputeStack extends cdk.Stack {
|
||||||
securityGroups: [props.lambdaSecurityGroup],
|
securityGroups: [props.lambdaSecurityGroup],
|
||||||
environment: {
|
environment: {
|
||||||
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
||||||
|
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
|
||||||
|
DATA_SOURCE_ID: dataSource.attrDataSourceId,
|
||||||
API_BASE_URL: httpApi.apiEndpoint,
|
API_BASE_URL: httpApi.apiEndpoint,
|
||||||
|
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||||
},
|
},
|
||||||
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internalApiKeySecret.grantRead(libraryIngestFunction);
|
||||||
props.libraryBucket.grantWrite(libraryIngestFunction);
|
props.libraryBucket.grantWrite(libraryIngestFunction);
|
||||||
libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({
|
libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||||
actions: ['bedrock:StartIngestionJob'],
|
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, {
|
pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
|
||||||
batchSize: 1,
|
batchSize: 1,
|
||||||
filters: [
|
filters: [
|
||||||
|
|
@ -195,5 +367,7 @@ export class ComputeStack extends cdk.Stack {
|
||||||
// Outputs
|
// Outputs
|
||||||
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
|
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
|
||||||
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });
|
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });
|
||||||
|
new cdk.CfnOutput(this, 'KnowledgeBaseId', { value: knowledgeBase.attrKnowledgeBaseId });
|
||||||
|
new cdk.CfnOutput(this, 'DataSourceId', { value: dataSource.attrDataSourceId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,196 @@
|
||||||
"""Proposal System - Library Ingest Lambda.
|
"""Proposal System - Library Ingest Lambda.
|
||||||
|
|
||||||
Processes approved proposals into the Bedrock Knowledge Base.
|
Processes approved/sent proposals into the Bedrock Knowledge Base library.
|
||||||
Implementation in Phase 4.
|
Formats proposal data as structured markdown and uploads to the library bucket,
|
||||||
|
then triggers a KB sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
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):
|
def handler(event, context):
|
||||||
for record in event.get("Records", []):
|
for record in event.get("Records", []):
|
||||||
body = json.loads(record["body"])
|
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}
|
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
|
||||||
|
|
|
||||||
411
lambdas/library-ingest/batch_ingest.py
Normal file
411
lambdas/library-ingest/batch_ingest.py
Normal file
|
|
@ -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 <KB_ID> --ds-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()
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
boto3>=1.35.0,<2.0
|
boto3>=1.35.0,<2.0
|
||||||
|
httpx>=0.27.0,<1.0
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,334 @@
|
||||||
"""Proposal System - PDF Extract Lambda.
|
"""Proposal System - PDF Extract Lambda.
|
||||||
|
|
||||||
Parses vendor proposal PDFs and extracts structured line item data.
|
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 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):
|
def handler(event, context):
|
||||||
for record in event.get("Records", []):
|
for record in event.get("Records", []):
|
||||||
body = json.loads(record["body"])
|
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}
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pdfplumber>=0.11.0,<1.0
|
pdfplumber>=0.11.0,<1.0
|
||||||
boto3>=1.35.0,<2.0
|
boto3>=1.35.0,<2.0
|
||||||
|
httpx>=0.27.0,<1.0
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,540 @@
|
||||||
"""Proposal System - PDF Generate Lambda.
|
"""Proposal System - PDF Generate Lambda.
|
||||||
|
|
||||||
Generates professional branded proposal PDFs from approved proposals.
|
Generates professional branded proposal PDFs using reportlab Platypus.
|
||||||
Implementation in Phase 5.
|
Triggered via SQS when an admin requests PDF generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
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):
|
def handler(event, context):
|
||||||
for record in event.get("Records", []):
|
for record in event.get("Records", []):
|
||||||
body = json.loads(record["body"])
|
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}
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
reportlab>=4.2.0,<5.0
|
reportlab>=4.2.0,<5.0
|
||||||
boto3>=1.35.0,<2.0
|
boto3>=1.35.0,<2.0
|
||||||
|
httpx>=0.27.0,<1.0
|
||||||
|
|
|
||||||
315
lambdas/suggestions/app.py
Normal file
315
lambdas/suggestions/app.py
Normal file
|
|
@ -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
|
||||||
2
lambdas/suggestions/requirements.txt
Normal file
2
lambdas/suggestions/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
boto3>=1.35.0,<2.0
|
||||||
|
httpx>=0.27.0,<1.0
|
||||||
6
web/.env.example
Normal file
6
web/.env.example
Normal file
|
|
@ -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
|
||||||
3121
web/package-lock.json
generated
Normal file
3121
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,12 +11,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/material": "^7.0.0",
|
"@mui/icons-material": "^7.3.1",
|
||||||
"@tanstack/react-query": "^5.60.0",
|
"@mui/material": "^7.3.1",
|
||||||
"axios": "^1.7.0",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"react": "^19.0.0",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"react-dom": "^19.0.0",
|
"axios": "^1.16.0",
|
||||||
"react-router": "^7.0.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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|
|
||||||
67
web/src/App.tsx
Normal file
67
web/src/App.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/callback" element={<AuthCallback />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<Topbar />
|
||||||
|
<Sidebar />
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: '10px',
|
||||||
|
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
|
||||||
|
transition: 'margin-left 250ms ease',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: 64 }} />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/proposals" element={<ProposalListPage />} />
|
||||||
|
<Route path="/proposals/new" element={<ProposalFormPage />} />
|
||||||
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/proposals" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/proposals/:id" element={<AdminWorkspace />} />
|
||||||
|
<Route path="/admin/users" element={<Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography>} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web/src/app/slices/authSlice.ts
Normal file
87
web/src/app/slices/authSlice.ts
Normal file
|
|
@ -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<AuthUser>) => {
|
||||||
|
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<boolean>) => {
|
||||||
|
state.loading = action.payload;
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
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;
|
||||||
29
web/src/app/slices/uiSlice.ts
Normal file
29
web/src/app/slices/uiSlice.ts
Normal file
|
|
@ -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<boolean>) => {
|
||||||
|
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;
|
||||||
19
web/src/app/store.ts
Normal file
19
web/src/app/store.ts
Normal file
|
|
@ -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<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
12
web/src/components/ProtectedRoute.tsx
Normal file
12
web/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -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 <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
154
web/src/components/Sidebar.tsx
Normal file
154
web/src/components/Sidebar.tsx
Normal file
|
|
@ -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: <DashboardIcon fontSize="small" /> },
|
||||||
|
{ label: 'My Proposals', path: '/proposals', icon: <DescriptionIcon fontSize="small" /> },
|
||||||
|
{ label: 'New Proposal', path: '/proposals/new', icon: <AddCircleIcon fontSize="small" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNav = [
|
||||||
|
{ label: 'Admin Queue', path: '/admin', icon: <AdminPanelSettingsIcon fontSize="small" /> },
|
||||||
|
{ label: 'All Proposals', path: '/admin/proposals', icon: <AssignmentIcon fontSize="small" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sysadminNav = [
|
||||||
|
{ label: 'Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Drawer
|
||||||
|
variant="persistent"
|
||||||
|
open={open}
|
||||||
|
sx={{
|
||||||
|
width: open ? DRAWER_WIDTH : 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: 64 }} />
|
||||||
|
<Box sx={{ overflow: 'auto', flexGrow: 1, pt: 1 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '1.2px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#9aa0a6',
|
||||||
|
px: 2.5,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Proposals
|
||||||
|
</Typography>
|
||||||
|
<List disablePadding>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={isNavActive(location.pathname, item.path)}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '1.2px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#9aa0a6',
|
||||||
|
px: 2.5,
|
||||||
|
mt: 2,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Typography>
|
||||||
|
<List disablePadding>
|
||||||
|
{adminNav.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={isNavActive(location.pathname, item.path)}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
{isSysAdmin && sysadminNav.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={isNavActive(location.pathname, item.path)}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
sx={{ py: 1 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{user && (
|
||||||
|
<Box sx={{ p: 2, mt: 'auto', borderTop: '1px solid #d9dde0' }}>
|
||||||
|
<Box sx={{ fontSize: '13px', fontWeight: 500 }}>{user.displayName}</Box>
|
||||||
|
<Box sx={{ fontSize: '11px', color: '#9aa0a6' }}>{user.role}</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
web/src/components/Topbar.tsx
Normal file
58
web/src/components/Topbar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, height: 64 }}>
|
||||||
|
<Toolbar sx={{ height: 64, minHeight: 64 }}>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
edge="start"
|
||||||
|
onClick={() => dispatch(toggleSidebar())}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6" noWrap sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||||
|
Proposal System
|
||||||
|
</Typography>
|
||||||
|
{user && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Avatar sx={{ width: 32, height: 32, bgcolor: '#1fa8de', fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
|
||||||
|
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: '#fff' }}>
|
||||||
|
{user.displayName}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>
|
||||||
|
{user.role}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: '1px', height: 24, bgcolor: 'rgba(255,255,255,0.15)', mx: 0.5 }} />
|
||||||
|
<IconButton
|
||||||
|
onClick={logout}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
|
||||||
|
>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
web/src/components/admin/LineItemEditor.tsx
Normal file
249
web/src/components/admin/LineItemEditor.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import type { PricingMode, LineItemSource } from '../../lib/api/lineItems';
|
||||||
|
|
||||||
|
export interface EditableLineItem {
|
||||||
|
id: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number | null;
|
||||||
|
totalPrice: number;
|
||||||
|
pricingMode: PricingMode;
|
||||||
|
sortOrder: number;
|
||||||
|
source: LineItemSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineItemEditorProps {
|
||||||
|
items: EditableLineItem[];
|
||||||
|
onChange: (items: EditableLineItem[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<LineItemSource, 'primary' | 'secondary' | 'default' | 'success'> = {
|
||||||
|
AI: 'primary',
|
||||||
|
Vendor: 'secondary',
|
||||||
|
Manual: 'default',
|
||||||
|
Historical: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNITS = ['each', 'sq ft', 'linear ft', 'hours', 'days', 'lot', 'gallon', 'ton'];
|
||||||
|
|
||||||
|
function createEmptyItem(sortOrder: number): EditableLineItem {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'each',
|
||||||
|
unitPrice: 0,
|
||||||
|
totalPrice: 0,
|
||||||
|
pricingMode: 'UnitPrice',
|
||||||
|
sortOrder,
|
||||||
|
source: 'Manual',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LineItemEditor({ items, onChange, disabled = false }: LineItemEditorProps) {
|
||||||
|
const updateItem = (index: number, field: keyof EditableLineItem, value: unknown) => {
|
||||||
|
const updated = [...items];
|
||||||
|
const current = updated[index];
|
||||||
|
if (!current) return;
|
||||||
|
updated[index] = { ...current, [field]: value };
|
||||||
|
|
||||||
|
if (field === 'quantity' || field === 'unitPrice') {
|
||||||
|
const item = updated[index]!;
|
||||||
|
if (item.pricingMode === 'UnitPrice' || item.pricingMode === 'Both') {
|
||||||
|
item.totalPrice = (item.quantity || 0) * (item.unitPrice || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
|
||||||
|
onChange([...items, createEmptyItem(maxSort + 1)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
const updated = items.filter((_, i) => i !== index);
|
||||||
|
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveItem = (index: number, direction: -1 | 1) => {
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= items.length) return;
|
||||||
|
const updated = [...items];
|
||||||
|
const a = updated[index];
|
||||||
|
const b = updated[targetIndex];
|
||||||
|
if (!a || !b) return;
|
||||||
|
updated[index] = b;
|
||||||
|
updated[targetIndex] = a;
|
||||||
|
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
Line Items ({items.length})
|
||||||
|
</Typography>
|
||||||
|
{!disabled && (
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addItem}>
|
||||||
|
Add Row
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer sx={{ maxHeight: 480 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 60 }}>#</TableCell>
|
||||||
|
<TableCell sx={{ minWidth: 200 }}>Description</TableCell>
|
||||||
|
<TableCell sx={{ width: 80 }}>Qty</TableCell>
|
||||||
|
<TableCell sx={{ width: 100 }}>Unit</TableCell>
|
||||||
|
<TableCell sx={{ width: 110 }}>Unit Price</TableCell>
|
||||||
|
<TableCell sx={{ width: 110 }}>Total</TableCell>
|
||||||
|
<TableCell sx={{ width: 90 }}>Source</TableCell>
|
||||||
|
{!disabled && <TableCell sx={{ width: 100 }}>Actions</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={item.id ?? `new-${index}`} hover>
|
||||||
|
<TableCell>
|
||||||
|
{!disabled && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0}>
|
||||||
|
<ArrowUpwardIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="caption">{index + 1}</Typography>
|
||||||
|
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1}>
|
||||||
|
<ArrowDownwardIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{disabled && <Typography variant="caption">{index + 1}</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
maxRows={3}
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{UNITS.map((u) => (
|
||||||
|
<MenuItem key={u} value={u}>{u}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice ?? ''}
|
||||||
|
onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={disabled || item.pricingMode === 'TotalPrice'}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={item.totalPrice}
|
||||||
|
onChange={(e) => updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)}
|
||||||
|
disabled={disabled || item.pricingMode === 'UnitPrice'}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={item.source}
|
||||||
|
size="small"
|
||||||
|
color={SOURCE_COLORS[item.source]}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
{!disabled && (
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Remove">
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeItem(index)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 3 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No line items yet. {!disabled && 'Click "Add Row" to start.'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
web/src/components/admin/SimilarProposalsPanel.tsx
Normal file
135
web/src/components/admin/SimilarProposalsPanel.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Skeleton,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { adminApi } from '../../lib/api/admin';
|
||||||
|
import type { EditableLineItem } from './LineItemEditor';
|
||||||
|
|
||||||
|
interface SimilarProposal {
|
||||||
|
proposalNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
serviceCategory: string;
|
||||||
|
similarityScore: number;
|
||||||
|
totalBidAmount: number;
|
||||||
|
lineItems: Array<{
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number | null;
|
||||||
|
totalPrice: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimilarProposalsPanelProps {
|
||||||
|
proposalId: string;
|
||||||
|
onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
|
||||||
|
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
|
||||||
|
queryKey: ['similarProposals', proposalId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await adminApi.getSimilar(proposalId);
|
||||||
|
return result as SimilarProposal[];
|
||||||
|
},
|
||||||
|
enabled: !!proposalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Similar Proposals</Typography>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="rectangular" height={40} sx={{ mb: 1, borderRadius: 1 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Similar Proposals {similar && similar.length > 0 && `(${similar.length})`}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{(!similar || similar.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No similar proposals found. AI suggestions will populate this panel once the RAG engine is active.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{similar?.map((sp, idx) => (
|
||||||
|
<Accordion key={idx} disableGutters sx={{ '&:before': { display: 'none' }, mb: 1 }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ minHeight: 'auto', px: 1 }}>
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{sp.proposalNumber}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`${Math.round(sp.similarityScore * 100)}%`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{sp.customerName} - {sp.serviceCategory}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ px: 1, pt: 0 }}>
|
||||||
|
{sp.lineItems.map((li, liIdx) => (
|
||||||
|
<Box
|
||||||
|
key={liIdx}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 0.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" noWrap>{li.description}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 'auto', p: 0.5 }}
|
||||||
|
onClick={() =>
|
||||||
|
onPullLineItem({
|
||||||
|
description: li.description,
|
||||||
|
quantity: li.quantity,
|
||||||
|
unit: li.unit,
|
||||||
|
unitPrice: li.unitPrice,
|
||||||
|
totalPrice: li.totalPrice,
|
||||||
|
pricingMode: li.unitPrice ? 'UnitPrice' : 'TotalPrice',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
web/src/components/admin/VendorDataPanel.tsx
Normal file
90
web/src/components/admin/VendorDataPanel.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Box, Typography, Skeleton, Chip } from '@mui/material';
|
||||||
|
import apiClient from '../../lib/api/client';
|
||||||
|
|
||||||
|
interface VendorProposal {
|
||||||
|
id: string;
|
||||||
|
vendorName: string;
|
||||||
|
fileName: string;
|
||||||
|
totalVendorCost: number;
|
||||||
|
processingStatus: string;
|
||||||
|
extractedData: {
|
||||||
|
lineItems?: Array<{
|
||||||
|
description: string;
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
total?: number;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorDataPanelProps {
|
||||||
|
proposalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
||||||
|
const { data: vendors, isLoading } = useQuery<VendorProposal[]>({
|
||||||
|
queryKey: ['vendorProposals', proposalId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!proposalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton variant="rectangular" height={60} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vendors || vendors.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Vendor Data</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">No vendor proposals attached</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Vendor Data</Typography>
|
||||||
|
{vendors.map((vp) => (
|
||||||
|
<Box key={vp.id} sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{vp.vendorName}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={vp.processingStatus}
|
||||||
|
size="small"
|
||||||
|
color={vp.processingStatus === 'Complete' ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">{vp.fileName}</Typography>
|
||||||
|
|
||||||
|
{vp.totalVendorCost > 0 && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>
|
||||||
|
Vendor Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(vp.totalVendorCost)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vp.extractedData?.lineItems && vp.extractedData.lineItems.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1, pl: 1, borderLeft: 2, borderColor: 'divider' }}>
|
||||||
|
{vp.extractedData.lineItems.map((li, i) => (
|
||||||
|
<Box key={i} sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||||
|
{li.description}
|
||||||
|
</Typography>
|
||||||
|
{li.total != null && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
${li.total.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
web/src/constants/index.ts
Normal file
21
web/src/constants/index.ts
Normal file
|
|
@ -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;
|
||||||
9
web/src/constants/queryKeys.ts
Normal file
9
web/src/constants/queryKeys.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const QUERY_KEYS = {
|
||||||
|
proposals: 'proposals',
|
||||||
|
proposal: 'proposal',
|
||||||
|
proposalLineItems: 'proposalLineItems',
|
||||||
|
customers: 'customers',
|
||||||
|
users: 'users',
|
||||||
|
dashboard: 'dashboard',
|
||||||
|
auditTrail: 'auditTrail',
|
||||||
|
} as const;
|
||||||
35
web/src/hooks/useAuth.ts
Normal file
35
web/src/hooks/useAuth.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
web/src/hooks/usePaginatedList.ts
Normal file
68
web/src/hooks/usePaginatedList.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
|
||||||
|
|
||||||
|
interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchFn<T> = (params: Record<string, unknown>) => Promise<PaginatedResult<T>>;
|
||||||
|
|
||||||
|
export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<string, unknown> = {}) {
|
||||||
|
const [rows, setRows] = useState<T[]>([]);
|
||||||
|
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<ReturnType<typeof setTimeout> | 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
27
web/src/index.css
Normal file
27
web/src/index.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
76
web/src/lib/api/admin.ts
Normal file
76
web/src/lib/api/admin.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const res = await apiClient.get('/admin/dashboard');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<void> => {
|
||||||
|
await apiClient.put(`/proposals/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
approveProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/approve`);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/send`);
|
||||||
|
},
|
||||||
|
|
||||||
|
reviseProposal: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/revise`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHistory: async (id: string): Promise<unknown[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/history`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAudit: async (id: string): Promise<AuditEntry[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/audit`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSuggestions: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/proposals/${id}/generate-suggestions`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSimilar: async (id: string): Promise<unknown[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${id}/similar`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
web/src/lib/api/auth.ts
Normal file
33
web/src/lib/api/auth.ts
Normal file
|
|
@ -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<AuthUser> => {
|
||||||
|
const res = await apiClient.post('/auth/callback', { code, redirectUri });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
devLogin: async (email: string, displayName: string, role: string): Promise<AuthUser> => {
|
||||||
|
const res = await apiClient.post('/auth/dev-login', { email, displayName, role });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async (): Promise<UserProfile> => {
|
||||||
|
const res = await apiClient.get('/users/me');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
62
web/src/lib/api/client.ts
Normal file
62
web/src/lib/api/client.ts
Normal file
|
|
@ -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;
|
||||||
21
web/src/lib/api/customers.ts
Normal file
21
web/src/lib/api/customers.ts
Normal file
|
|
@ -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<Customer[]> => {
|
||||||
|
const params = query ? `?query=${encodeURIComponent(query)}` : '';
|
||||||
|
const res = await apiClient.get(`/customers${params}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: { name: string; addresses?: string[] }): Promise<Customer> => {
|
||||||
|
const res = await apiClient.post('/customers', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
63
web/src/lib/api/lineItems.ts
Normal file
63
web/src/lib/api/lineItems.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both';
|
||||||
|
export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical';
|
||||||
|
|
||||||
|
export interface LineItem {
|
||||||
|
id: string;
|
||||||
|
proposalId: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number | null;
|
||||||
|
totalPrice: number;
|
||||||
|
pricingMode: PricingMode;
|
||||||
|
sortOrder: number;
|
||||||
|
source: LineItemSource;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLineItemRequest {
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number | null;
|
||||||
|
totalPrice: number;
|
||||||
|
pricingMode: PricingMode;
|
||||||
|
sortOrder: number;
|
||||||
|
source: LineItemSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLineItemEntry {
|
||||||
|
id: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number | null;
|
||||||
|
totalPrice: number;
|
||||||
|
pricingMode: PricingMode;
|
||||||
|
sortOrder: number;
|
||||||
|
source: LineItemSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lineItemsApi = {
|
||||||
|
getAll: async (proposalId: string): Promise<LineItem[]> => {
|
||||||
|
const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (proposalId: string, data: CreateLineItemRequest): Promise<LineItem> => {
|
||||||
|
const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => {
|
||||||
|
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (proposalId: string, itemId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
112
web/src/lib/api/proposals.ts
Normal file
112
web/src/lib/api/proposals.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
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<ProposalDetail> => {
|
||||||
|
const res = await apiClient.post('/proposals', data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll: async (filters: ProposalFilters = {}): Promise<PagedResponse<ProposalListItem>> => {
|
||||||
|
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<ProposalDetail> => {
|
||||||
|
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<ProposalStats> => {
|
||||||
|
const res = await apiClient.get('/proposals/stats');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProposalStats {
|
||||||
|
totalCount: number;
|
||||||
|
inReviewCount: number;
|
||||||
|
approvedCount: number;
|
||||||
|
sentCount: number;
|
||||||
|
}
|
||||||
19
web/src/lib/queryClient.ts
Normal file
19
web/src/lib/queryClient.ts
Normal file
|
|
@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
function App() {
|
import { Provider } from 'react-redux';
|
||||||
return <div>Proposal System</div>;
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
<ToastContainer position="bottom-right" autoClose={4000} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
268
web/src/pages/admin/dashboard/AdminDashboard.tsx
Normal file
268
web/src/pages/admin/dashboard/AdminDashboard.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success'> = {
|
||||||
|
Draft: 'default',
|
||||||
|
InReview: 'info',
|
||||||
|
Approved: 'success',
|
||||||
|
Sent: 'success',
|
||||||
|
Revised: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||||||
|
Standard: 'default',
|
||||||
|
Urgent: 'warning',
|
||||||
|
Emergency: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ borderRadius: '8px', transition: 'transform 0.2s, box-shadow 0.2s', '&:hover': { transform: 'translateY(-2px)', boxShadow: '0 4px 8px rgba(0,0,0,0.15)' } }}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
|
||||||
|
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
|
||||||
|
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||||||
|
queryKey: [QUERY_KEYS.dashboard],
|
||||||
|
queryFn: adminApi.getDashboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
err,
|
||||||
|
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3 }}>Admin Dashboard</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<PendingActionsIcon fontSize="large" />}
|
||||||
|
label="Pending Review"
|
||||||
|
value={String(stats?.pendingCount ?? 0)}
|
||||||
|
color="#1565c0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<CheckCircleIcon fontSize="large" />}
|
||||||
|
label="Approved This Week"
|
||||||
|
value={String(stats?.approvedThisWeek ?? 0)}
|
||||||
|
color="#2e7d32"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<TimerIcon fontSize="large" />}
|
||||||
|
label="Avg Turnaround"
|
||||||
|
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
|
||||||
|
color="#f57c00"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
|
||||||
|
) : (
|
||||||
|
<StatCard
|
||||||
|
icon={<DescriptionIcon fontSize="large" />}
|
||||||
|
label="Total Proposals"
|
||||||
|
value={String(stats?.totalProposals ?? 0)}
|
||||||
|
color="#5e35b1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{ width: 240 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value=""
|
||||||
|
sx={{ width: 160 }}
|
||||||
|
onChange={() => {}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{SERVICE_CATEGORIES.map((c) => (
|
||||||
|
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Priority"
|
||||||
|
value=""
|
||||||
|
sx={{ width: 140 }}
|
||||||
|
onChange={() => {}}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<MenuItem key={p} value={p}>{p}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<Typography color="error" sx={{ mb: 2 }}>{err}</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Proposal #</TableCell>
|
||||||
|
<TableCell>Customer</TableCell>
|
||||||
|
<TableCell>WO #</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Priority</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Bid Amount</TableCell>
|
||||||
|
<TableCell>Submitted</TableCell>
|
||||||
|
<TableCell>Submitted By</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 9 }).map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/admin/proposals/${row.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
||||||
|
<TableCell>{row.customerName}</TableCell>
|
||||||
|
<TableCell>{row.workOrderNumber}</TableCell>
|
||||||
|
<TableCell>{row.serviceCategory}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={row.priority} size="small" color={PRIORITY_COLORS[row.priority] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||||
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||||
|
<TableCell>{row.submittedByName || '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography color="text.secondary">No proposals in queue</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page - 1}
|
||||||
|
onPageChange={(_, newPage) => setPage(newPage + 1)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
423
web/src/pages/admin/workspace/AdminWorkspace.tsx
Normal file
423
web/src/pages/admin/workspace/AdminWorkspace.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success'> = {
|
||||||
|
Draft: 'default',
|
||||||
|
InReview: 'info',
|
||||||
|
Approved: 'success',
|
||||||
|
Sent: 'success',
|
||||||
|
Revised: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminWorkspace() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
|
||||||
|
const [refinedScope, setRefinedScope] = useState('');
|
||||||
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
|
||||||
|
queryKey: [QUERY_KEYS.proposals, id],
|
||||||
|
queryFn: () => proposalsApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fetchedLineItems } = useQuery<LineItem[]>({
|
||||||
|
queryKey: [QUERY_KEYS.proposalLineItems, id],
|
||||||
|
queryFn: () => lineItemsApi.getAll(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchedLineItems) {
|
||||||
|
setLineItems(
|
||||||
|
fetchedLineItems.map((li) => ({
|
||||||
|
id: li.id,
|
||||||
|
description: li.description,
|
||||||
|
quantity: li.quantity,
|
||||||
|
unit: li.unit,
|
||||||
|
unitPrice: li.unitPrice,
|
||||||
|
totalPrice: li.totalPrice,
|
||||||
|
pricingMode: li.pricingMode,
|
||||||
|
sortOrder: li.sortOrder,
|
||||||
|
source: li.source,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [fetchedLineItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (proposal) {
|
||||||
|
setRefinedScope(proposal.refinedScope || proposal.scopeOfWork);
|
||||||
|
}
|
||||||
|
}, [proposal]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await adminApi.updateProposal(id!, { refinedScope });
|
||||||
|
await lineItemsApi.bulkUpdate(
|
||||||
|
id!,
|
||||||
|
lineItems.map((li) => ({
|
||||||
|
id: li.id,
|
||||||
|
description: li.description,
|
||||||
|
quantity: li.quantity,
|
||||||
|
unit: li.unit,
|
||||||
|
unitPrice: li.unitPrice,
|
||||||
|
totalPrice: li.totalPrice,
|
||||||
|
pricingMode: li.pricingMode,
|
||||||
|
sortOrder: li.sortOrder,
|
||||||
|
source: li.source,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||||
|
setDirty(false);
|
||||||
|
toast.success('Changes saved');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: () => adminApi.approveProposal(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||||
|
setApproveDialogOpen(false);
|
||||||
|
toast.success('Proposal approved');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: () => adminApi.sendProposal(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||||
|
toast.success('Proposal marked as sent');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviseMutation = useMutation({
|
||||||
|
mutationFn: () => adminApi.reviseProposal(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
||||||
|
toast.success('Revision created');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenerateMutation = useMutation({
|
||||||
|
mutationFn: () => adminApi.generateSuggestions(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.info('AI suggestion generation started');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const 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 (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposal) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography color="error">Proposal not found</Typography>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEditable = proposal.status === 'InReview' || proposal.status === 'Revised';
|
||||||
|
const isApproved = proposal.status === 'Approved';
|
||||||
|
const isSent = proposal.status === 'Sent';
|
||||||
|
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||||
|
{proposal.proposalNumber}
|
||||||
|
</Typography>
|
||||||
|
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
|
||||||
|
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Proposal Info Bar */}
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Customer</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">WO #</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Category</Typography>
|
||||||
|
<Typography variant="body2">{proposal.serviceCategory}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Priority</Typography>
|
||||||
|
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Address</Typography>
|
||||||
|
<Typography variant="body2">{proposal.customerAddress}</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Three-Panel Layout */}
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{/* Left Panel: Reference */}
|
||||||
|
<Grid size={{ xs: 12, md: 3 }}>
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">Submitted By</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">Submitted</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
{new Date(proposal.submittedAt).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">Original Scope</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}>
|
||||||
|
{proposal.scopeOfWork}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{proposal.notes && (
|
||||||
|
<>
|
||||||
|
<Typography variant="caption" color="text.secondary">Notes</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
|
||||||
|
{proposal.notes}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<VendorDataPanel proposalId={id!} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Center Panel: Editor */}
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
maxRows={6}
|
||||||
|
value={refinedScope}
|
||||||
|
onChange={(e) => { setRefinedScope(e.target.value); setDirty(true); }}
|
||||||
|
disabled={!isEditable}
|
||||||
|
size="small"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LineItemEditor
|
||||||
|
items={lineItems}
|
||||||
|
onChange={handleLineItemsChange}
|
||||||
|
disabled={!isEditable}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Right Panel: Similar Proposals */}
|
||||||
|
<Grid size={{ xs: 12, md: 3 }}>
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<SimilarProposalsPanel
|
||||||
|
proposalId={id!}
|
||||||
|
onPullLineItem={(item) => {
|
||||||
|
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
|
||||||
|
handleLineItemsChange([
|
||||||
|
...lineItems,
|
||||||
|
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
disabled={!isEditable}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{dirty && (
|
||||||
|
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
|
||||||
|
)}
|
||||||
|
{!dirty && <Box sx={{ flexGrow: 1 }} />}
|
||||||
|
|
||||||
|
{isEditable && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => regenerateMutation.mutate()}
|
||||||
|
disabled={regenerateMutation.isPending}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !dirty}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={<CheckCircleIcon />}
|
||||||
|
onClick={() => setApproveDialogOpen(true)}
|
||||||
|
disabled={lineItems.length === 0 || bidTotal <= 0}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isApproved && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PictureAsPdfIcon />}
|
||||||
|
onClick={() => pdfMutation.mutate()}
|
||||||
|
disabled={pdfMutation.isPending}
|
||||||
|
>
|
||||||
|
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={<SendIcon />}
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
Mark as Sent
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSent && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<HistoryIcon />}
|
||||||
|
onClick={() => reviseMutation.mutate()}
|
||||||
|
disabled={reviseMutation.isPending}
|
||||||
|
>
|
||||||
|
Create Revision
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Approve Confirmation Dialog */}
|
||||||
|
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
|
||||||
|
<DialogTitle>Approve Proposal</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography gutterBottom>
|
||||||
|
Approve <strong>{proposal.proposalNumber}</strong> for {proposal.customerName}?
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2">Line Items: {lineItems.length}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)}
|
||||||
|
</Typography>
|
||||||
|
{proposal.vendorTotalCost != null && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setApproveDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
onClick={() => approveMutation.mutate()}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
>
|
||||||
|
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/pages/auth/AuthCallback.tsx
Normal file
61
web/src/pages/auth/AuthCallback.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography color="text.secondary">Completing sign-in...</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
web/src/pages/auth/LoginPage.tsx
Normal file
169
web/src/pages/auth/LoginPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
pt: '7%',
|
||||||
|
bgcolor: '#333',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 400, mx: 2 }}>
|
||||||
|
<Card sx={{ borderRadius: 0, boxShadow: '0 0 10px rgba(0,0,0,0.3)', border: 'none' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderBottom: '1px solid #b8b5b5',
|
||||||
|
px: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '24px', fontWeight: 600, color: '#003A5D' }}>
|
||||||
|
Sea Haven Industries
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#003A5D',
|
||||||
|
height: 40,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid #b8b5b5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '14px', fontWeight: 700, color: '#fff' }}>
|
||||||
|
Proposal System
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ p: '30px', textAlign: 'center' }}>
|
||||||
|
{isDevMode ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Development Mode — Select a role to sign in as:
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<AdminPanelSettingsIcon />}
|
||||||
|
onClick={() => handleDevLogin('SysAdmin')}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
||||||
|
>
|
||||||
|
Sign in as SysAdmin
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<EngineeringIcon />}
|
||||||
|
onClick={() => handleDevLogin('Admin')}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ py: 1.5, bgcolor: '#0c4f6f', '&:hover': { bgcolor: '#083a52' } }}
|
||||||
|
>
|
||||||
|
Sign in as Admin
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<PersonIcon />}
|
||||||
|
onClick={() => handleDevLogin('Dispatcher')}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ py: 1.5 }}
|
||||||
|
>
|
||||||
|
Sign in as Dispatcher
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Dev login is only available when VITE_COGNITO_CLIENT_ID is not set
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
Sign in with your Sea Haven Google account to continue.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<GoogleIcon />}
|
||||||
|
href={buildLoginUrl()}
|
||||||
|
sx={{ py: 1.5, bgcolor: '#003A5D', '&:hover': { bgcolor: '#00527d' } }}
|
||||||
|
>
|
||||||
|
Sign in with Google
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Typography sx={{ textAlign: 'center', color: '#fff', fontSize: '12px', mt: 2, opacity: 0.7 }}>
|
||||||
|
Sea Haven Industries © {new Date().getFullYear()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
web/src/pages/dashboard/Dashboard.tsx
Normal file
188
web/src/pages/dashboard/Dashboard.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
|
||||||
|
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 (
|
||||||
|
<Card sx={{ borderRadius: '8px', transition: 'transform 0.2s, box-shadow 0.2s', '&:hover': { transform: 'translateY(-2px)', boxShadow: '0 4px 8px rgba(0,0,0,0.15)' } }}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: '#0c4f6f' }}>{icon}</Box>
|
||||||
|
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProposalStats>({
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Dashboard</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddCircleIcon />}
|
||||||
|
onClick={() => navigate('/proposals/new')}
|
||||||
|
>
|
||||||
|
New Proposal
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
|
) : (
|
||||||
|
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} />
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
|
) : (
|
||||||
|
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} />
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
|
) : (
|
||||||
|
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} />
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6, sm: 3 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
|
||||||
|
) : (
|
||||||
|
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} />
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Recent Proposals</Typography>
|
||||||
|
<Button size="small" onClick={() => navigate('/proposals')}>
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Proposal #</TableCell>
|
||||||
|
<TableCell>Customer</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Bid Amount</TableCell>
|
||||||
|
<TableCell>Submitted</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 6 }).map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: proposals.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/proposals/${row.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
||||||
|
<TableCell>{row.customerName}</TableCell>
|
||||||
|
<TableCell>{row.serviceCategory}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||||
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!isLoading && proposals.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No proposals yet. Submit your first one!
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
web/src/pages/proposals/detail/ProposalDetailPage.tsx
Normal file
178
web/src/pages/proposals/detail/ProposalDetailPage.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
|
||||||
|
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 (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">{value || '-'}</Typography>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProposalDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
||||||
|
queryKey: ['proposals', id],
|
||||||
|
queryFn: () => proposalsApi.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={300} height={40} />
|
||||||
|
<Skeleton variant="rectangular" height={400} sx={{ mt: 2, borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !proposal) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography color="error">
|
||||||
|
{error instanceof Error ? error.message : 'Proposal not found'}
|
||||||
|
</Typography>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')} sx={{ mt: 2 }}>
|
||||||
|
Back to Proposals
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStep = STATUS_ORDER.indexOf(proposal.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
||||||
|
{proposal.proposalNumber}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={proposal.status}
|
||||||
|
color={STATUS_COLORS[proposal.status] || 'default'}
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||||
|
Status Timeline
|
||||||
|
</Typography>
|
||||||
|
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
|
||||||
|
{STATUS_ORDER.map((label) => (
|
||||||
|
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||||
|
Proposal Details
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
|
||||||
|
<InfoRow label="Customer" value={proposal.customerName} />
|
||||||
|
<InfoRow label="Address" value={proposal.customerAddress} />
|
||||||
|
<InfoRow label="Category" value={proposal.serviceCategory} />
|
||||||
|
<InfoRow label="Priority" value={proposal.priority} />
|
||||||
|
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
|
||||||
|
<InfoRow label="Submitted By" value={proposal.submittedByName} />
|
||||||
|
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
|
||||||
|
<InfoRow label="Bid Amount" value={formatCurrency(proposal.totalBidAmount)} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Scope of Work
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
|
||||||
|
{proposal.refinedScope || proposal.scopeOfWork}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{proposal.notes && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Notes
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
|
||||||
|
{proposal.notes}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(proposal.approvedAt || proposal.sentAt) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||||
|
Approval & Delivery
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<InfoRow label="Approved At" value={formatDateTime(proposal.approvedAt)} />
|
||||||
|
<InfoRow label="Sent At" value={formatDateTime(proposal.sentAt)} />
|
||||||
|
{proposal.vendorTotalCost !== null && (
|
||||||
|
<InfoRow label="Vendor Cost" value={formatCurrency(proposal.vendorTotalCost)} />
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
web/src/pages/proposals/form/ProposalFormPage.tsx
Normal file
274
web/src/pages/proposals/form/ProposalFormPage.tsx
Normal file
|
|
@ -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<CreateProposalRequest>({
|
||||||
|
workOrderNumber: '',
|
||||||
|
customerName: '',
|
||||||
|
customerAddress: '',
|
||||||
|
scopeOfWork: '',
|
||||||
|
serviceCategory: '',
|
||||||
|
priority: 'Standard',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [customerLoading, setCustomerLoading] = useState(false);
|
||||||
|
const [addresses, setAddresses] = useState<string[]>([]);
|
||||||
|
const [vendorFile, setVendorFile] = useState<File | null>(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 (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3 }}>
|
||||||
|
New Proposal Request
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
label="Work Order #"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={form.workOrderNumber}
|
||||||
|
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={customers}
|
||||||
|
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
|
||||||
|
loading={customerLoading}
|
||||||
|
onInputChange={(_, value) => searchCustomers(value)}
|
||||||
|
onChange={handleCustomerSelect}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Customer"
|
||||||
|
required
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{customerLoading && <CircularProgress size={20} />}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
{addresses.length > 1 ? (
|
||||||
|
<TextField
|
||||||
|
label="Customer Address"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
value={form.customerAddress}
|
||||||
|
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
||||||
|
>
|
||||||
|
{addresses.map((addr) => (
|
||||||
|
<MenuItem key={addr} value={addr}>
|
||||||
|
{addr}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
label="Customer Address"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={form.customerAddress}
|
||||||
|
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
label="Service Category"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
value={form.serviceCategory}
|
||||||
|
onChange={(e) => handleChange('serviceCategory', e.target.value)}
|
||||||
|
>
|
||||||
|
{SERVICE_CATEGORIES.map((cat) => (
|
||||||
|
<MenuItem key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
label="Priority"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => handleChange('priority', e.target.value)}
|
||||||
|
>
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<MenuItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Scope of Work"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
value={form.scopeOfWork}
|
||||||
|
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
|
||||||
|
placeholder="Describe the work needed..."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Notes (optional)"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => handleChange('notes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Button variant="outlined" component="label">
|
||||||
|
{vendorFile ? vendorFile.name : 'Attach Vendor Proposal (PDF)'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept=".pdf"
|
||||||
|
onChange={(e) => setVendorFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="text" onClick={() => navigate(-1)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SendIcon />}
|
||||||
|
disabled={!isValid || createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
web/src/pages/proposals/list/ProposalListPage.tsx
Normal file
168
web/src/pages/proposals/list/ProposalListPage.tsx
Normal file
|
|
@ -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<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
|
||||||
|
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<ProposalListItem>(proposalsApi.getAll);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">My Proposals</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddCircleIcon />}
|
||||||
|
onClick={() => navigate('/proposals/new')}
|
||||||
|
>
|
||||||
|
New Proposal
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: 0 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search proposals..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
sx={{ mb: 2, width: 320 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<Typography color="error" sx={{ mb: 2 }}>
|
||||||
|
{err}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Proposal #</TableCell>
|
||||||
|
<TableCell>Customer</TableCell>
|
||||||
|
<TableCell>WO #</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Priority</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Bid Amount</TableCell>
|
||||||
|
<TableCell>Submitted</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton variant="text" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/proposals/${row.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
||||||
|
<TableCell>{row.customerName}</TableCell>
|
||||||
|
<TableCell>{row.workOrderNumber}</TableCell>
|
||||||
|
<TableCell>{row.serviceCategory}</TableCell>
|
||||||
|
<TableCell>{row.priority}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={row.status}
|
||||||
|
size="small"
|
||||||
|
color={STATUS_COLORS[row.status] || 'default'}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
|
||||||
|
<TableCell>{formatDate(row.submittedAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No proposals found. Create your first one!
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page - 1}
|
||||||
|
onPageChange={(_, newPage) => setPage(newPage + 1)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
web/src/theme.ts
Normal file
164
web/src/theme.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -5,5 +5,11 @@ export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue