mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-03 13:53:18 +00:00
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:
parent
58a4a94d8f
commit
9b502045dd
16 changed files with 1031 additions and 90 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -57,5 +57,11 @@ xcuserdata/
|
||||||
*.hmap
|
*.hmap
|
||||||
*.ipa
|
*.ipa
|
||||||
|
|
||||||
|
# Generated PDFs (local dev)
|
||||||
|
generated-pdfs/
|
||||||
|
|
||||||
|
# Local DynamoDB data dump
|
||||||
|
api/src/ProposalSystem.Api/Data/verified-sites.json
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.zip
|
*.zip
|
||||||
|
|
|
||||||
31
api/src/ProposalSystem.Api/Controllers/SitesController.cs
Normal file
31
api/src/ProposalSystem.Api/Controllers/SitesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Amazon.DynamoDBv2;
|
||||||
using Amazon.S3;
|
using Amazon.S3;
|
||||||
using Amazon.SecretsManager;
|
using Amazon.SecretsManager;
|
||||||
using Amazon.SQS;
|
using Amazon.SQS;
|
||||||
|
|
@ -25,6 +26,7 @@ if (!devMode)
|
||||||
builder.Services.AddAWSService<IAmazonS3>();
|
builder.Services.AddAWSService<IAmazonS3>();
|
||||||
builder.Services.AddAWSService<IAmazonSQS>();
|
builder.Services.AddAWSService<IAmazonSQS>();
|
||||||
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
builder.Services.AddAWSService<IAmazonSecretsManager>();
|
||||||
|
builder.Services.AddAWSService<IAmazonDynamoDB>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
|
|
@ -112,9 +114,15 @@ builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||||
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
|
||||||
if (devMode)
|
if (devMode)
|
||||||
|
{
|
||||||
builder.Services.AddScoped<IS3Service, DevS3Service>();
|
builder.Services.AddScoped<IS3Service, DevS3Service>();
|
||||||
|
builder.Services.AddScoped<ISiteService, DevSiteService>();
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
builder.Services.AddScoped<IS3Service, S3Service>();
|
builder.Services.AddScoped<IS3Service, S3Service>();
|
||||||
|
builder.Services.AddScoped<ISiteService, SiteService>();
|
||||||
|
}
|
||||||
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
|
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
|
||||||
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
|
||||||
if (string.IsNullOrEmpty(jobsQueueUrl))
|
if (string.IsNullOrEmpty(jobsQueueUrl))
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
<PublishReadyToRun>true</PublishReadyToRun>
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="Data\verified-sites.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
|
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
|
||||||
<ProjectReference Include="..\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
|
<ProjectReference Include="..\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
|
||||||
|
|
|
||||||
42
api/src/ProposalSystem.Api/Services/DevSiteService.cs
Normal file
42
api/src/ProposalSystem.Api/Services/DevSiteService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
api/src/ProposalSystem.Application/DTOs/SiteDtos.cs
Normal file
10
api/src/ProposalSystem.Application/DTOs/SiteDtos.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace ProposalSystem.Application.DTOs;
|
||||||
|
|
||||||
|
public record SiteResponse(
|
||||||
|
string SiteCode,
|
||||||
|
string? FullAddress,
|
||||||
|
string? Address,
|
||||||
|
string? City,
|
||||||
|
string? State,
|
||||||
|
string? Zip
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
using ProposalSystem.Application.DTOs;
|
||||||
|
|
||||||
|
namespace ProposalSystem.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ISiteService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ public enum ServiceCategory
|
||||||
Plumbing,
|
Plumbing,
|
||||||
Electrical,
|
Electrical,
|
||||||
General,
|
General,
|
||||||
Renovation
|
Renovation,
|
||||||
|
Other
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Priority
|
public enum Priority
|
||||||
|
|
@ -30,6 +31,7 @@ public class Proposal
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string ProposalNumber { get; set; } = string.Empty;
|
public string ProposalNumber { get; set; } = string.Empty;
|
||||||
public string WorkOrderNumber { 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 CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerAddress { get; set; } = string.Empty;
|
public string CustomerAddress { get; set; } = string.Empty;
|
||||||
public string ScopeOfWork { get; set; } = string.Empty;
|
public string ScopeOfWork { get; set; } = string.Empty;
|
||||||
|
|
|
||||||
507
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs
generated
Normal file
507
api/src/ProposalSystem.Infrastructure/Data/Migrations/20260522000000_AddPoNumber.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -202,6 +202,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
|
||||||
b.Property<Guid?>("ParentProposalId")
|
b.Property<Guid?>("ParentProposalId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("PoNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Priority")
|
b.Property<string>("Priority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AWSSDK.DynamoDBv2" Version="3.7.400" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
scripts/generate-pdf-local.py
Normal file
67
scripts/generate-pdf-local.py
Normal 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
17
web/src/lib/api/sites.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -16,16 +16,18 @@ import {
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals';
|
import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals';
|
||||||
import { customersApi, type Customer } from '../../../lib/api/customers';
|
import { sitesApi, type Site } from '../../../lib/api/sites';
|
||||||
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
|
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
|
||||||
import { queryClient } from '../../../lib/queryClient';
|
import { queryClient } from '../../../lib/queryClient';
|
||||||
|
|
||||||
|
const CUSTOMERS = ['Amazon Services, LLC'] as const;
|
||||||
|
|
||||||
export default function ProposalFormPage() {
|
export default function ProposalFormPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateProposalRequest>({
|
const [form, setForm] = useState<CreateProposalRequest>({
|
||||||
workOrderNumber: '',
|
workOrderNumber: '',
|
||||||
customerName: '',
|
customerName: CUSTOMERS[0],
|
||||||
customerAddress: '',
|
customerAddress: '',
|
||||||
scopeOfWork: '',
|
scopeOfWork: '',
|
||||||
serviceCategory: '',
|
serviceCategory: '',
|
||||||
|
|
@ -33,53 +35,67 @@ export default function ProposalFormPage() {
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
||||||
const [customerLoading, setCustomerLoading] = useState(false);
|
|
||||||
const [addresses, setAddresses] = useState<string[]>([]);
|
|
||||||
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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) => {
|
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchCustomers = useCallback(async (query: string) => {
|
const searchSites = useCallback(async (query: string) => {
|
||||||
if (query.length < 2) return;
|
if (query.length < 2) {
|
||||||
setCustomerLoading(true);
|
setSiteOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSiteLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await customersApi.search(query);
|
const results = await sitesApi.search(query);
|
||||||
setCustomers(results);
|
setSiteOptions(results);
|
||||||
} catch {
|
} catch {
|
||||||
setCustomers([]);
|
setSiteOptions([]);
|
||||||
} finally {
|
} 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({
|
const createMutation = useMutation({
|
||||||
mutationFn: async () => {
|
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) {
|
if (vendorFile) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -117,7 +133,8 @@ export default function ProposalFormPage() {
|
||||||
form.customerName.trim() &&
|
form.customerName.trim() &&
|
||||||
form.customerAddress.trim() &&
|
form.customerAddress.trim() &&
|
||||||
form.scopeOfWork.trim() &&
|
form.scopeOfWork.trim() &&
|
||||||
form.serviceCategory;
|
form.serviceCategory &&
|
||||||
|
(form.serviceCategory !== 'Other' || customCategory.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -129,9 +146,9 @@ export default function ProposalFormPage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Box component="form" onSubmit={handleSubmit}>
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Work Order #"
|
label="Work Order Number"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
value={form.workOrderNumber}
|
value={form.workOrderNumber}
|
||||||
|
|
@ -139,65 +156,168 @@ export default function ProposalFormPage() {
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<Autocomplete
|
<TextField
|
||||||
freeSolo
|
label="Purchase Order Number"
|
||||||
options={customers}
|
fullWidth
|
||||||
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
|
value={form.poNumber ?? ''}
|
||||||
loading={customerLoading}
|
onChange={(e) => handleChange('poNumber', e.target.value)}
|
||||||
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>
|
</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 }}>
|
<Grid size={{ xs: 12 }}>
|
||||||
{addresses.length > 1 ? (
|
{manualEntry ? (
|
||||||
<TextField
|
<Box>
|
||||||
label="Customer Address"
|
<Grid container spacing={2}>
|
||||||
required
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
fullWidth
|
<TextField
|
||||||
select
|
label="Site Code"
|
||||||
value={form.customerAddress}
|
fullWidth
|
||||||
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
value={manualSiteCode}
|
||||||
>
|
onChange={(e) => setManualSiteCode(e.target.value)}
|
||||||
{addresses.map((addr) => (
|
placeholder="e.g. LGB8"
|
||||||
<MenuItem key={addr} value={addr}>
|
/>
|
||||||
{addr}
|
</Grid>
|
||||||
</MenuItem>
|
<Grid size={{ xs: 12, sm: 8 }}>
|
||||||
))}
|
<TextField
|
||||||
</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
|
<Box>
|
||||||
label="Customer Address"
|
<Autocomplete
|
||||||
required
|
options={siteOptions}
|
||||||
fullWidth
|
filterOptions={(x) => x}
|
||||||
value={form.customerAddress}
|
getOptionLabel={(opt) =>
|
||||||
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
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>
|
</Grid>
|
||||||
|
|
||||||
|
|
@ -218,6 +338,21 @@ export default function ProposalFormPage() {
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</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 }}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Priority"
|
label="Priority"
|
||||||
|
|
@ -229,7 +364,7 @@ export default function ProposalFormPage() {
|
||||||
>
|
>
|
||||||
{PRIORITIES.map((p) => (
|
{PRIORITIES.map((p) => (
|
||||||
<MenuItem key={p} value={p}>
|
<MenuItem key={p} value={p}>
|
||||||
{p}
|
{PRIORITY_LABELS[p] || p}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue