mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +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
|
||||
*.ipa
|
||||
|
||||
# Generated PDFs (local dev)
|
||||
generated-pdfs/
|
||||
|
||||
# Local DynamoDB data dump
|
||||
api/src/ProposalSystem.Api/Data/verified-sites.json
|
||||
|
||||
# Build artifacts
|
||||
*.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 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))
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
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,
|
||||
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;
|
||||
|
|
|
|||
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")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PoNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 { 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue