Add site search, PO number, service category Other, and form enhancements

- 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
This commit is contained in:
Adam Moussa 2026-05-22 16:38:37 -04:00
parent 58a4a94d8f
commit 9b502045dd
16 changed files with 1031 additions and 90 deletions

6
.gitignore vendored
View file

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

View file

@ -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<ActionResult<IReadOnlyList<SiteResponse>>> Search(
[FromQuery] string? query,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
return Ok(Array.Empty<SiteResponse>());
var results = await _siteService.SearchAsync(query, ct);
return Ok(results);
}
}

View file

@ -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<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
}
// Database
@ -112,9 +114,15 @@ builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
if (devMode)
{
builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>();
}
else
{
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))

View file

@ -10,6 +10,10 @@
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>
<Content Update="Data\verified-sites.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
<ProjectReference Include="..\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />

View file

@ -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<List<SiteResponse>> _sites = new(LoadSites);
public Task<IReadOnlyList<SiteResponse>> 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<IReadOnlyList<SiteResponse>>(results);
}
private static List<SiteResponse> LoadSites()
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "verified-sites.json");
if (!File.Exists(path))
return new List<SiteResponse>();
var json = File.ReadAllText(path);
var items = JsonSerializer.Deserialize<List<JsonElement>>(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();
}
}

View file

@ -0,0 +1,10 @@
namespace ProposalSystem.Application.DTOs;
public record SiteResponse(
string SiteCode,
string? FullAddress,
string? Address,
string? City,
string? State,
string? Zip
);

View file

@ -0,0 +1,8 @@
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Interfaces;
public interface ISiteService
{
Task<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default);
}

View file

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

View file

@ -0,0 +1,507 @@
// <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("20260522000000_AddPoNumber")]
partial class AddPoNumber
{
/// <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>("PoNumber")
.HasColumnType("text");
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,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPoNumber : Migration
{
private static readonly Guid AmazonCustomerId = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000001");
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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),
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Customers",
keyColumn: "Id",
keyValue: AmazonCustomerId);
migrationBuilder.DropColumn(
name: "PoNumber",
table: "Proposals");
}
}
}

View file

@ -202,6 +202,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");

View file

@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.DynamoDBv2" Version="3.7.400" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />

View file

@ -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<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default)
{
var upper = query.ToUpper();
var request = new ScanRequest
{
TableName = TableName,
FilterExpression = "contains(siteCode, :q)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
{ ":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<string, AttributeValue> item, string key)
{
return item.TryGetValue(key, out var attr) ? attr.S : null;
}
}

View file

@ -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 <proposal-id> <output-dir>
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 <proposal-id> <output-dir>", 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()

17
web/src/lib/api/sites.ts Normal file
View file

@ -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<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};

View file

@ -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<CreateProposalRequest>({
workOrderNumber: '',
customerName: '',
customerName: CUSTOMERS[0],
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
@ -33,53 +35,67 @@ export default function ProposalFormPage() {
notes: '',
});
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
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<ReturnType<typeof setTimeout> | 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 (
<Box>
@ -129,9 +146,9 @@ export default function ProposalFormPage() {
<CardContent>
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Work Order #"
label="Work Order Number"
required
fullWidth
value={form.workOrderNumber}
@ -139,65 +156,168 @@ export default function ProposalFormPage() {
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Autocomplete
freeSolo
options={customers}
getOptionLabel={(opt) => (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) => (
<TextField
{...params}
label="Customer"
required
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{customerLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Purchase Order Number"
fullWidth
value={form.poNumber ?? ''}
onChange={(e) => handleChange('poNumber', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Customer"
required
fullWidth
select
value={form.customerName}
onChange={(e) => handleChange('customerName', e.target.value)}
>
{CUSTOMERS.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
</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>
{manualEntry ? (
<Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Site Code"
fullWidth
value={manualSiteCode}
onChange={(e) => setManualSiteCode(e.target.value)}
placeholder="e.g. LGB8"
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
label="Street Address"
required
fullWidth
value={manualStreet}
onChange={(e) => setManualStreet(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 5 }}>
<TextField
label="City"
required
fullWidth
value={manualCity}
onChange={(e) => setManualCity(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
label="State"
required
fullWidth
value={manualState}
onChange={(e) => setManualState(e.target.value)}
placeholder="e.g. CA"
/>
</Grid>
<Grid size={{ xs: 6, sm: 4 }}>
<TextField
label="Zip Code"
required
fullWidth
value={manualZip}
onChange={(e) => setManualZip(e.target.value)}
/>
</Grid>
</Grid>
<Button
variant="text"
size="small"
sx={{ mt: 1 }}
onClick={() => {
setManualEntry(false);
setManualSiteCode('');
setManualStreet('');
setManualCity('');
setManualState('');
setManualZip('');
setSiteInputValue('');
handleChange('customerAddress', '');
}}
>
Search sites instead
</Button>
</Box>
) : (
<TextField
label="Customer Address"
required
fullWidth
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
/>
<Box>
<Autocomplete
options={siteOptions}
filterOptions={(x) => 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) => (
<TextField
{...params}
label="Site"
required
placeholder="Search by site code..."
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{siteLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
<Button
variant="text"
size="small"
sx={{ mt: 0.5 }}
onClick={() => setManualEntry(true)}
>
Enter address manually
</Button>
</Box>
)}
</Grid>
@ -218,6 +338,21 @@ export default function ProposalFormPage() {
</TextField>
</Grid>
{form.serviceCategory === 'Other' ? (
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Specify Category"
required
fullWidth
value={customCategory}
onChange={(e) => setCustomCategory(e.target.value)}
placeholder="e.g. Fire Protection"
/>
</Grid>
) : (
<Grid size={{ sm: 6 }} sx={{ display: { xs: 'none', sm: 'block' } }} />
)}
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Priority"
@ -229,7 +364,7 @@ export default function ProposalFormPage() {
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p}
{PRIORITY_LABELS[p] || p}
</MenuItem>
))}
</TextField>