mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
feat(vendors): add directory filters and details API
This commit is contained in:
parent
b2aeb35a55
commit
1a5587126e
13 changed files with 781 additions and 25 deletions
113
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
113
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VendorDataServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vendor VendorWithJobs(
|
||||||
|
string company,
|
||||||
|
string city,
|
||||||
|
string state,
|
||||||
|
string trades,
|
||||||
|
int jobCount,
|
||||||
|
bool isActive = true)
|
||||||
|
{
|
||||||
|
return new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = company,
|
||||||
|
ContactName = $"{company} Technician",
|
||||||
|
City = city,
|
||||||
|
State = state,
|
||||||
|
TradeSpecialties = trades,
|
||||||
|
IsActive = isActive,
|
||||||
|
IsDeleted = false,
|
||||||
|
Dispatches = Enumerable.Range(1, jobCount)
|
||||||
|
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
|
||||||
|
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
|
||||||
|
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
|
||||||
|
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("under-50", "Under Co")]
|
||||||
|
[InlineData("50-99", "Fifty Co")]
|
||||||
|
[InlineData("100-149", "Hundred Co")]
|
||||||
|
[InlineData("150-plus", "One Fifty Co")]
|
||||||
|
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
|
||||||
|
string bucket,
|
||||||
|
string expectedCompany)
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedBoundaryVendors(context);
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var (items, totalCount) = await service.GetPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
isActive: true,
|
||||||
|
jobBuckets: new[] { bucket });
|
||||||
|
|
||||||
|
totalCount.Should().Be(1);
|
||||||
|
items.Single().CompanyName.Should().Be(expectedCompany);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedBoundaryVendors(context);
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var (items, totalCount) = await service.GetPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
isActive: true,
|
||||||
|
companies: new[] { "Under Co", "Hundred Co" },
|
||||||
|
trades: new[] { "HVAC", "Plumbing" },
|
||||||
|
locations: new[] { "Chicago, IL" },
|
||||||
|
jobBuckets: new[] { "under-50", "100-149" });
|
||||||
|
|
||||||
|
totalCount.Should().Be(1);
|
||||||
|
items.Single().CompanyName.Should().Be("Under Co");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
|
||||||
|
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var active = await service.GetPagedAsync(1, 1, isActive: true);
|
||||||
|
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
|
||||||
|
|
||||||
|
active.TotalCount.Should().Be(1);
|
||||||
|
inactive.TotalCount.Should().Be(1);
|
||||||
|
active.Items.Single().CompanyName.Should().Be("Active Co");
|
||||||
|
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
|
||||||
|
}
|
||||||
|
}
|
||||||
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
213
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VendorServiceTests
|
||||||
|
{
|
||||||
|
private static VendorService NewService(Mock<IVendorDataService> data) =>
|
||||||
|
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateVendor_PersistsCompleteProfile()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
Vendor? saved = null;
|
||||||
|
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||||
|
.ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; });
|
||||||
|
|
||||||
|
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||||
|
{
|
||||||
|
Name = "Gateway Plumbing",
|
||||||
|
ContactName = "Jordan Lee",
|
||||||
|
Email = "dispatch@gateway.example",
|
||||||
|
Phone = "555-0100",
|
||||||
|
CompanyPhone = "555-0199",
|
||||||
|
Address = "10 Lake St",
|
||||||
|
City = "Chicago",
|
||||||
|
State = "IL",
|
||||||
|
Zipcode = "60601",
|
||||||
|
TradeSpecialties = "Plumbing, Backflow",
|
||||||
|
GoogleMapsUrl = "https://maps.google.com/example",
|
||||||
|
Notes = "After-hours dispatch available",
|
||||||
|
IsActive = true
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
saved.Should().NotBeNull();
|
||||||
|
saved!.ContactName.Should().Be("Jordan Lee");
|
||||||
|
saved.TradeSpecialties.Should().Be("Plumbing, Backflow");
|
||||||
|
saved.CompanyPhone.Should().Be("555-0199");
|
||||||
|
saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example");
|
||||||
|
saved.Notes.Should().Be("After-hours dispatch available");
|
||||||
|
saved.City.Should().Be("Chicago");
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
result.ContactName.Should().Be("Jordan Lee");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateVendor_UpdatesProfileAndActiveState()
|
||||||
|
{
|
||||||
|
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
|
||||||
|
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
|
||||||
|
{
|
||||||
|
Name = "New",
|
||||||
|
ContactName = "Casey",
|
||||||
|
TradeSpecialties = "HVAC",
|
||||||
|
IsActive = false
|
||||||
|
}, "42");
|
||||||
|
|
||||||
|
existing.CompanyName.Should().Be("New");
|
||||||
|
existing.ContactName.Should().Be("Casey");
|
||||||
|
existing.TradeSpecialties.Should().Be("HVAC");
|
||||||
|
existing.IsActive.Should().BeFalse();
|
||||||
|
existing.LastModifierUserId.Should().Be(42);
|
||||||
|
result.IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteVendor_DeactivatesWithoutDeletingHistory()
|
||||||
|
{
|
||||||
|
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
|
||||||
|
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await NewService(data).DeleteVendorAsync(9, "42");
|
||||||
|
|
||||||
|
existing.IsActive.Should().BeFalse();
|
||||||
|
existing.IsDeleted.Should().NotBeTrue();
|
||||||
|
existing.DeletionTime.Should().BeNull();
|
||||||
|
existing.LastModificationTime.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
"chicago",
|
||||||
|
false,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>()))
|
||||||
|
.ReturnsAsync((new[]
|
||||||
|
{
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
CompanyName = "Inactive Vendor",
|
||||||
|
ContactName = "Taylor",
|
||||||
|
City = "Chicago",
|
||||||
|
State = "IL",
|
||||||
|
TradeSpecialties = "Electrical",
|
||||||
|
IsActive = false
|
||||||
|
}
|
||||||
|
}.AsEnumerable(), 1));
|
||||||
|
|
||||||
|
var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false);
|
||||||
|
|
||||||
|
result.TotalCount.Should().Be(1);
|
||||||
|
result.Items.Single().Should().BeEquivalentTo(new
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
Name = "Inactive Vendor",
|
||||||
|
ContactName = "Taylor",
|
||||||
|
City = "Chicago",
|
||||||
|
State = "IL",
|
||||||
|
TradeSpecialties = "Electrical",
|
||||||
|
IsActive = false
|
||||||
|
}, options => options.ExcludingMissingMembers());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||||
|
{
|
||||||
|
var vendor = new Vendor
|
||||||
|
{
|
||||||
|
Id = 11,
|
||||||
|
CompanyName = "History Vendor",
|
||||||
|
Dispatches = new List<Dispatch>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WorkOrderId = 100,
|
||||||
|
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||||
|
{
|
||||||
|
new() { WorkOrderId = 100 },
|
||||||
|
new() { WorkOrderId = 101 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new() { WorkOrderId = 101 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetByIdAsync(11)).ReturnsAsync(vendor);
|
||||||
|
|
||||||
|
var result = await NewService(data).GetVendorByIdAsync(11);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.TotalJobs.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorsPaged_ForwardsFacetFilters()
|
||||||
|
{
|
||||||
|
var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" };
|
||||||
|
var trades = new[] { "Backflow Preventers" };
|
||||||
|
var locations = new[] { "Dallas, TX" };
|
||||||
|
var jobBuckets = new[] { "100-149" };
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetPagedAsync(
|
||||||
|
1,
|
||||||
|
25,
|
||||||
|
"gateway",
|
||||||
|
true,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets))
|
||||||
|
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
|
||||||
|
|
||||||
|
await NewService(data).GetVendorsPagedAsync(
|
||||||
|
1,
|
||||||
|
25,
|
||||||
|
"gateway",
|
||||||
|
true,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
data.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
|
||||||
|
{
|
||||||
|
var validator = new CreateVendorValidation();
|
||||||
|
|
||||||
|
var result = validator.Validate(new CreateVendorDTO
|
||||||
|
{
|
||||||
|
Name = "Gateway Plumbing",
|
||||||
|
ContactName = "",
|
||||||
|
GoogleMapsUrl = "http://maps.google.com/gateway"
|
||||||
|
});
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
||||||
|
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetVendorList")]
|
[HttpGet("GetVendorList")]
|
||||||
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetVendorList(
|
||||||
|
string? search = "",
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 10,
|
||||||
|
bool? isActive = true,
|
||||||
|
[FromQuery] string[]? companies = null,
|
||||||
|
[FromQuery] string[]? trades = null,
|
||||||
|
[FromQuery] string[]? locations = null,
|
||||||
|
[FromQuery] string[]? jobBuckets = null)
|
||||||
{
|
{
|
||||||
var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search);
|
var pagedResult = await _vendorService.GetVendorsPagedAsync(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(v => new
|
var data = pagedResult.Items.Select(v => new
|
||||||
{
|
{
|
||||||
v.Id,
|
v.Id,
|
||||||
CompanyName = v.Name,
|
CompanyName = v.Name,
|
||||||
|
v.ContactName,
|
||||||
v.Email,
|
v.Email,
|
||||||
v.Phone
|
v.Phone,
|
||||||
|
v.CompanyPhone,
|
||||||
|
v.Address,
|
||||||
|
v.City,
|
||||||
|
v.State,
|
||||||
|
Zip = v.Zipcode,
|
||||||
|
v.TradeSpecialties,
|
||||||
|
v.GoogleMapsUrl,
|
||||||
|
v.Notes,
|
||||||
|
v.IsActive,
|
||||||
|
v.TotalJobs
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(new Pagination_DTO
|
return Ok(new Pagination_DTO
|
||||||
|
|
@ -56,17 +83,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[HttpGet("GetById")]
|
[HttpGet("GetById")]
|
||||||
public async Task<IActionResult> GetVendorById([FromQuery] int? id)
|
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
||||||
{
|
{
|
||||||
if (id == null)
|
var vendorId = id ?? queryId;
|
||||||
|
if (vendorId == null)
|
||||||
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
|
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
|
||||||
|
|
||||||
var vendor = await _vendorService.GetVendorByIdAsync(id.Value);
|
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
|
||||||
|
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
|
|
||||||
return Ok(vendor);
|
return Ok(new
|
||||||
|
{
|
||||||
|
vendor.Id,
|
||||||
|
CompanyName = vendor.Name,
|
||||||
|
vendor.ContactName,
|
||||||
|
vendor.Email,
|
||||||
|
vendor.Phone,
|
||||||
|
vendor.CompanyPhone,
|
||||||
|
vendor.Address,
|
||||||
|
vendor.City,
|
||||||
|
vendor.State,
|
||||||
|
Zip = vendor.Zipcode,
|
||||||
|
vendor.TradeSpecialties,
|
||||||
|
vendor.GoogleMapsUrl,
|
||||||
|
vendor.Notes,
|
||||||
|
vendor.IsActive,
|
||||||
|
vendor.TotalJobs
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
@ -77,7 +122,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
var createDto = new CreateVendorDTO
|
var createDto = new CreateVendorDTO
|
||||||
{
|
{
|
||||||
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
|
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
|
||||||
|
ContactName = model.ContactName,
|
||||||
|
Email = model.Email,
|
||||||
|
Phone = model.Phone,
|
||||||
|
CompanyPhone = model.CompanyPhone,
|
||||||
|
Address = model.Address,
|
||||||
|
City = model.City,
|
||||||
|
State = model.State,
|
||||||
|
Zipcode = model.Zip,
|
||||||
|
TradeSpecialties = model.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = model.GoogleMapsUrl,
|
||||||
|
Notes = model.Notes,
|
||||||
|
IsActive = model.IsActive
|
||||||
};
|
};
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -111,7 +168,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
var updateDto = new UpdateVendorDTO
|
var updateDto = new UpdateVendorDTO
|
||||||
{
|
{
|
||||||
Name = model.CompanyName
|
Name = model.CompanyName,
|
||||||
|
ContactName = model.ContactName,
|
||||||
|
Email = model.Email,
|
||||||
|
Phone = model.Phone,
|
||||||
|
CompanyPhone = model.CompanyPhone,
|
||||||
|
Address = model.Address,
|
||||||
|
City = model.City,
|
||||||
|
State = model.State,
|
||||||
|
Zipcode = model.Zip,
|
||||||
|
TradeSpecialties = model.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = model.GoogleMapsUrl,
|
||||||
|
Notes = model.Notes,
|
||||||
|
IsActive = model.IsActive
|
||||||
};
|
};
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -126,7 +195,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +207,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("Delete")]
|
[HttpPost("Delete")]
|
||||||
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
|
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -152,9 +221,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
await _vendorService.DeleteVendorAsync(vendorId, userId);
|
await _vendorService.DeleteVendorAsync(vendorId, userId);
|
||||||
return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" });
|
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +281,59 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("facets")]
|
||||||
|
public async Task<IActionResult> GetFacets()
|
||||||
|
{
|
||||||
|
var vendors = await _db.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false)
|
||||||
|
.OrderByDescending(v => v.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var companies = vendors
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||||||
|
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(v => v.CompanyName)
|
||||||
|
.Select(v => new
|
||||||
|
{
|
||||||
|
Name = v.CompanyName,
|
||||||
|
v.CompanyPhone,
|
||||||
|
v.Email,
|
||||||
|
v.Address,
|
||||||
|
v.City,
|
||||||
|
v.State,
|
||||||
|
Zip = v.Zip,
|
||||||
|
v.GoogleMapsUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
var trades = vendors
|
||||||
|
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(value => value);
|
||||||
|
|
||||||
|
var locations = vendors
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||||||
|
.Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) })
|
||||||
|
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(location => location.Label);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Companies = companies,
|
||||||
|
Trades = trades,
|
||||||
|
Locations = locations,
|
||||||
|
JobBuckets = new[]
|
||||||
|
{
|
||||||
|
new { Id = "under-50", Label = "Under 50" },
|
||||||
|
new { Id = "50-99", Label = "50–99" },
|
||||||
|
new { Id = "100-149", Label = "100–149" },
|
||||||
|
new { Id = "150-plus", Label = "150+" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/portal-token")]
|
[HttpGet("{id:int}/portal-token")]
|
||||||
public async Task<IActionResult> GetPortalToken(int id)
|
public async Task<IActionResult> GetPortalToken(int id)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260720220000_AddVendorDirectoryDetails")]
|
||||||
|
public partial class AddVendorDirectoryDetails : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CompanyPhone",
|
||||||
|
table: "Vendors",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "GoogleMapsUrl",
|
||||||
|
table: "Vendors",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Notes",
|
||||||
|
table: "Vendors",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors");
|
||||||
|
migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors");
|
||||||
|
migrationBuilder.DropColumn(name: "Notes", table: "Vendors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1811,6 +1811,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("CompanyName")
|
b.Property<string>("CompanyName")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CompanyPhone")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ContactName")
|
b.Property<string>("ContactName")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -1826,6 +1829,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("GoogleMapsUrl")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|
@ -1838,6 +1844,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<int?>("LastModifierUserId")
|
b.Property<int?>("LastModifierUserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? ContactName { get; set; }
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? TradeSpecialties { get; set; }
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Linq.Expressions;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -15,12 +16,17 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
public async Task<Vendor?> GetByIdAsync(int id)
|
public async Task<Vendor?> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
return await _context.Vendors.FindAsync(id);
|
return await _context.Vendors
|
||||||
|
.Include(v => v.Dispatches!)
|
||||||
|
.ThenInclude(dispatch => dispatch.DispatchWorkOrders)
|
||||||
|
.FirstOrDefaultAsync(v => v.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
||||||
{
|
{
|
||||||
return await _context.Vendors
|
return await _context.Vendors
|
||||||
|
.Include(v => v.Dispatches!)
|
||||||
|
.ThenInclude(dispatch => dispatch.DispatchWorkOrders)
|
||||||
.FirstOrDefaultAsync(v => v.Id == id);
|
.FirstOrDefaultAsync(v => v.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,11 +38,19 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null)
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null)
|
||||||
{
|
{
|
||||||
search ??= "";
|
search ??= "";
|
||||||
|
|
||||||
var query = _context.Vendors.Where(v => v.IsActive);
|
var query = _context.Vendors.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||||
|
|
||||||
|
if (isActive.HasValue)
|
||||||
|
query = query.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
|
|
@ -45,11 +59,71 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
(v.CompanyName ?? "").ToLower().Contains(s) ||
|
(v.CompanyName ?? "").ToLower().Contains(s) ||
|
||||||
(v.ContactName ?? "").ToLower().Contains(s) ||
|
(v.ContactName ?? "").ToLower().Contains(s) ||
|
||||||
(v.Email ?? "").ToLower().Contains(s) ||
|
(v.Email ?? "").ToLower().Contains(s) ||
|
||||||
(v.TradeSpecialties ?? "").ToLower().Contains(s));
|
(v.TradeSpecialties ?? "").ToLower().Contains(s) ||
|
||||||
|
(v.City ?? "").ToLower().Contains(s) ||
|
||||||
|
(v.State ?? "").ToLower().Contains(s) ||
|
||||||
|
(v.Zip ?? "").ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedCompanies.Length > 0)
|
||||||
|
query = query.Where(v => selectedCompanies.Contains((v.CompanyName ?? "").ToLower()));
|
||||||
|
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedTrades.Length > 0)
|
||||||
|
query = query.Where(BuildTradePredicate(selectedTrades));
|
||||||
|
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedLocations.Length > 0)
|
||||||
|
query = query.Where(v => selectedLocations.Contains(((v.City ?? "") + ", " + (v.State ?? "")).ToLower()));
|
||||||
|
|
||||||
|
var selectedJobBuckets = jobBuckets?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedJobBuckets.Length > 0)
|
||||||
|
{
|
||||||
|
var under50 = selectedJobBuckets.Contains("under-50");
|
||||||
|
var from50To99 = selectedJobBuckets.Contains("50-99");
|
||||||
|
var from100To149 = selectedJobBuckets.Contains("100-149");
|
||||||
|
var from150 = selectedJobBuckets.Contains("150-plus");
|
||||||
|
|
||||||
|
var vendorsWithDispatches = await query
|
||||||
|
.Include(v => v.Dispatches!)
|
||||||
|
.ThenInclude(dispatch => dispatch.DispatchWorkOrders)
|
||||||
|
.OrderByDescending(v => v.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
var filtered = vendorsWithDispatches.Where(vendor =>
|
||||||
|
{
|
||||||
|
var totalJobs = CountDistinctWorkOrders(vendor);
|
||||||
|
return (under50 && totalJobs < 50) ||
|
||||||
|
(from50To99 && totalJobs is >= 50 and <= 99) ||
|
||||||
|
(from100To149 && totalJobs is >= 100 and <= 149) ||
|
||||||
|
(from150 && totalJobs >= 150);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return (
|
||||||
|
filtered.Skip((page - 1) * pageSize).Take(pageSize),
|
||||||
|
filtered.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await query.CountAsync();
|
var totalCount = await query.CountAsync();
|
||||||
var items = await query
|
var items = await query
|
||||||
|
.Include(v => v.Dispatches!)
|
||||||
|
.ThenInclude(dispatch => dispatch.DispatchWorkOrders)
|
||||||
.OrderByDescending(v => v.Id)
|
.OrderByDescending(v => v.Id)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
|
|
@ -58,6 +132,47 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int CountDistinctWorkOrders(Vendor vendor)
|
||||||
|
{
|
||||||
|
if (vendor.Dispatches == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return vendor.Dispatches
|
||||||
|
.SelectMany(dispatch =>
|
||||||
|
{
|
||||||
|
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
|
||||||
|
?? Enumerable.Empty<int>();
|
||||||
|
return dispatch.WorkOrderId.HasValue
|
||||||
|
? linked.Append(dispatch.WorkOrderId.Value)
|
||||||
|
: linked;
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
|
||||||
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
|
{
|
||||||
|
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
||||||
|
var specialties = Expression.Coalesce(
|
||||||
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||||
|
Expression.Constant(string.Empty));
|
||||||
|
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
|
||||||
|
Expression? body = null;
|
||||||
|
|
||||||
|
foreach (var trade in selectedTrades)
|
||||||
|
{
|
||||||
|
var contains = Expression.Call(
|
||||||
|
lowered,
|
||||||
|
nameof(string.Contains),
|
||||||
|
Type.EmptyTypes,
|
||||||
|
Expression.Constant(trade));
|
||||||
|
body = body == null ? contains : Expression.OrElse(body, contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Vendor> AddAsync(Vendor vendor)
|
public async Task<Vendor> AddAsync(Vendor vendor)
|
||||||
{
|
{
|
||||||
await _context.Vendors.AddAsync(vendor);
|
await _context.Vendors.AddAsync(vendor);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<Vendor?> GetByIdAsync(int id);
|
Task<Vendor?> GetByIdAsync(int id);
|
||||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null);
|
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,52 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { get; set; }
|
public string? Zipcode { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateVendorDTO
|
public class CreateVendorDTO
|
||||||
{
|
{
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { get; set; }
|
public string? Zipcode { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateVendorDTO
|
public class UpdateVendorDTO
|
||||||
{
|
{
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Phone { get; set; }
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zipcode { get; set; }
|
public string? Zipcode { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,25 @@ namespace SeaHaven.Services.Implementation
|
||||||
return vendors.Select(MapToDTO);
|
return vendors.Select(MapToDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null)
|
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null)
|
||||||
{
|
{
|
||||||
var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search);
|
var (items, totalCount) = await _vendorDataService.GetPagedAsync(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
return new PagedResult<VendorDTO>
|
return new PagedResult<VendorDTO>
|
||||||
{
|
{
|
||||||
Items = items.Select(MapToDTO).ToList(),
|
Items = items.Select(MapToDTO).ToList(),
|
||||||
|
|
@ -68,12 +84,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
var vendor = new Vendor
|
var vendor = new Vendor
|
||||||
{
|
{
|
||||||
CompanyName = dto.Name,
|
CompanyName = dto.Name,
|
||||||
|
ContactName = dto.ContactName,
|
||||||
Email = dto.Email,
|
Email = dto.Email,
|
||||||
Phone = dto.Phone,
|
Phone = dto.Phone,
|
||||||
|
CompanyPhone = dto.CompanyPhone,
|
||||||
Address = dto.Address,
|
Address = dto.Address,
|
||||||
City = dto.City,
|
City = dto.City,
|
||||||
State = dto.State,
|
State = dto.State,
|
||||||
Zip = dto.Zipcode,
|
Zip = dto.Zipcode,
|
||||||
|
TradeSpecialties = dto.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
IsActive = dto.IsActive,
|
||||||
CreatedDate = DateTime.UtcNow,
|
CreatedDate = DateTime.UtcNow,
|
||||||
createdby = userId
|
createdby = userId
|
||||||
};
|
};
|
||||||
|
|
@ -95,12 +117,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||||
|
|
||||||
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
||||||
|
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
||||||
if (dto.Email != null) vendor.Email = dto.Email;
|
if (dto.Email != null) vendor.Email = dto.Email;
|
||||||
if (dto.Phone != null) vendor.Phone = dto.Phone;
|
if (dto.Phone != null) vendor.Phone = dto.Phone;
|
||||||
|
if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone;
|
||||||
if (dto.Address != null) vendor.Address = dto.Address;
|
if (dto.Address != null) vendor.Address = dto.Address;
|
||||||
if (dto.City != null) vendor.City = dto.City;
|
if (dto.City != null) vendor.City = dto.City;
|
||||||
if (dto.State != null) vendor.State = dto.State;
|
if (dto.State != null) vendor.State = dto.State;
|
||||||
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
||||||
|
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
|
||||||
|
if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl;
|
||||||
|
if (dto.Notes != null) vendor.Notes = dto.Notes;
|
||||||
|
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
|
||||||
|
|
||||||
vendor.LastModificationTime = DateTime.UtcNow;
|
vendor.LastModificationTime = DateTime.UtcNow;
|
||||||
if (int.TryParse(userId, out int userIdInt))
|
if (int.TryParse(userId, out int userIdInt))
|
||||||
|
|
@ -116,9 +144,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||||
|
|
||||||
vendor.IsDeleted = true;
|
vendor.IsActive = false;
|
||||||
vendor.DeleterUserId = userId;
|
vendor.LastModificationTime = DateTime.UtcNow;
|
||||||
vendor.DeletionTime = DateTime.UtcNow;
|
if (int.TryParse(userId, out int userIdInt))
|
||||||
|
vendor.LastModifierUserId = userIdInt;
|
||||||
|
|
||||||
await _vendorDataService.UpdateAsync(vendor);
|
await _vendorDataService.UpdateAsync(vendor);
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +168,38 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
Id = vendor.Id,
|
Id = vendor.Id,
|
||||||
Name = vendor.CompanyName,
|
Name = vendor.CompanyName,
|
||||||
|
ContactName = vendor.ContactName,
|
||||||
Email = vendor.Email,
|
Email = vendor.Email,
|
||||||
Phone = vendor.Phone,
|
Phone = vendor.Phone,
|
||||||
|
CompanyPhone = vendor.CompanyPhone,
|
||||||
Address = vendor.Address,
|
Address = vendor.Address,
|
||||||
City = vendor.City,
|
City = vendor.City,
|
||||||
State = vendor.State,
|
State = vendor.State,
|
||||||
Zipcode = vendor.Zip
|
Zipcode = vendor.Zip,
|
||||||
|
TradeSpecialties = vendor.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = vendor.GoogleMapsUrl,
|
||||||
|
Notes = vendor.Notes,
|
||||||
|
IsActive = vendor.IsActive,
|
||||||
|
TotalJobs = CountDistinctWorkOrders(vendor)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int CountDistinctWorkOrders(Vendor vendor)
|
||||||
|
{
|
||||||
|
if (vendor.Dispatches == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return vendor.Dispatches
|
||||||
|
.SelectMany(dispatch =>
|
||||||
|
{
|
||||||
|
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
|
||||||
|
?? Enumerable.Empty<int>();
|
||||||
|
return dispatch.WorkOrderId.HasValue
|
||||||
|
? linked.Append(dispatch.WorkOrderId.Value)
|
||||||
|
: linked;
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces
|
||||||
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
||||||
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
||||||
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
|
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
|
||||||
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null);
|
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||||
Task DeleteVendorAsync(int id, string userId);
|
Task DeleteVendorAsync(int id, string userId);
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,22 @@ namespace SeaHaven.Services.Validation
|
||||||
.EmailAddress().WithMessage("Invalid email address")
|
.EmailAddress().WithMessage("Invalid email address")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||||
|
|
||||||
|
RuleFor(x => x.ContactName)
|
||||||
|
.NotEmpty().WithMessage("Technician name is required")
|
||||||
|
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.TradeSpecialties)
|
||||||
|
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||||
|
|
||||||
RuleFor(x => x.Phone)
|
RuleFor(x => x.Phone)
|
||||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||||
|
|
||||||
|
RuleFor(x => x.CompanyPhone)
|
||||||
|
.MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||||
|
|
||||||
RuleFor(x => x.Address)
|
RuleFor(x => x.Address)
|
||||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||||
|
|
@ -38,6 +50,14 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
|
RuleFor(x => x.GoogleMapsUrl)
|
||||||
|
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
|
||||||
|
|
||||||
|
RuleFor(x => x.Notes)
|
||||||
|
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.Notes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,10 +77,23 @@ namespace SeaHaven.Services.Validation
|
||||||
.EmailAddress().WithMessage("Invalid email address")
|
.EmailAddress().WithMessage("Invalid email address")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||||
|
|
||||||
|
RuleFor(x => x.ContactName)
|
||||||
|
.NotEmpty().WithMessage("Technician name is required")
|
||||||
|
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
|
||||||
|
.When(x => x.ContactName != null);
|
||||||
|
|
||||||
|
RuleFor(x => x.TradeSpecialties)
|
||||||
|
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||||
|
|
||||||
RuleFor(x => x.Phone)
|
RuleFor(x => x.Phone)
|
||||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||||
|
|
||||||
|
RuleFor(x => x.CompanyPhone)
|
||||||
|
.MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||||
|
|
||||||
RuleFor(x => x.Address)
|
RuleFor(x => x.Address)
|
||||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||||
|
|
@ -76,6 +109,21 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.Zipcode)
|
RuleFor(x => x.Zipcode)
|
||||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||||
|
|
||||||
|
RuleFor(x => x.GoogleMapsUrl)
|
||||||
|
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
|
||||||
|
|
||||||
|
RuleFor(x => x.Notes)
|
||||||
|
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.Notes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class VendorValidationRules
|
||||||
|
{
|
||||||
|
internal static bool BeValidHttpsUrl(string? value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue