From 9b502045ddeb18ea3476086d159192b6bfd4c55f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 22 May 2026 16:38:37 -0400 Subject: [PATCH] Add site search, PO number, service category Other, and form enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Structured manual site entry with 5 separate address fields - Site search via Autocomplete with server-side filtering - Added PO number field to proposal form and AddPoNumber migration - Added Other to ServiceCategory enum with custom category text input - Label consistency: "Work Order #" → "Work Order Number", "PO Number" - Top row reflow to 3-column layout (4/4/4) - Dev PDF generation script for local testing --- .gitignore | 6 + .../Controllers/SitesController.cs | 31 ++ api/src/ProposalSystem.Api/Program.cs | 8 + .../ProposalSystem.Api.csproj | 4 + .../Services/DevSiteService.cs | 42 ++ .../DTOs/SiteDtos.cs | 10 + .../Interfaces/ISiteService.cs | 8 + .../Entities/Proposal.cs | 4 +- .../20260522000000_AddPoNumber.Designer.cs | 507 ++++++++++++++++++ .../Migrations/20260522000000_AddPoNumber.cs | 48 ++ .../ProposalDbContextModelSnapshot.cs | 3 + .../ProposalSystem.Infrastructure.csproj | 1 + .../Services/SiteService.cs | 52 ++ scripts/generate-pdf-local.py | 67 +++ web/src/lib/api/sites.ts | 17 + .../pages/proposals/form/ProposalFormPage.tsx | 313 ++++++++--- 16 files changed, 1031 insertions(+), 90 deletions(-) create mode 100644 api/src/ProposalSystem.Api/Controllers/SitesController.cs create mode 100644 api/src/ProposalSystem.Api/Services/DevSiteService.cs create mode 100644 api/src/ProposalSystem.Application/DTOs/SiteDtos.cs create mode 100644 api/src/ProposalSystem.Application/Interfaces/ISiteService.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.cs create mode 100644 api/src/ProposalSystem.Infrastructure/Services/SiteService.cs create mode 100644 scripts/generate-pdf-local.py create mode 100644 web/src/lib/api/sites.ts diff --git a/.gitignore b/.gitignore index bb15d89..cac9115 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,11 @@ xcuserdata/ *.hmap *.ipa +# Generated PDFs (local dev) +generated-pdfs/ + +# Local DynamoDB data dump +api/src/ProposalSystem.Api/Data/verified-sites.json + # Build artifacts *.zip diff --git a/api/src/ProposalSystem.Api/Controllers/SitesController.cs b/api/src/ProposalSystem.Api/Controllers/SitesController.cs new file mode 100644 index 0000000..0286127 --- /dev/null +++ b/api/src/ProposalSystem.Api/Controllers/SitesController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class SitesController : ControllerBase +{ + private readonly ISiteService _siteService; + + public SitesController(ISiteService siteService) + { + _siteService = siteService; + } + + [HttpGet] + public async Task>> Search( + [FromQuery] string? query, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(query) || query.Length < 2) + return Ok(Array.Empty()); + + var results = await _siteService.SearchAsync(query, ct); + return Ok(results); + } +} diff --git a/api/src/ProposalSystem.Api/Program.cs b/api/src/ProposalSystem.Api/Program.cs index c14452f..6cc2679 100644 --- a/api/src/ProposalSystem.Api/Program.cs +++ b/api/src/ProposalSystem.Api/Program.cs @@ -1,4 +1,5 @@ using System.Text; +using Amazon.DynamoDBv2; using Amazon.S3; using Amazon.SecretsManager; using Amazon.SQS; @@ -25,6 +26,7 @@ if (!devMode) builder.Services.AddAWSService(); builder.Services.AddAWSService(); builder.Services.AddAWSService(); + builder.Services.AddAWSService(); } // Database @@ -112,9 +114,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); if (devMode) +{ builder.Services.AddScoped(); + builder.Services.AddScoped(); +} else +{ builder.Services.AddScoped(); + builder.Services.AddScoped(); +} builder.Services.AddScoped(); var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? ""; if (string.IsNullOrEmpty(jobsQueueUrl)) diff --git a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj index fcbd053..49ace4e 100644 --- a/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj +++ b/api/src/ProposalSystem.Api/ProposalSystem.Api.csproj @@ -10,6 +10,10 @@ true + + + + diff --git a/api/src/ProposalSystem.Api/Services/DevSiteService.cs b/api/src/ProposalSystem.Api/Services/DevSiteService.cs new file mode 100644 index 0000000..7124966 --- /dev/null +++ b/api/src/ProposalSystem.Api/Services/DevSiteService.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Api.Services; + +public class DevSiteService : ISiteService +{ + private static readonly Lazy> _sites = new(LoadSites); + + public Task> SearchAsync(string query, CancellationToken ct = default) + { + var upper = query.ToUpper(); + var results = _sites.Value + .Where(s => s.SiteCode.Contains(upper)) + .Take(20) + .ToList(); + return Task.FromResult>(results); + } + + private static List LoadSites() + { + var path = Path.Combine(AppContext.BaseDirectory, "Data", "verified-sites.json"); + if (!File.Exists(path)) + return new List(); + + var json = File.ReadAllText(path); + var items = JsonSerializer.Deserialize>(json) ?? new(); + + return items + .Select(item => new SiteResponse( + item.TryGetProperty("siteCode", out var sc) ? sc.GetString() ?? "" : "", + item.TryGetProperty("fullAddress", out var fa) ? fa.GetString() : null, + item.TryGetProperty("address", out var a) ? a.GetString() : null, + item.TryGetProperty("city", out var c) ? c.GetString() : null, + item.TryGetProperty("state", out var s) ? s.GetString() : null, + item.TryGetProperty("zip", out var z) ? z.GetString() : null + )) + .OrderBy(s => s.SiteCode) + .ToList(); + } +} diff --git a/api/src/ProposalSystem.Application/DTOs/SiteDtos.cs b/api/src/ProposalSystem.Application/DTOs/SiteDtos.cs new file mode 100644 index 0000000..f8aba7d --- /dev/null +++ b/api/src/ProposalSystem.Application/DTOs/SiteDtos.cs @@ -0,0 +1,10 @@ +namespace ProposalSystem.Application.DTOs; + +public record SiteResponse( + string SiteCode, + string? FullAddress, + string? Address, + string? City, + string? State, + string? Zip +); diff --git a/api/src/ProposalSystem.Application/Interfaces/ISiteService.cs b/api/src/ProposalSystem.Application/Interfaces/ISiteService.cs new file mode 100644 index 0000000..cbfa590 --- /dev/null +++ b/api/src/ProposalSystem.Application/Interfaces/ISiteService.cs @@ -0,0 +1,8 @@ +using ProposalSystem.Application.DTOs; + +namespace ProposalSystem.Application.Interfaces; + +public interface ISiteService +{ + Task> SearchAsync(string query, CancellationToken ct = default); +} diff --git a/api/src/ProposalSystem.Domain/Entities/Proposal.cs b/api/src/ProposalSystem.Domain/Entities/Proposal.cs index 9674287..4d7d9d3 100644 --- a/api/src/ProposalSystem.Domain/Entities/Proposal.cs +++ b/api/src/ProposalSystem.Domain/Entities/Proposal.cs @@ -15,7 +15,8 @@ public enum ServiceCategory Plumbing, Electrical, General, - Renovation + Renovation, + Other } public enum Priority @@ -30,6 +31,7 @@ public class Proposal public Guid Id { get; set; } public string ProposalNumber { get; set; } = string.Empty; public string WorkOrderNumber { get; set; } = string.Empty; + public string? PoNumber { get; set; } public string CustomerName { get; set; } = string.Empty; public string CustomerAddress { get; set; } = string.Empty; public string ScopeOfWork { get; set; } = string.Empty; diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs new file mode 100644 index 0000000..5a51a3c --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs @@ -0,0 +1,507 @@ +// +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("20260522000000_AddPoNumber")] + partial class AddPoNumber + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .HasColumnType("jsonb"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Addresses") + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedById") + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedById"); + + b.HasIndex("ProposalId"); + + b.ToTable("GeneratedPdfs"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PricingMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("LineItems"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedById") + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentRevision") + .HasColumnType("integer"); + + b.Property("CustomerAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentProposalId") + .HasColumnType("uuid"); + + b.Property("PoNumber") + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefinedScope") + .HasColumnType("text"); + + b.Property("ScopeOfWork") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceCategory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedById") + .HasColumnType("uuid"); + + b.Property("TotalBidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorTotalCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("WorkOrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedById"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("ParentProposalId"); + + b.HasIndex("ProposalNumber") + .IsUnique(); + + b.HasIndex("SubmittedById"); + + b.ToTable("Proposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReferencedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencedById") + .HasColumnType("uuid"); + + b.Property("ReferencedLibraryItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SimilarityScore") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ReferencedById"); + + b.ToTable("SimilarProposalReferences"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CognitoSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CognitoSub") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExtractedData") + .HasColumnType("jsonb"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("S3Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalVendorCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("VendorProposals"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId"); + + b.HasOne("ProposalSystem.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy") + .WithMany() + .HasForeignKey("GeneratedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GeneratedBy"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("LineItems") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy") + .WithMany() + .HasForeignKey("ApprovedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal") + .WithMany() + .HasForeignKey("ParentProposalId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy") + .WithMany() + .HasForeignKey("SubmittedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ApprovedBy"); + + b.Navigation("AssignedAdmin"); + + b.Navigation("ParentProposal"); + + b.Navigation("SubmittedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany() + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy") + .WithMany() + .HasForeignKey("ReferencedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + + b.Navigation("ReferencedBy"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b => + { + b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal") + .WithMany("VendorProposals") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b => + { + b.Navigation("LineItems"); + + b.Navigation("VendorProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.cs new file mode 100644 index 0000000..fe089c5 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProposalSystem.Infrastructure.Data.Migrations +{ + /// + public partial class AddPoNumber : Migration + { + private static readonly Guid AmazonCustomerId = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000001"); + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PoNumber", + table: "Proposals", + type: "text", + nullable: true); + + migrationBuilder.InsertData( + table: "Customers", + columns: new[] { "Id", "Name", "Addresses", "CreatedAt", "UpdatedAt" }, + values: new object[] + { + AmazonCustomerId, + "Amazon Services, LLC", + null, + new DateTime(2026, 5, 22, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 22, 0, 0, 0, DateTimeKind.Utc), + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Customers", + keyColumn: "Id", + keyValue: AmazonCustomerId); + + migrationBuilder.DropColumn( + name: "PoNumber", + table: "Proposals"); + } + } +} diff --git a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs index 3d20496..a15f266 100644 --- a/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs +++ b/api/src/ProposalSystem.Infrastructure/Data/Migrations/ProposalDbContextModelSnapshot.cs @@ -202,6 +202,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations b.Property("ParentProposalId") .HasColumnType("uuid"); + b.Property("PoNumber") + .HasColumnType("text"); + b.Property("Priority") .IsRequired() .HasColumnType("text"); diff --git a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj index d2cd721..240132d 100644 --- a/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj +++ b/api/src/ProposalSystem.Infrastructure/ProposalSystem.Infrastructure.csproj @@ -11,6 +11,7 @@ + diff --git a/api/src/ProposalSystem.Infrastructure/Services/SiteService.cs b/api/src/ProposalSystem.Infrastructure/Services/SiteService.cs new file mode 100644 index 0000000..7098fb2 --- /dev/null +++ b/api/src/ProposalSystem.Infrastructure/Services/SiteService.cs @@ -0,0 +1,52 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using ProposalSystem.Application.DTOs; +using ProposalSystem.Application.Interfaces; + +namespace ProposalSystem.Infrastructure.Services; + +public class SiteService : ISiteService +{ + private readonly IAmazonDynamoDB _dynamo; + private const string TableName = "verified-sites"; + + public SiteService(IAmazonDynamoDB dynamo) + { + _dynamo = dynamo; + } + + public async Task> SearchAsync(string query, CancellationToken ct = default) + { + var upper = query.ToUpper(); + + var request = new ScanRequest + { + TableName = TableName, + FilterExpression = "contains(siteCode, :q)", + ExpressionAttributeValues = new Dictionary + { + { ":q", new AttributeValue { S = upper } } + }, + Limit = 20, + }; + + var response = await _dynamo.ScanAsync(request, ct); + + return response.Items + .Select(item => new SiteResponse( + GetString(item, "siteCode") ?? "", + GetString(item, "fullAddress"), + GetString(item, "address"), + GetString(item, "city"), + GetString(item, "state"), + GetString(item, "zip") + )) + .OrderBy(s => s.SiteCode) + .ToList(); + } + + private static string? GetString(Dictionary item, string key) + { + return item.TryGetValue(key, out var attr) ? attr.S : null; + } +} diff --git a/scripts/generate-pdf-local.py b/scripts/generate-pdf-local.py new file mode 100644 index 0000000..d184fc6 --- /dev/null +++ b/scripts/generate-pdf-local.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Generate a proposal PDF locally using the Lambda's template. + +Usage: python3 scripts/generate-pdf-local.py +Fetches proposal data from the local API, generates a PDF, and saves it. +Prints JSON with the output path and s3Key on success. +""" + +import json +import os +import sys + +import httpx + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lambdas", "pdf-generate")) +from app import build_pdf # noqa: E402 + +API_URL = os.environ.get("API_URL", "http://localhost:5000") + + +def main(): + if len(sys.argv) < 3: + print("Usage: generate-pdf-local.py ", file=sys.stderr) + sys.exit(1) + + proposal_id = sys.argv[1] + output_dir = sys.argv[2] + + token_resp = httpx.post( + f"{API_URL}/api/auth/dev-login", + json={"email": "adam@seahavenind.com", "role": "SysAdmin"}, + timeout=10, + ) + token_resp.raise_for_status() + token = token_resp.json()["token"] + headers = {"Authorization": f"Bearer {token}"} + + proposal_resp = httpx.get( + f"{API_URL}/api/proposals/{proposal_id}", headers=headers, timeout=10 + ) + proposal_resp.raise_for_status() + proposal = proposal_resp.json() + + li_resp = httpx.get( + f"{API_URL}/api/proposals/{proposal_id}/line-items", + headers=headers, + timeout=10, + ) + li_resp.raise_for_status() + line_items = li_resp.json() + + pdf_bytes = build_pdf(proposal, line_items) + + proposal_number = proposal["proposalNumber"] + revision = proposal.get("currentRevision", 1) + s3_key = f"{proposal_number}/rev-{revision}.pdf" + output_path = os.path.join(output_dir, s3_key) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, "wb") as f: + f.write(pdf_bytes) + + print(json.dumps({"path": output_path, "s3Key": s3_key, "size": len(pdf_bytes)})) + + +if __name__ == "__main__": + main() diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts new file mode 100644 index 0000000..e65bb19 --- /dev/null +++ b/web/src/lib/api/sites.ts @@ -0,0 +1,17 @@ +import apiClient from './client'; + +export interface Site { + siteCode: string; + fullAddress: string | null; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; +} + +export const sitesApi = { + search: async (query: string): Promise => { + const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); + return res.data; + }, +}; diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index db59f0f..89ed01a 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -16,16 +16,18 @@ import { 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 { sitesApi, type Site } from '../../../lib/api/sites'; +import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { queryClient } from '../../../lib/queryClient'; +const CUSTOMERS = ['Amazon Services, LLC'] as const; + export default function ProposalFormPage() { const navigate = useNavigate(); const [form, setForm] = useState({ workOrderNumber: '', - customerName: '', + customerName: CUSTOMERS[0], customerAddress: '', scopeOfWork: '', serviceCategory: '', @@ -33,53 +35,67 @@ export default function ProposalFormPage() { notes: '', }); - const [customers, setCustomers] = useState([]); - const [customerLoading, setCustomerLoading] = useState(false); - const [addresses, setAddresses] = useState([]); const [vendorFile, setVendorFile] = useState(null); - const searchDebounceRef = useRef | null>(null); + const [siteOptions, setSiteOptions] = useState([]); + const [siteLoading, setSiteLoading] = useState(false); + const [siteInputValue, setSiteInputValue] = useState(''); + const [manualEntry, setManualEntry] = useState(false); + const [manualSiteCode, setManualSiteCode] = useState(''); + const [manualStreet, setManualStreet] = useState(''); + const [manualCity, setManualCity] = useState(''); + const [manualState, setManualState] = useState(''); + const [manualZip, setManualZip] = useState(''); + const [customCategory, setCustomCategory] = useState(''); + const siteDebounceRef = useRef | null>(null); useEffect(() => { return () => { - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); }; }, []); + useEffect(() => { + if (!manualEntry) return; + const addressParts = [ + manualStreet, + manualCity, + [manualState, manualZip].filter(Boolean).join(' '), + ].filter(Boolean); + const address = addressParts.join(', '); + const display = + manualSiteCode && address + ? `${manualSiteCode} — ${address}` + : manualSiteCode || address; + setForm((prev) => ({ ...prev, customerAddress: display })); + }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]); + 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); + const searchSites = useCallback(async (query: string) => { + if (query.length < 2) { + setSiteOptions([]); + return; + } + setSiteLoading(true); try { - const results = await customersApi.search(query); - setCustomers(results); + const results = await sitesApi.search(query); + setSiteOptions(results); } catch { - setCustomers([]); + setSiteOptions([]); } finally { - setCustomerLoading(false); + setSiteLoading(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); + const submitData = { ...form }; + if (form.serviceCategory === 'Other' && customCategory.trim()) { + submitData.notes = `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim(); + } + const proposal = await proposalsApi.create(submitData); if (vendorFile) { try { @@ -117,7 +133,8 @@ export default function ProposalFormPage() { form.customerName.trim() && form.customerAddress.trim() && form.scopeOfWork.trim() && - form.serviceCategory; + form.serviceCategory && + (form.serviceCategory !== 'Other' || customCategory.trim()); return ( @@ -129,9 +146,9 @@ export default function ProposalFormPage() { - + - - (typeof opt === 'string' ? opt : opt.name)} - loading={customerLoading} - onInputChange={(_, value, reason) => { - if (reason === 'input') { - handleChange('customerName', value); - } - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300); - }} - onChange={handleCustomerSelect} - renderInput={(params) => ( - - {customerLoading && } - {params.InputProps.endAdornment} - - ), - }, - }} - /> - )} + + handleChange('poNumber', e.target.value)} /> + + handleChange('customerName', e.target.value)} + > + {CUSTOMERS.map((name) => ( + + {name} + + ))} + + + - {addresses.length > 1 ? ( - handleChange('customerAddress', e.target.value)} - > - {addresses.map((addr) => ( - - {addr} - - ))} - + {manualEntry ? ( + + + + setManualSiteCode(e.target.value)} + placeholder="e.g. LGB8" + /> + + + setManualStreet(e.target.value)} + /> + + + setManualCity(e.target.value)} + /> + + + setManualState(e.target.value)} + placeholder="e.g. CA" + /> + + + setManualZip(e.target.value)} + /> + + + + ) : ( - handleChange('customerAddress', e.target.value)} - /> + + x} + getOptionLabel={(opt) => + opt.fullAddress + ? `${opt.siteCode} — ${opt.fullAddress}` + : opt.siteCode + } + loading={siteLoading} + inputValue={siteInputValue} + onInputChange={(_, value, reason) => { + if (reason === 'input') { + setSiteInputValue(value); + if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); + siteDebounceRef.current = setTimeout(() => searchSites(value), 300); + } + }} + onChange={(_, value) => { + if (value) { + const display = value.fullAddress + ? `${value.siteCode} — ${value.fullAddress}` + : value.siteCode; + handleChange('customerAddress', display); + setSiteInputValue(display); + } else { + handleChange('customerAddress', ''); + setSiteInputValue(''); + } + }} + noOptionsText={ + siteLoading + ? 'Searching...' + : siteInputValue.length >= 2 + ? `No verified sites found for "${siteInputValue}"` + : 'Type a site code to search (e.g. LGB8)' + } + renderInput={(params) => ( + + {siteLoading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + + )} @@ -218,6 +338,21 @@ export default function ProposalFormPage() { + {form.serviceCategory === 'Other' ? ( + + setCustomCategory(e.target.value)} + placeholder="e.g. Fire Protection" + /> + + ) : ( + + )} + {PRIORITIES.map((p) => ( - {p} + {PRIORITY_LABELS[p] || p} ))}