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:
Adam Moussa 2026-05-17 13:06:23 -04:00 • committed by GitHub
parent d2e12d3940
commit ceefae2850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 10611 additions and 58 deletions

View file

@ -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
View file

@ -27,6 +27,7 @@ cdk.out/
.env
.env.*
!.env.example
appsettings.Development.json
# IDE
.idea/

View 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; }
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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");

View file

@ -0,0 +1,13 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -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" />

View file

@ -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,

View 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}");
}
}

View file

@ -7,7 +7,9 @@
},
"Auth": {
"Authority": "",
"Audience": ""
"Audience": "",
"CognitoDomain": "proposal-system-seahaven.auth.us-east-1.amazoncognito.com",
"ClientId": ""
},
"ConnectionStrings": {
"DefaultConnection": ""

View file

@ -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
);

View file

@ -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
);

View file

@ -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
);

View file

@ -8,4 +8,5 @@ public interface ICurrentUserService
string Email { get; }
UserRole Role { get; }
string? IpAddress { get; }
Task ResolveAsync();
}

View file

@ -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);
}

View file

@ -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);
}

View 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
}
}
}

View file

@ -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");
}
}
}

View file

@ -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
}
}
}

View file

@ -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>

View file

@ -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,

View file

@ -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
View 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:

View file

@ -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 });
}
}

View file

@ -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

View 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()

View file

@ -1 +1,2 @@
boto3>=1.35.0,<2.0
httpx>=0.27.0,<1.0

View file

@ -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

View file

@ -1,2 +1,3 @@
pdfplumber>=0.11.0,<1.0
boto3>=1.35.0,<2.0
httpx>=0.27.0,<1.0

View file

@ -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

View file

@ -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
View 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

View file

@ -0,0 +1,2 @@
boto3>=1.35.0,<2.0
httpx>=0.27.0,<1.0

6
web/.env.example Normal file
View 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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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>
);
}

View 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;

View 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
View 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;

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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
View 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,
};
}

View 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
View 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
View 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
View 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
View 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;

View 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;
},
};

View 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}`);
},
};

View 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;
}

View 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);
},
},
},
});

View file

@ -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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &copy; {new Date().getFullYear()}
</Typography>
</Box>
</Box>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
});