mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +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:
|
||||
name: CDK Synth
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: infra
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Publish .NET API (required for CDK asset path)
|
||||
run: dotnet publish api/src/ProposalSystem.Api/ProposalSystem.Api.csproj --configuration Release --runtime linux-arm64 --self-contained
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
|
@ -73,4 +77,6 @@ jobs:
|
|||
cache-dependency-path: infra/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
working-directory: infra
|
||||
- run: npx cdk synth
|
||||
working-directory: infra
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,6 +27,7 @@ cdk.out/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
appsettings.Development.json
|
||||
|
||||
# IDE
|
||||
.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.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -111,4 +112,27 @@ public class FilesController : ControllerBase
|
|||
|
||||
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
|
||||
}
|
||||
|
||||
[HttpGet("vendors")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<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 IJobPublisher _jobPublisher;
|
||||
private readonly ISimilarProposalService _similarService;
|
||||
|
||||
public ProposalsController(IProposalService proposalService, IJobPublisher jobPublisher)
|
||||
public ProposalsController(
|
||||
IProposalService proposalService,
|
||||
IJobPublisher jobPublisher,
|
||||
ISimilarProposalService similarService)
|
||||
{
|
||||
_proposalService = proposalService;
|
||||
_jobPublisher = jobPublisher;
|
||||
_similarService = similarService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
|
@ -95,6 +100,14 @@ public class ProposalsController : ControllerBase
|
|||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/similar")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<ActionResult<IReadOnlyList<SimilarProposalResponse>>> GetSimilar(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await _similarService.GetSimilarProposalsAsync(id, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/generate-suggestions")]
|
||||
[Authorize(Roles = "admins,sysadmins")]
|
||||
public async Task<IActionResult> GenerateSuggestions(Guid id, CancellationToken ct)
|
||||
|
|
@ -110,4 +123,22 @@ public class ProposalsController : ControllerBase
|
|||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = id, trigger = "regenerate" }, ct);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<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.SecretsManager;
|
||||
using Amazon.SQS;
|
||||
|
|
@ -14,11 +15,17 @@ using ProposalSystem.Infrastructure.Services;
|
|||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// AWS SDK clients
|
||||
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
||||
builder.Services.AddAWSService<IAmazonS3>();
|
||||
builder.Services.AddAWSService<IAmazonSQS>();
|
||||
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
||||
// Dev mode flag (read early for conditional setup)
|
||||
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode");
|
||||
|
||||
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
|
||||
if (!devMode)
|
||||
{
|
||||
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
|
||||
builder.Services.AddAWSService<IAmazonS3>();
|
||||
builder.Services.AddAWSService<IAmazonSQS>();
|
||||
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
||||
}
|
||||
|
||||
// Database
|
||||
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
|
||||
|
|
@ -35,8 +42,21 @@ else
|
|||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
}
|
||||
|
||||
// Internal API key (for Lambda-to-API calls)
|
||||
var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"];
|
||||
if (!string.IsNullOrEmpty(internalApiKeySecretArn))
|
||||
{
|
||||
var smClient = new AmazonSecretsManagerClient();
|
||||
var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest
|
||||
{
|
||||
SecretId = internalApiKeySecretArn,
|
||||
}).GetAwaiter().GetResult();
|
||||
builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
var cognitoAuthority = builder.Configuration["Auth:Authority"];
|
||||
|
||||
if (!string.IsNullOrEmpty(cognitoAuthority))
|
||||
{
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
|
|
@ -49,6 +69,26 @@ if (!string.IsNullOrEmpty(cognitoAuthority))
|
|||
ValidateIssuer = true,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
RoleClaimType = "cognito:groups",
|
||||
};
|
||||
});
|
||||
}
|
||||
else if (devMode)
|
||||
{
|
||||
var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!;
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "proposal-system-dev",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "proposal-system-dev",
|
||||
ValidateLifetime = true,
|
||||
RoleClaimType = "cognito:groups",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -68,13 +108,27 @@ builder.Services.AddScoped<ILineItemService, LineItemService>();
|
|||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
||||
builder.Services.AddScoped<IS3Service, S3Service>();
|
||||
builder.Services.AddScoped<IJobPublisher>(sp =>
|
||||
if (devMode)
|
||||
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>();
|
||||
var queueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
||||
return new SqsJobPublisher(sqsClient, queueUrl);
|
||||
});
|
||||
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
|
||||
}
|
||||
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
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
|
||||
|
|
@ -112,8 +166,18 @@ var app = builder.Build();
|
|||
|
||||
app.UseMiddleware<GlobalExceptionHandler>();
|
||||
app.UseCors();
|
||||
app.UseMiddleware<InternalApiKeyMiddleware>();
|
||||
app.UseAuthentication();
|
||||
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.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>
|
||||
<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="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||
|
|
|
|||
|
|
@ -25,28 +25,46 @@ public class CurrentUserService : ICurrentUserService
|
|||
public string? IpAddress =>
|
||||
_httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
private User GetUser()
|
||||
public async Task ResolveAsync()
|
||||
{
|
||||
if (_cachedUser != null) return _cachedUser;
|
||||
if (_cachedUser != null) return;
|
||||
|
||||
var cognitoSub = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? _httpContext.HttpContext?.User.FindFirstValue("sub")
|
||||
var principal = _httpContext.HttpContext?.User
|
||||
?? throw new UnauthorizedAccessException("No authenticated user");
|
||||
|
||||
_cachedUser = _db.Users
|
||||
.FirstOrDefault(u => u.CognitoSub == cognitoSub);
|
||||
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var sub = principal.FindFirstValue("sub");
|
||||
|
||||
if (userId != null && Guid.TryParse(userId, out var parsedId))
|
||||
{
|
||||
_cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == parsedId);
|
||||
}
|
||||
|
||||
if (_cachedUser == null && sub != null)
|
||||
{
|
||||
_cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.CognitoSub == sub);
|
||||
}
|
||||
|
||||
if (_cachedUser == null)
|
||||
{
|
||||
var email = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
||||
?? _httpContext.HttpContext?.User.FindFirstValue("email")
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? principal.FindFirstValue("email")
|
||||
?? "unknown@seahaven.com";
|
||||
|
||||
var name = _httpContext.HttpContext?.User.FindFirstValue("name")
|
||||
_cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
|
||||
}
|
||||
|
||||
if (_cachedUser == null)
|
||||
{
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? principal.FindFirstValue("email")
|
||||
?? "unknown@seahaven.com";
|
||||
|
||||
var name = principal.FindFirstValue("name")
|
||||
?? email.Split('@')[0];
|
||||
|
||||
var groups = _httpContext.HttpContext?.User.FindAll("cognito:groups")
|
||||
.Select(c => c.Value).ToList() ?? new List<string>();
|
||||
var groups = principal.FindAll("cognito:groups")
|
||||
.Select(c => c.Value).ToList();
|
||||
|
||||
var role = groups.Contains("sysadmins") ? UserRole.SysAdmin
|
||||
: groups.Contains("admins") ? UserRole.Admin
|
||||
|
|
@ -55,7 +73,69 @@ public class CurrentUserService : ICurrentUserService
|
|||
_cachedUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CognitoSub = cognitoSub,
|
||||
CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}",
|
||||
Email = email,
|
||||
DisplayName = name,
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.Users.Add(_cachedUser);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private User GetUser()
|
||||
{
|
||||
if (_cachedUser != null) return _cachedUser;
|
||||
|
||||
var principal = _httpContext.HttpContext?.User
|
||||
?? throw new UnauthorizedAccessException("No authenticated user");
|
||||
|
||||
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var sub = principal.FindFirstValue("sub");
|
||||
|
||||
if (userId != null && Guid.TryParse(userId, out var parsedId))
|
||||
{
|
||||
_cachedUser = _db.Users.FirstOrDefault(u => u.Id == parsedId);
|
||||
}
|
||||
|
||||
if (_cachedUser == null && sub != null)
|
||||
{
|
||||
_cachedUser = _db.Users.FirstOrDefault(u => u.CognitoSub == sub);
|
||||
}
|
||||
|
||||
if (_cachedUser == null)
|
||||
{
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? principal.FindFirstValue("email")
|
||||
?? "unknown@seahaven.com";
|
||||
|
||||
_cachedUser = _db.Users.FirstOrDefault(u => u.Email == email);
|
||||
}
|
||||
|
||||
if (_cachedUser == null)
|
||||
{
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email)
|
||||
?? principal.FindFirstValue("email")
|
||||
?? "unknown@seahaven.com";
|
||||
|
||||
var name = principal.FindFirstValue("name")
|
||||
?? email.Split('@')[0];
|
||||
|
||||
var groups = principal.FindAll("cognito:groups")
|
||||
.Select(c => c.Value).ToList();
|
||||
|
||||
var role = groups.Contains("sysadmins") ? UserRole.SysAdmin
|
||||
: groups.Contains("admins") ? UserRole.Admin
|
||||
: UserRole.Dispatcher;
|
||||
|
||||
_cachedUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CognitoSub = sub ?? $"auto-{Guid.NewGuid():N}",
|
||||
Email = email,
|
||||
DisplayName = name,
|
||||
Role = role,
|
||||
|
|
|
|||
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": {
|
||||
"Authority": "",
|
||||
"Audience": ""
|
||||
"Audience": "",
|
||||
"CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com",
|
||||
"ClientId": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
|
|
|
|||
|
|
@ -10,3 +10,12 @@ public record PdfDownloadResponse(
|
|||
string DownloadUrl,
|
||||
DateTime ExpiresAt
|
||||
);
|
||||
|
||||
public record VendorProposalResponse(
|
||||
Guid Id,
|
||||
string VendorName,
|
||||
string FileName,
|
||||
decimal TotalVendorCost,
|
||||
string ProcessingStatus,
|
||||
object? ExtractedData
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ public record CreateProposalRequest(
|
|||
public record UpdateProposalRequest(
|
||||
string? RefinedScope,
|
||||
string? Notes,
|
||||
Guid? AssignedAdminId
|
||||
Guid? AssignedAdminId,
|
||||
ProposalStatus? Status
|
||||
);
|
||||
|
||||
public record ProposalResponse(
|
||||
|
|
@ -76,3 +77,10 @@ public record PagedResponse<T>(
|
|||
int Page,
|
||||
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; }
|
||||
UserRole Role { get; }
|
||||
string? IpAddress { get; }
|
||||
Task ResolveAsync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ public interface IProposalService
|
|||
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(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.Design" Version="8.0.11" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.405.0" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.401.0" />
|
||||
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.401.0" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.500.0" />
|
||||
<PackageReference Include="AWSSDK.SecretsManager" Version="3.7.500.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public class ProposalService : IProposalService
|
|||
|
||||
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
|
||||
|
||||
await _jobPublisher.PublishAsync("pdf-extract", new { proposalId = proposal.Id }, ct);
|
||||
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
|
@ -147,6 +147,10 @@ public class ProposalService : IProposalService
|
|||
if (request.AssignedAdminId.HasValue)
|
||||
proposal.AssignedAdminId = request.AssignedAdminId.Value;
|
||||
|
||||
if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft
|
||||
&& request.Status.Value == ProposalStatus.InReview)
|
||||
proposal.Status = request.Status.Value;
|
||||
|
||||
proposal.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
|
|
@ -296,6 +300,19 @@ public class ProposalService : IProposalService
|
|||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<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(
|
||||
p.Id,
|
||||
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 secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||||
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
|
||||
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
|
||||
import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
|
||||
import * as logs from 'aws-cdk-lib/aws-logs';
|
||||
import { Construct } from 'constructs';
|
||||
|
||||
export interface ComputeStackProps extends cdk.StackProps {
|
||||
|
|
@ -28,6 +31,123 @@ export class ComputeStack extends cdk.Stack {
|
|||
|
||||
const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
|
||||
|
||||
// Internal API key for Lambda-to-API calls (stored in Secrets Manager)
|
||||
const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', {
|
||||
secretName: 'proposal-system/internal-api-key',
|
||||
generateSecretString: {
|
||||
excludePunctuation: true,
|
||||
passwordLength: 48,
|
||||
},
|
||||
});
|
||||
|
||||
// OpenSearch Serverless collection for Bedrock KB vector store
|
||||
const ossEncryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssEncryptionPolicy', {
|
||||
name: 'proposal-system-kb-enc',
|
||||
type: 'encryption',
|
||||
policy: JSON.stringify({
|
||||
Rules: [{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }],
|
||||
AWSOwnedKey: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', {
|
||||
name: 'proposal-system-kb-net',
|
||||
type: 'network',
|
||||
policy: JSON.stringify([{
|
||||
Rules: [
|
||||
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] },
|
||||
{ ResourceType: 'dashboard', Resource: ['collection/proposal-system-kb'] },
|
||||
],
|
||||
AllowFromPublic: true,
|
||||
}]),
|
||||
});
|
||||
|
||||
const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', {
|
||||
name: 'proposal-system-kb',
|
||||
type: 'VECTORSEARCH',
|
||||
});
|
||||
ossCollection.addDependency(ossEncryptionPolicy);
|
||||
ossCollection.addDependency(ossNetworkPolicy);
|
||||
|
||||
// Bedrock KB execution role
|
||||
const kbRole = new iam.Role(this, 'KnowledgeBaseRole', {
|
||||
roleName: 'proposal-system-kb-role',
|
||||
assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
|
||||
});
|
||||
|
||||
kbRole.addToPolicy(new iam.PolicyStatement({
|
||||
actions: ['s3:GetObject', 's3:ListBucket'],
|
||||
resources: [props.libraryBucket.bucketArn, `${props.libraryBucket.bucketArn}/*`],
|
||||
}));
|
||||
|
||||
kbRole.addToPolicy(new iam.PolicyStatement({
|
||||
actions: ['aoss:APIAccessAll'],
|
||||
resources: [ossCollection.attrArn],
|
||||
}));
|
||||
|
||||
kbRole.addToPolicy(new iam.PolicyStatement({
|
||||
actions: ['bedrock:InvokeModel'],
|
||||
resources: [`arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`],
|
||||
}));
|
||||
|
||||
// OpenSearch Serverless data access policy
|
||||
new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', {
|
||||
name: 'proposal-system-kb-access',
|
||||
type: 'data',
|
||||
policy: JSON.stringify([{
|
||||
Rules: [
|
||||
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'], Permission: ['aoss:*'] },
|
||||
{ ResourceType: 'index', Resource: ['index/proposal-system-kb/*'], Permission: ['aoss:*'] },
|
||||
],
|
||||
Principal: [kbRole.roleArn, `arn:aws:iam::${this.account}:root`],
|
||||
}]),
|
||||
});
|
||||
|
||||
// Bedrock Knowledge Base
|
||||
const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', {
|
||||
name: 'proposal-system-kb',
|
||||
roleArn: kbRole.roleArn,
|
||||
knowledgeBaseConfiguration: {
|
||||
type: 'VECTOR',
|
||||
vectorKnowledgeBaseConfiguration: {
|
||||
embeddingModelArn: `arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`,
|
||||
},
|
||||
},
|
||||
storageConfiguration: {
|
||||
type: 'OPENSEARCH_SERVERLESS',
|
||||
opensearchServerlessConfiguration: {
|
||||
collectionArn: ossCollection.attrArn,
|
||||
vectorIndexName: 'proposal-system-index',
|
||||
fieldMapping: {
|
||||
vectorField: 'embedding',
|
||||
textField: 'text',
|
||||
metadataField: 'metadata',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// KB Data Source (S3 library bucket)
|
||||
const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', {
|
||||
name: 'proposal-system-library',
|
||||
knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId,
|
||||
dataSourceConfiguration: {
|
||||
type: 'S3',
|
||||
s3Configuration: {
|
||||
bucketArn: props.libraryBucket.bucketArn,
|
||||
},
|
||||
},
|
||||
vectorIngestionConfiguration: {
|
||||
chunkingConfiguration: {
|
||||
chunkingStrategy: 'FIXED_SIZE',
|
||||
fixedSizeChunkingConfiguration: {
|
||||
maxTokens: 512,
|
||||
overlapPercentage: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// .NET 8 API Lambda
|
||||
const apiFunction = new lambda.Function(this, 'ApiFunction', {
|
||||
functionName: 'proposal-system-api',
|
||||
|
|
@ -47,12 +167,15 @@ export class ComputeStack extends cdk.Stack {
|
|||
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
||||
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
||||
JOBS_QUEUE_URL: props.jobsQueue.queueUrl,
|
||||
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||
},
|
||||
tracing: lambda.Tracing.ACTIVE,
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
// API Lambda permissions
|
||||
props.dbSecret.grantRead(apiFunction);
|
||||
internalApiKeySecret.grantRead(apiFunction);
|
||||
props.uploadsBucket.grantReadWrite(apiFunction);
|
||||
props.generatedBucket.grantRead(apiFunction);
|
||||
props.jobsQueue.grantSendMessages(apiFunction);
|
||||
|
|
@ -93,6 +216,37 @@ export class ComputeStack extends cdk.Stack {
|
|||
integration: apiIntegration,
|
||||
});
|
||||
|
||||
// Python Lambda: Suggestions Engine
|
||||
const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', {
|
||||
functionName: 'proposal-system-suggestions',
|
||||
runtime: lambda.Runtime.PYTHON_3_12,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: 'app.handler',
|
||||
code: lambda.Code.fromAsset('../lambdas/suggestions'),
|
||||
memorySize: 512,
|
||||
timeout: cdk.Duration.seconds(60),
|
||||
vpc: props.vpc,
|
||||
vpcSubnets: privateSubnets,
|
||||
securityGroups: [props.lambdaSecurityGroup],
|
||||
environment: {
|
||||
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
|
||||
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
API_BASE_URL: httpApi.apiEndpoint,
|
||||
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||
},
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
internalApiKeySecret.grantRead(suggestionsFunction);
|
||||
suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||
actions: ['bedrock:InvokeModel'],
|
||||
resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`],
|
||||
}));
|
||||
suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||
actions: ['bedrock:Retrieve'],
|
||||
resources: [knowledgeBase.attrKnowledgeBaseArn],
|
||||
}));
|
||||
|
||||
// Python Lambda: PDF Extract
|
||||
const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', {
|
||||
functionName: 'proposal-system-pdf-extract',
|
||||
|
|
@ -107,14 +261,18 @@ export class ComputeStack extends cdk.Stack {
|
|||
securityGroups: [props.lambdaSecurityGroup],
|
||||
environment: {
|
||||
UPLOADS_BUCKET: props.uploadsBucket.bucketName,
|
||||
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
API_BASE_URL: httpApi.apiEndpoint,
|
||||
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||
},
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
internalApiKeySecret.grantRead(pdfExtractFunction);
|
||||
props.uploadsBucket.grantRead(pdfExtractFunction);
|
||||
pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||
actions: ['bedrock:InvokeModel'],
|
||||
resources: ['*'],
|
||||
resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`],
|
||||
}));
|
||||
|
||||
// Python Lambda: PDF Generate
|
||||
|
|
@ -132,9 +290,12 @@ export class ComputeStack extends cdk.Stack {
|
|||
environment: {
|
||||
GENERATED_BUCKET: props.generatedBucket.bucketName,
|
||||
API_BASE_URL: httpApi.apiEndpoint,
|
||||
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||
},
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
internalApiKeySecret.grantRead(pdfGenerateFunction);
|
||||
props.generatedBucket.grantWrite(pdfGenerateFunction);
|
||||
|
||||
// Python Lambda: Library Ingest
|
||||
|
|
@ -151,20 +312,31 @@ export class ComputeStack extends cdk.Stack {
|
|||
securityGroups: [props.lambdaSecurityGroup],
|
||||
environment: {
|
||||
LIBRARY_BUCKET: props.libraryBucket.bucketName,
|
||||
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
|
||||
DATA_SOURCE_ID: dataSource.attrDataSourceId,
|
||||
API_BASE_URL: httpApi.apiEndpoint,
|
||||
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
|
||||
},
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
internalApiKeySecret.grantRead(libraryIngestFunction);
|
||||
props.libraryBucket.grantWrite(libraryIngestFunction);
|
||||
libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({
|
||||
actions: ['bedrock:StartIngestionJob'],
|
||||
resources: ['*'],
|
||||
resources: [knowledgeBase.attrKnowledgeBaseArn],
|
||||
}));
|
||||
|
||||
// SQS Event Sources with message filtering
|
||||
suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
|
||||
batchSize: 1,
|
||||
filters: [
|
||||
lambda.FilterCriteria.filter({
|
||||
body: { jobType: lambda.FilterRule.isEqual('suggestions') },
|
||||
}),
|
||||
],
|
||||
}));
|
||||
|
||||
// SQS Event Source for Python Lambdas
|
||||
// All three consume from the same queue, routed by message attributes
|
||||
// For now, use a single consumer that routes internally
|
||||
// TODO: Phase 4 will refine this to use message filtering or separate queues per function
|
||||
pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
|
||||
batchSize: 1,
|
||||
filters: [
|
||||
|
|
@ -195,5 +367,7 @@ export class ComputeStack extends cdk.Stack {
|
|||
// Outputs
|
||||
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
|
||||
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });
|
||||
new cdk.CfnOutput(this, 'KnowledgeBaseId', { value: knowledgeBase.attrKnowledgeBaseId });
|
||||
new cdk.CfnOutput(this, 'DataSourceId', { value: dataSource.attrDataSourceId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,196 @@
|
|||
"""Proposal System - Library Ingest Lambda.
|
||||
|
||||
Processes approved proposals into the Bedrock Knowledge Base.
|
||||
Implementation in Phase 4.
|
||||
Processes approved/sent proposals into the Bedrock Knowledge Base library.
|
||||
Formats proposal data as structured markdown and uploads to the library bucket,
|
||||
then triggers a KB sync.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
|
||||
LIBRARY_BUCKET = os.environ.get("LIBRARY_BUCKET", "")
|
||||
KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "")
|
||||
DATA_SOURCE_ID = os.environ.get("DATA_SOURCE_ID", "")
|
||||
API_BASE_URL = os.environ.get("API_BASE_URL", "")
|
||||
INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "")
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
bedrock_agent = boto3.client("bedrock-agent")
|
||||
secrets_client = boto3.client("secretsmanager")
|
||||
|
||||
_cached_api_key: str | None = None
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
global _cached_api_key
|
||||
if _cached_api_key is None:
|
||||
if INTERNAL_API_KEY_SECRET_ARN:
|
||||
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
|
||||
_cached_api_key = resp["SecretString"]
|
||||
else:
|
||||
_cached_api_key = ""
|
||||
return _cached_api_key
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
for record in event.get("Records", []):
|
||||
body = json.loads(record["body"])
|
||||
print(f"Processing library-ingest job: {body}")
|
||||
payload = body.get("payload", body)
|
||||
proposal_id = payload["proposalId"]
|
||||
process_ingestion(proposal_id)
|
||||
return {"statusCode": 200}
|
||||
|
||||
|
||||
def process_ingestion(proposal_id: str):
|
||||
proposal = fetch_proposal(proposal_id)
|
||||
if not proposal:
|
||||
print(f"Proposal {proposal_id} not found")
|
||||
return
|
||||
|
||||
line_items = fetch_line_items(proposal_id)
|
||||
|
||||
document = format_proposal_document(proposal, line_items)
|
||||
|
||||
s3_key = upload_to_library(proposal, document)
|
||||
|
||||
if s3_key:
|
||||
trigger_kb_sync()
|
||||
|
||||
|
||||
def fetch_proposal(proposal_id: str) -> dict | None:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Error fetching proposal: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_line_items(proposal_id: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Error fetching line items: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def format_proposal_document(proposal: dict, line_items: list[dict]) -> str:
|
||||
total = sum(li.get("totalPrice", 0) for li in line_items)
|
||||
submitted_at = proposal.get("submittedAt", "")
|
||||
if submitted_at:
|
||||
try:
|
||||
dt = datetime.fromisoformat(submitted_at.replace("Z", "+00:00"))
|
||||
submitted_at = dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
lines = [
|
||||
f"# Proposal: {proposal['proposalNumber']}",
|
||||
"",
|
||||
f"**Customer:** {proposal['customerName']}",
|
||||
f"**Address:** {proposal.get('customerAddress', '')}",
|
||||
f"**Service Category:** {proposal['serviceCategory']}",
|
||||
f"**Priority:** {proposal['priority']}",
|
||||
f"**Date:** {submitted_at}",
|
||||
f"**Total Bid Amount:** ${total:,.2f}",
|
||||
f"**Work Order:** {proposal.get('workOrderNumber', '')}",
|
||||
"",
|
||||
"## Scope of Work",
|
||||
"",
|
||||
proposal.get("refinedScope") or proposal.get("scopeOfWork", ""),
|
||||
"",
|
||||
"## Line Items",
|
||||
"",
|
||||
"| # | Description | Qty | Unit | Unit Price | Total |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
for i, li in enumerate(line_items, 1):
|
||||
desc = li.get("description", "")
|
||||
qty = li.get("quantity", "")
|
||||
unit = li.get("unit", "")
|
||||
unit_price = li.get("unitPrice")
|
||||
total_price = li.get("totalPrice", 0)
|
||||
|
||||
up_str = f"${unit_price:,.2f}" if unit_price else "-"
|
||||
tp_str = f"${total_price:,.2f}"
|
||||
|
||||
lines.append(f"| {i} | {desc} | {qty} | {unit} | {up_str} | {tp_str} |")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"**Total: ${total:,.2f}**",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upload_to_library(proposal: dict, document: str) -> str | None:
|
||||
if not LIBRARY_BUCKET:
|
||||
print("No library bucket configured")
|
||||
return None
|
||||
|
||||
proposal_number = proposal["proposalNumber"]
|
||||
category = proposal.get("serviceCategory", "General")
|
||||
s3_key = f"proposals/{category.lower()}/{proposal_number}.md"
|
||||
|
||||
try:
|
||||
s3.put_object(
|
||||
Bucket=LIBRARY_BUCKET,
|
||||
Key=s3_key,
|
||||
Body=document.encode("utf-8"),
|
||||
ContentType="text/markdown",
|
||||
Metadata={
|
||||
"service-category": category,
|
||||
"proposal-number": proposal_number,
|
||||
"customer-name": proposal.get("customerName", ""),
|
||||
"total-amount": str(proposal.get("totalBidAmount", 0)),
|
||||
"date-submitted": proposal.get("submittedAt", ""),
|
||||
},
|
||||
)
|
||||
print(f"Uploaded {s3_key} to library bucket")
|
||||
return s3_key
|
||||
except Exception as e:
|
||||
print(f"Error uploading to library: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def trigger_kb_sync():
|
||||
if not KNOWLEDGE_BASE_ID or not DATA_SOURCE_ID:
|
||||
print("KB or data source ID not configured, skipping sync")
|
||||
return
|
||||
|
||||
try:
|
||||
response = bedrock_agent.start_ingestion_job(
|
||||
knowledgeBaseId=KNOWLEDGE_BASE_ID,
|
||||
dataSourceId=DATA_SOURCE_ID,
|
||||
)
|
||||
job_id = response.get("ingestionJob", {}).get("ingestionJobId", "")
|
||||
print(f"Started KB ingestion job: {job_id}")
|
||||
except Exception as e:
|
||||
print(f"Error triggering KB sync: {e}")
|
||||
|
||||
|
||||
def _api_headers() -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = _get_api_key()
|
||||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
|
|
|||
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
|
||||
httpx>=0.27.0,<1.0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,334 @@
|
|||
"""Proposal System - PDF Extract Lambda.
|
||||
|
||||
Parses vendor proposal PDFs and extracts structured line item data.
|
||||
Implementation in Phase 4.
|
||||
Falls back to Claude multimodal for scanned/image-based PDFs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import base64
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
import pdfplumber
|
||||
|
||||
UPLOADS_BUCKET = os.environ.get("UPLOADS_BUCKET", "")
|
||||
API_BASE_URL = os.environ.get("API_BASE_URL", "")
|
||||
MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "")
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
bedrock_runtime = boto3.client("bedrock-runtime")
|
||||
secrets_client = boto3.client("secretsmanager")
|
||||
|
||||
_cached_api_key: str | None = None
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
global _cached_api_key
|
||||
if _cached_api_key is None:
|
||||
if INTERNAL_API_KEY_SECRET_ARN:
|
||||
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
|
||||
_cached_api_key = resp["SecretString"]
|
||||
else:
|
||||
_cached_api_key = ""
|
||||
return _cached_api_key
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
for record in event.get("Records", []):
|
||||
body = json.loads(record["body"])
|
||||
print(f"Processing pdf-extract job: {body}")
|
||||
payload = body.get("payload", body)
|
||||
proposal_id = payload["proposalId"]
|
||||
s3_key = payload.get("s3Key", "")
|
||||
vendor_proposal_id = payload.get("vendorProposalId", "")
|
||||
|
||||
if not s3_key:
|
||||
print(f"No s3Key in payload for proposal {proposal_id}")
|
||||
continue
|
||||
|
||||
process_pdf(proposal_id, s3_key, vendor_proposal_id)
|
||||
return {"statusCode": 200}
|
||||
|
||||
|
||||
def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str):
|
||||
update_processing_status(vendor_proposal_id, "Processing")
|
||||
|
||||
pdf_path = None
|
||||
try:
|
||||
pdf_path = download_pdf(s3_key)
|
||||
extracted = extract_with_pdfplumber(pdf_path)
|
||||
|
||||
if not extracted["lineItems"] and extracted["rawText"].strip():
|
||||
extracted = extract_with_claude_multimodal(pdf_path)
|
||||
|
||||
if not extracted["lineItems"] and not extracted["rawText"].strip():
|
||||
extracted = extract_with_claude_multimodal(pdf_path)
|
||||
|
||||
save_extraction(vendor_proposal_id, extracted)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing PDF: {e}")
|
||||
update_processing_status(vendor_proposal_id, "Failed")
|
||||
finally:
|
||||
if pdf_path:
|
||||
try:
|
||||
os.unlink(pdf_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def download_pdf(s3_key: str) -> str:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
|
||||
s3.download_file(UPLOADS_BUCKET, s3_key, tmp.name)
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def extract_with_pdfplumber(pdf_path: str) -> dict:
|
||||
result = {
|
||||
"vendorName": "",
|
||||
"lineItems": [],
|
||||
"rawText": "",
|
||||
"totalVendorCost": 0.0,
|
||||
}
|
||||
|
||||
try:
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
all_text = ""
|
||||
all_tables = []
|
||||
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text() or ""
|
||||
all_text += text + "\n"
|
||||
|
||||
tables = page.extract_tables()
|
||||
for table in tables:
|
||||
all_tables.append(table)
|
||||
|
||||
result["rawText"] = all_text.strip()
|
||||
|
||||
if all_tables:
|
||||
result["lineItems"] = parse_tables(all_tables)
|
||||
result["totalVendorCost"] = sum(
|
||||
li.get("total", 0) for li in result["lineItems"]
|
||||
)
|
||||
|
||||
if not result["vendorName"] and all_text:
|
||||
lines = all_text.split("\n")
|
||||
for line in lines[:5]:
|
||||
stripped = line.strip()
|
||||
if stripped and len(stripped) > 3 and not stripped[0].isdigit():
|
||||
result["vendorName"] = stripped
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"pdfplumber extraction failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_tables(tables: list) -> list[dict]:
|
||||
line_items = []
|
||||
|
||||
for table in tables:
|
||||
if not table or len(table) < 2:
|
||||
continue
|
||||
|
||||
header = [str(cell).lower().strip() if cell else "" for cell in table[0]]
|
||||
|
||||
desc_col = find_column(
|
||||
header, ["description", "item", "service", "work", "scope"]
|
||||
)
|
||||
qty_col = find_column(header, ["qty", "quantity", "count"])
|
||||
price_col = find_column(
|
||||
header, ["unit price", "rate", "price/unit", "unit cost"]
|
||||
)
|
||||
total_col = find_column(
|
||||
header, ["total", "amount", "ext", "extended", "line total"]
|
||||
)
|
||||
|
||||
if desc_col is None:
|
||||
continue
|
||||
|
||||
for row in table[1:]:
|
||||
if not row or len(row) <= desc_col:
|
||||
continue
|
||||
|
||||
description = str(row[desc_col]).strip() if row[desc_col] else ""
|
||||
if not description or description.lower() in (
|
||||
"",
|
||||
"total",
|
||||
"subtotal",
|
||||
"grand total",
|
||||
):
|
||||
continue
|
||||
|
||||
quantity = (
|
||||
parse_number(row[qty_col])
|
||||
if qty_col is not None and qty_col < len(row)
|
||||
else None
|
||||
)
|
||||
unit_price = (
|
||||
parse_number(row[price_col])
|
||||
if price_col is not None and price_col < len(row)
|
||||
else None
|
||||
)
|
||||
total = (
|
||||
parse_number(row[total_col])
|
||||
if total_col is not None and total_col < len(row)
|
||||
else None
|
||||
)
|
||||
|
||||
if total is None and quantity and unit_price:
|
||||
total = quantity * unit_price
|
||||
|
||||
if description and (total or unit_price):
|
||||
line_items.append(
|
||||
{
|
||||
"description": description,
|
||||
"quantity": quantity,
|
||||
"unitPrice": unit_price,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
return line_items
|
||||
|
||||
|
||||
def find_column(header: list[str], keywords: list[str]) -> int | None:
|
||||
for i, col in enumerate(header):
|
||||
for kw in keywords:
|
||||
if kw in col:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def parse_number(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
cleaned = str(value).replace("$", "").replace(",", "").strip()
|
||||
if not cleaned or cleaned == "-":
|
||||
return None
|
||||
return float(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_with_claude_multimodal(pdf_path: str) -> dict:
|
||||
try:
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdf_bytes = f.read()
|
||||
|
||||
pdf_b64 = base64.standard_b64encode(pdf_bytes).decode("utf-8")
|
||||
|
||||
response = bedrock_runtime.invoke_model(
|
||||
modelId=MODEL_ID,
|
||||
contentType="application/json",
|
||||
accept="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "application/pdf",
|
||||
"data": pdf_b64,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": """Extract all line items from this vendor proposal PDF.
|
||||
Return a JSON object with these fields:
|
||||
- vendorName: the vendor/company name
|
||||
- lineItems: array of objects with: description, quantity (number or null), unitPrice (number or null), total (number or null)
|
||||
- totalVendorCost: the grand total amount
|
||||
|
||||
Respond ONLY with the JSON object, no additional text.""",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
response_body = json.loads(response["body"].read())
|
||||
content = response_body["content"][0]["text"]
|
||||
|
||||
content = content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[1]
|
||||
content = content.rsplit("```", 1)[0]
|
||||
|
||||
parsed = json.loads(content)
|
||||
return {
|
||||
"vendorName": parsed.get("vendorName", ""),
|
||||
"lineItems": parsed.get("lineItems", []),
|
||||
"rawText": "",
|
||||
"totalVendorCost": float(parsed.get("totalVendorCost", 0)),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Claude multimodal extraction failed: {e}")
|
||||
return {
|
||||
"vendorName": "",
|
||||
"lineItems": [],
|
||||
"rawText": "",
|
||||
"totalVendorCost": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def save_extraction(vendor_proposal_id: str, extracted: dict):
|
||||
extracted_data = {
|
||||
"lineItems": extracted["lineItems"],
|
||||
"vendorName": extracted["vendorName"],
|
||||
}
|
||||
|
||||
try:
|
||||
resp = httpx.put(
|
||||
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}",
|
||||
json={
|
||||
"vendorName": extracted["vendorName"],
|
||||
"extractedData": json.dumps(extracted_data),
|
||||
"totalVendorCost": extracted["totalVendorCost"],
|
||||
"processingStatus": "Complete",
|
||||
},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code not in (200, 204):
|
||||
print(f"Failed to save extraction: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
print(f"Error saving extraction: {e}")
|
||||
|
||||
|
||||
def update_processing_status(vendor_proposal_id: str, status: str):
|
||||
if not vendor_proposal_id:
|
||||
return
|
||||
try:
|
||||
httpx.put(
|
||||
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status",
|
||||
json={"processingStatus": status},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error updating status: {e}")
|
||||
|
||||
|
||||
def _api_headers() -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = _get_api_key()
|
||||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pdfplumber>=0.11.0,<1.0
|
||||
boto3>=1.35.0,<2.0
|
||||
httpx>=0.27.0,<1.0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,540 @@
|
|||
"""Proposal System - PDF Generate Lambda.
|
||||
|
||||
Generates professional branded proposal PDFs from approved proposals.
|
||||
Implementation in Phase 5.
|
||||
Generates professional branded proposal PDFs using reportlab Platypus.
|
||||
Triggered via SQS when an admin requests PDF generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER, TA_RIGHT
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import (
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
GENERATED_BUCKET = os.environ.get("GENERATED_BUCKET", "")
|
||||
API_BASE_URL = os.environ.get("API_BASE_URL", "")
|
||||
INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "")
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
secrets_client = boto3.client("secretsmanager")
|
||||
|
||||
_cached_api_key: str | None = None
|
||||
|
||||
COMPANY_NAME = "Sea Haven Industries"
|
||||
COMPANY_ADDRESS = "Sea Haven Industries LLC"
|
||||
COMPANY_PHONE = ""
|
||||
COMPANY_EMAIL = "info@seahavenind.com"
|
||||
|
||||
TERMS_AND_CONDITIONS = """
|
||||
1. This proposal is valid for 30 days from the date of issue.
|
||||
2. Payment terms: Net 30 days from invoice date.
|
||||
3. Any changes to the scope of work may result in additional charges.
|
||||
4. Work will be scheduled upon acceptance of this proposal.
|
||||
5. All materials and workmanship are guaranteed for one (1) year from completion.
|
||||
6. Client is responsible for providing access to the work area.
|
||||
7. This proposal does not include permits unless specifically noted in the line items.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
global _cached_api_key
|
||||
if _cached_api_key is None:
|
||||
if INTERNAL_API_KEY_SECRET_ARN:
|
||||
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
|
||||
_cached_api_key = resp["SecretString"]
|
||||
else:
|
||||
_cached_api_key = ""
|
||||
return _cached_api_key
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
for record in event.get("Records", []):
|
||||
body = json.loads(record["body"])
|
||||
print(f"Processing pdf-generate job: {body}")
|
||||
payload = body.get("payload", body)
|
||||
proposal_id = payload["proposalId"]
|
||||
generate_pdf(proposal_id)
|
||||
return {"statusCode": 200}
|
||||
|
||||
|
||||
def generate_pdf(proposal_id: str):
|
||||
proposal = fetch_proposal(proposal_id)
|
||||
if not proposal:
|
||||
print(f"Proposal {proposal_id} not found")
|
||||
return
|
||||
|
||||
line_items = fetch_line_items(proposal_id)
|
||||
|
||||
pdf_bytes = build_pdf(proposal, line_items)
|
||||
|
||||
proposal_number = proposal["proposalNumber"]
|
||||
revision = proposal.get("currentRevision", 1)
|
||||
s3_key = f"{proposal_number}/rev-{revision}.pdf"
|
||||
|
||||
upload_pdf(s3_key, pdf_bytes)
|
||||
|
||||
register_pdf(proposal_id, s3_key)
|
||||
|
||||
print(f"Generated PDF: {s3_key} ({len(pdf_bytes)} bytes)")
|
||||
|
||||
|
||||
def fetch_proposal(proposal_id: str) -> dict | None:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Error fetching proposal: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_line_items(proposal_id: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Error fetching line items: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def build_pdf(proposal: dict, line_items: list[dict]) -> bytes:
|
||||
buffer = BytesIO()
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=letter,
|
||||
leftMargin=0.75 * inch,
|
||||
rightMargin=0.75 * inch,
|
||||
topMargin=0.75 * inch,
|
||||
bottomMargin=0.75 * inch,
|
||||
)
|
||||
|
||||
styles = _get_styles()
|
||||
elements = []
|
||||
|
||||
# Header
|
||||
elements.extend(_build_header(proposal, styles))
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Proposal metadata
|
||||
elements.extend(_build_metadata(proposal, styles))
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Scope of work
|
||||
elements.extend(_build_scope(proposal, styles))
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Line items table
|
||||
elements.extend(_build_line_items_table(line_items, styles))
|
||||
elements.append(Spacer(1, 0.4 * inch))
|
||||
|
||||
# Terms and conditions
|
||||
elements.extend(_build_terms(styles))
|
||||
|
||||
doc.build(elements, onFirstPage=_page_footer, onLaterPages=_page_footer)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _get_styles():
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"CompanyName",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=18,
|
||||
leading=22,
|
||||
textColor=colors.HexColor("#1a237e"),
|
||||
spaceAfter=2,
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"CompanyInfo",
|
||||
parent=styles["Normal"],
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
textColor=colors.HexColor("#555555"),
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"ProposalTitle",
|
||||
parent=styles["Heading2"],
|
||||
fontSize=14,
|
||||
leading=18,
|
||||
textColor=colors.HexColor("#1a237e"),
|
||||
spaceBefore=6,
|
||||
spaceAfter=12,
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"SectionHeader",
|
||||
parent=styles["Heading3"],
|
||||
fontSize=11,
|
||||
leading=14,
|
||||
textColor=colors.HexColor("#1a237e"),
|
||||
spaceBefore=8,
|
||||
spaceAfter=6,
|
||||
borderWidth=0,
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"MetaLabel",
|
||||
parent=styles["Normal"],
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
textColor=colors.HexColor("#666666"),
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"MetaValue",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
leading=13,
|
||||
fontName="Helvetica-Bold",
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"ScopeText",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
spaceBefore=4,
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"TermsText",
|
||||
parent=styles["Normal"],
|
||||
fontSize=8,
|
||||
leading=11,
|
||||
textColor=colors.HexColor("#555555"),
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"TotalLabel",
|
||||
parent=styles["Normal"],
|
||||
fontSize=11,
|
||||
leading=14,
|
||||
fontName="Helvetica-Bold",
|
||||
alignment=TA_RIGHT,
|
||||
)
|
||||
)
|
||||
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
"FooterText",
|
||||
parent=styles["Normal"],
|
||||
fontSize=8,
|
||||
leading=10,
|
||||
textColor=colors.HexColor("#888888"),
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
)
|
||||
|
||||
return styles
|
||||
|
||||
|
||||
def _build_header(proposal: dict, styles) -> list:
|
||||
revision = proposal.get("currentRevision", 1)
|
||||
revision_text = f" | Rev {revision}" if revision > 1 else ""
|
||||
|
||||
header_data = [
|
||||
[
|
||||
Paragraph(COMPANY_NAME, styles["CompanyName"]),
|
||||
Paragraph(f"PROPOSAL{revision_text}", styles["ProposalTitle"]),
|
||||
],
|
||||
[
|
||||
Paragraph(f"{COMPANY_EMAIL}", styles["CompanyInfo"]),
|
||||
Paragraph(f"#{proposal['proposalNumber']}", styles["MetaValue"]),
|
||||
],
|
||||
]
|
||||
|
||||
header_table = Table(header_data, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
header_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("ALIGN", (1, 0), (1, -1), "RIGHT"),
|
||||
("LINEBELOW", (0, -1), (-1, -1), 1.5, colors.HexColor("#1a237e")),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 8),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return [header_table]
|
||||
|
||||
|
||||
def _build_metadata(proposal: dict, styles) -> list:
|
||||
submitted_at = proposal.get("submittedAt", "")
|
||||
if submitted_at:
|
||||
try:
|
||||
dt = datetime.fromisoformat(submitted_at.replace("Z", "+00:00"))
|
||||
submitted_at = dt.strftime("%B %d, %Y")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
approved_at = proposal.get("approvedAt", "")
|
||||
if approved_at:
|
||||
try:
|
||||
dt = datetime.fromisoformat(approved_at.replace("Z", "+00:00"))
|
||||
approved_at = dt.strftime("%B %d, %Y")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
meta_data = [
|
||||
[
|
||||
Paragraph("Customer", styles["MetaLabel"]),
|
||||
Paragraph("Site Address", styles["MetaLabel"]),
|
||||
],
|
||||
[
|
||||
Paragraph(proposal.get("customerName", ""), styles["MetaValue"]),
|
||||
Paragraph(proposal.get("customerAddress", ""), styles["MetaValue"]),
|
||||
],
|
||||
[
|
||||
Paragraph("Work Order #", styles["MetaLabel"]),
|
||||
Paragraph("Date", styles["MetaLabel"]),
|
||||
],
|
||||
[
|
||||
Paragraph(proposal.get("workOrderNumber", ""), styles["MetaValue"]),
|
||||
Paragraph(approved_at or submitted_at, styles["MetaValue"]),
|
||||
],
|
||||
[
|
||||
Paragraph("Category", styles["MetaLabel"]),
|
||||
Paragraph("Priority", styles["MetaLabel"]),
|
||||
],
|
||||
[
|
||||
Paragraph(proposal.get("serviceCategory", ""), styles["MetaValue"]),
|
||||
Paragraph(proposal.get("priority", ""), styles["MetaValue"]),
|
||||
],
|
||||
]
|
||||
|
||||
meta_table = Table(meta_data, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
meta_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return [meta_table]
|
||||
|
||||
|
||||
def _build_scope(proposal: dict, styles) -> list:
|
||||
scope = proposal.get("refinedScope") or proposal.get("scopeOfWork", "")
|
||||
if not scope:
|
||||
return []
|
||||
|
||||
return [
|
||||
Paragraph("Scope of Work", styles["SectionHeader"]),
|
||||
Paragraph(scope, styles["ScopeText"]),
|
||||
]
|
||||
|
||||
|
||||
def _build_line_items_table(line_items: list[dict], styles) -> list:
|
||||
if not line_items:
|
||||
return [Paragraph("No line items", styles["Normal"])]
|
||||
|
||||
elements = [Paragraph("Itemized Pricing", styles["SectionHeader"])]
|
||||
|
||||
header = ["#", "Description", "Qty", "Unit", "Unit Price", "Total"]
|
||||
|
||||
table_data = [header]
|
||||
subtotal = 0.0
|
||||
|
||||
for i, li in enumerate(line_items, 1):
|
||||
qty = li.get("quantity", "")
|
||||
unit = li.get("unit", "")
|
||||
unit_price = li.get("unitPrice")
|
||||
total_price = li.get("totalPrice", 0)
|
||||
pricing_mode = li.get("pricingMode", "TotalPrice")
|
||||
|
||||
subtotal += float(total_price or 0)
|
||||
|
||||
if pricing_mode == "TotalPrice":
|
||||
up_str = "-"
|
||||
elif unit_price is not None:
|
||||
up_str = f"${float(unit_price):,.2f}"
|
||||
else:
|
||||
up_str = "-"
|
||||
|
||||
tp_str = f"${float(total_price):,.2f}" if total_price else "-"
|
||||
|
||||
row = [
|
||||
str(i),
|
||||
Paragraph(li.get("description", ""), styles["Normal"]),
|
||||
str(qty) if qty else "",
|
||||
unit,
|
||||
up_str,
|
||||
tp_str,
|
||||
]
|
||||
table_data.append(row)
|
||||
|
||||
col_widths = [
|
||||
0.35 * inch,
|
||||
3.15 * inch,
|
||||
0.55 * inch,
|
||||
0.7 * inch,
|
||||
1.0 * inch,
|
||||
1.0 * inch,
|
||||
]
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
# Header row
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a237e")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 9),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
|
||||
("TOPPADDING", (0, 0), (-1, 0), 6),
|
||||
# Data rows
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 4),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
# Alignment
|
||||
("ALIGN", (0, 0), (0, -1), "CENTER"),
|
||||
("ALIGN", (2, 0), (2, -1), "CENTER"),
|
||||
("ALIGN", (3, 0), (3, -1), "CENTER"),
|
||||
("ALIGN", (4, 0), (4, -1), "RIGHT"),
|
||||
("ALIGN", (5, 0), (5, -1), "RIGHT"),
|
||||
# Grid
|
||||
("LINEBELOW", (0, 0), (-1, 0), 1, colors.HexColor("#1a237e")),
|
||||
("LINEBELOW", (0, 1), (-1, -2), 0.5, colors.HexColor("#e0e0e0")),
|
||||
("LINEBELOW", (0, -1), (-1, -1), 1, colors.HexColor("#1a237e")),
|
||||
# Alternating row colors
|
||||
*[
|
||||
("BACKGROUND", (0, i), (-1, i), colors.HexColor("#f5f5f5"))
|
||||
for i in range(2, len(table_data), 2)
|
||||
],
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(table)
|
||||
elements.append(Spacer(1, 0.15 * inch))
|
||||
|
||||
# Total row
|
||||
total_data = [
|
||||
["", "", "", "", "TOTAL:", f"${subtotal:,.2f}"],
|
||||
]
|
||||
total_table = Table(total_data, colWidths=col_widths)
|
||||
total_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 11),
|
||||
("ALIGN", (4, 0), (4, 0), "RIGHT"),
|
||||
("ALIGN", (5, 0), (5, 0), "RIGHT"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("LINEABOVE", (4, 0), (5, 0), 1.5, colors.HexColor("#1a237e")),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(total_table)
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def _build_terms(styles) -> list:
|
||||
elements = [
|
||||
Spacer(1, 0.2 * inch),
|
||||
Paragraph("Terms & Conditions", styles["SectionHeader"]),
|
||||
]
|
||||
|
||||
for line in TERMS_AND_CONDITIONS.split("\n"):
|
||||
elements.append(Paragraph(line, styles["TermsText"]))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def _page_footer(canvas, doc):
|
||||
canvas.saveState()
|
||||
page_num = canvas.getPageNumber()
|
||||
footer_text = f"Page {page_num}"
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.setFillColor(colors.HexColor("#888888"))
|
||||
canvas.drawCentredString(letter[0] / 2, 0.4 * inch, footer_text)
|
||||
canvas.drawString(
|
||||
0.75 * inch,
|
||||
0.4 * inch,
|
||||
f"{COMPANY_NAME} — Confidential",
|
||||
)
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def upload_pdf(s3_key: str, pdf_bytes: bytes):
|
||||
try:
|
||||
s3.put_object(
|
||||
Bucket=GENERATED_BUCKET,
|
||||
Key=s3_key,
|
||||
Body=pdf_bytes,
|
||||
ContentType="application/pdf",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error uploading PDF: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def register_pdf(proposal_id: str, s3_key: str):
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{API_BASE_URL}/api/generated-pdfs",
|
||||
json={"proposalId": proposal_id, "s3Key": s3_key},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
print(f"Failed to register PDF: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
print(f"Error registering PDF: {e}")
|
||||
|
||||
|
||||
def _api_headers() -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = _get_api_key()
|
||||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
reportlab>=4.2.0,<5.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": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"axios": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.0.0"
|
||||
"@mui/icons-material": "^7.3.1",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-toastify": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
|
|
|||
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 ReactDOM from 'react-dom/client';
|
||||
|
||||
function App() {
|
||||
return <div>Proposal System</div>;
|
||||
}
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './index.css';
|
||||
import { store } from './app/store';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { theme } from './theme';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
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()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue