mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
merge: integrate Phase 1 board API into Phase 2 branch
Combine the reviewed Phase 1 read-only board endpoints with Phase 2 inline edit and optimistic concurrency, resolving shared foundation conflicts while preserving both feature sets.
This commit is contained in:
commit
91122d753d
50 changed files with 1914 additions and 361 deletions
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# All changes require an approving review from the internal-dev team.
|
||||||
|
# Enforced via the org "main branch protection" ruleset (require_code_owner_review).
|
||||||
|
* @Sea-Haven-Industries/internal-dev
|
||||||
11
.github/workflows/ci.yml
vendored
Normal file
11
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
|
||||||
|
with:
|
||||||
|
dotnet-version: "8.0.x"
|
||||||
|
solution: "SeaHavenIndustries.sln"
|
||||||
|
run-tests: true
|
||||||
11
.github/workflows/labeler.yml
vendored
Normal file
11
.github/workflows/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
name: Labeler
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
206
Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs
Normal file
206
Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
using System.Collections;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class CalendarControllerTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserManager<ApplicationUser> MockUserManager()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
|
return new Mock<UserManager<ApplicationUser>>(
|
||||||
|
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CalendarController NewController(ApplicationDbContext ctx) =>
|
||||||
|
new(MockUserManager(), ctx);
|
||||||
|
|
||||||
|
private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Title = title,
|
||||||
|
ReminderMinutes = 15,
|
||||||
|
EventColor = "primary",
|
||||||
|
StartDate = new DateTime(2026, 6, 1),
|
||||||
|
StartTime = "08:00 AM",
|
||||||
|
EndDate = new DateTime(2026, 6, 1),
|
||||||
|
EndTime = "08:30 AM"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false)
|
||||||
|
{
|
||||||
|
var ev = new Events
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
EventColor = "primary",
|
||||||
|
ReminderMinutes = 15,
|
||||||
|
StartDate = startDate,
|
||||||
|
EndDate = startDate,
|
||||||
|
IsDeleted = isDeleted,
|
||||||
|
CreatedDate = DateTime.Now
|
||||||
|
};
|
||||||
|
ctx.Events.Add(ev);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewController(ctx).AddEvent(ValidDto());
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeOfType<DataResponse>().Which.Status.Should().Be("200");
|
||||||
|
|
||||||
|
var saved = ctx.Events.Single();
|
||||||
|
saved.Title.Should().Be("Standup");
|
||||||
|
saved.IsDeleted.Should().Be(false);
|
||||||
|
saved.CreatedDate.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewController(ctx).EditEvent(ValidDto(id: null));
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New"));
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
var updated = ctx.Events.Single();
|
||||||
|
updated.Title.Should().Be("New");
|
||||||
|
updated.LastModificationTime.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventById_WhenExists_ReturnsEventDto()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var result = await NewController(ctx).GetEventById(existing.Id);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var dto = ok.Value.Should().BeOfType<DataResponse>().Subject.Data as Event_DTO;
|
||||||
|
dto!.Title.Should().Be("Review");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
|
||||||
|
|
||||||
|
var result = await NewController(ctx).GetEventById(deleted.Id);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var result = await NewController(ctx).DeleteEvent(existing.Id);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
var row = ctx.Events.Single();
|
||||||
|
row.IsDeleted.Should().Be(true);
|
||||||
|
row.DeletionTime.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewController(ctx).DeleteEvent(12345);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetEventList_ExcludesDeletedAndCountsAfterFilter()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1));
|
||||||
|
SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2));
|
||||||
|
SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3));
|
||||||
|
SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true);
|
||||||
|
|
||||||
|
var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data!;
|
||||||
|
((int)Prop(data, "totalCount")).Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetEventList_OrdersByStartDateDescending()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
|
||||||
|
SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
|
||||||
|
SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
|
||||||
|
|
||||||
|
var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
|
||||||
|
|
||||||
|
var data = (result as OkObjectResult)!.Value.As<DataResponse>().Data!;
|
||||||
|
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
|
||||||
|
rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15));
|
||||||
|
SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1));
|
||||||
|
SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1));
|
||||||
|
|
||||||
|
var result = NewController(ctx).GetEventsForCalendar(
|
||||||
|
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30));
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
|
||||||
|
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
|
||||||
|
}
|
||||||
|
}
|
||||||
306
Api.SeaHavenIndustries/Controllers/CalendarController.cs
Normal file
306
Api.SeaHavenIndustries/Controllers/CalendarController.cs
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/Calendar")]
|
||||||
|
public class CalendarController : Controller
|
||||||
|
{
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
|
||||||
|
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// CREATE EVENT
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpPost]
|
||||||
|
[Route("AddEvent")]
|
||||||
|
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = new Events
|
||||||
|
{
|
||||||
|
Title = eventDto.Title,
|
||||||
|
Description = eventDto.Description,
|
||||||
|
Location = eventDto.Location,
|
||||||
|
RecordID = eventDto.RecordID,
|
||||||
|
RecordName = eventDto.RecordName,
|
||||||
|
RecordTargetType = eventDto.RecordTargetType,
|
||||||
|
ReminderMinutes = eventDto.ReminderMinutes,
|
||||||
|
EventColor = eventDto.EventColor,
|
||||||
|
StartDate = eventDto.StartDate,
|
||||||
|
StartTime = eventDto.StartTime,
|
||||||
|
EndDate = eventDto.EndDate,
|
||||||
|
EndTime = eventDto.EndTime,
|
||||||
|
AllDay = eventDto.AllDay,
|
||||||
|
CreatedDate = DateTime.Now,
|
||||||
|
IsDeleted = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Events.Add(model);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// UPDATE EVENT
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpPost]
|
||||||
|
[Route("EditEvent")]
|
||||||
|
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!eventDto.Id.HasValue)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = await _db.Events.FindAsync(eventDto.Id.Value);
|
||||||
|
if (model == null || model.IsDeleted == true)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
model.Title = eventDto.Title;
|
||||||
|
model.Description = eventDto.Description;
|
||||||
|
model.Location = eventDto.Location;
|
||||||
|
model.RecordID = eventDto.RecordID;
|
||||||
|
model.RecordName = eventDto.RecordName;
|
||||||
|
model.RecordTargetType = eventDto.RecordTargetType;
|
||||||
|
model.ReminderMinutes = eventDto.ReminderMinutes;
|
||||||
|
model.EventColor = eventDto.EventColor;
|
||||||
|
model.StartDate = eventDto.StartDate;
|
||||||
|
model.StartTime = eventDto.StartTime;
|
||||||
|
model.EndDate = eventDto.EndDate;
|
||||||
|
model.EndTime = eventDto.EndTime;
|
||||||
|
model.AllDay = eventDto.AllDay;
|
||||||
|
model.LastModificationTime = DateTime.Now;
|
||||||
|
|
||||||
|
_db.Events.Update(model);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// GET EVENT BY ID
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpGet]
|
||||||
|
[Route("GetEventById/{id}")]
|
||||||
|
public async Task<IActionResult> GetEventById(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = await _db.Events
|
||||||
|
.Where(e => e.Id == id && e.IsDeleted != true)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (model == null)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto = new Event_DTO
|
||||||
|
{
|
||||||
|
Id = model.Id,
|
||||||
|
Title = model.Title,
|
||||||
|
Description = model.Description,
|
||||||
|
Location = model.Location,
|
||||||
|
RecordID = model.RecordID,
|
||||||
|
RecordName = model.RecordName,
|
||||||
|
RecordTargetType = model.RecordTargetType,
|
||||||
|
ReminderMinutes = model.ReminderMinutes,
|
||||||
|
EventColor = model.EventColor,
|
||||||
|
StartDate = model.StartDate,
|
||||||
|
StartTime = model.StartTime,
|
||||||
|
EndDate = model.EndDate,
|
||||||
|
EndTime = model.EndTime,
|
||||||
|
AllDay = model.AllDay,
|
||||||
|
CreatedDate = model.CreatedDate,
|
||||||
|
createdby = model.createdby
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// DELETE EVENT
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("DeleteEvent/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteEvent(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = await _db.Events.FindAsync(id);
|
||||||
|
if (model == null)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
model.IsDeleted = true;
|
||||||
|
model.DeletionTime = DateTime.Now;
|
||||||
|
|
||||||
|
_db.Events.Update(model);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// GET EVENTS LIST (WITH PAGINATION AND FILTERS)
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpGet]
|
||||||
|
[Route("GetEventList")]
|
||||||
|
public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = _db.Events.Where(e => e.IsDeleted != true);
|
||||||
|
|
||||||
|
// Apply search filter if provided
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var searchLower = search.ToLower();
|
||||||
|
query = query.Where(e =>
|
||||||
|
e.Title.ToLower().Contains(searchLower) ||
|
||||||
|
(e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
|
||||||
|
(e.Location != null && e.Location.ToLower().Contains(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Calculate totalCount AFTER applying filters
|
||||||
|
var totalCount = query.Count();
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
var events = query
|
||||||
|
.OrderByDescending(e => e.StartDate)
|
||||||
|
.ThenByDescending(e => e.CreatedDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(e => new Event_DTO
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Title = e.Title,
|
||||||
|
Description = e.Description,
|
||||||
|
Location = e.Location,
|
||||||
|
RecordID = e.RecordID,
|
||||||
|
RecordName = e.RecordName,
|
||||||
|
RecordTargetType = e.RecordTargetType,
|
||||||
|
ReminderMinutes = e.ReminderMinutes,
|
||||||
|
EventColor = e.EventColor,
|
||||||
|
StartDate = e.StartDate,
|
||||||
|
StartTime = e.StartTime,
|
||||||
|
EndDate = e.EndDate,
|
||||||
|
EndTime = e.EndTime,
|
||||||
|
AllDay = e.AllDay,
|
||||||
|
CreatedDate = e.CreatedDate,
|
||||||
|
createdby = e.createdby
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new DataResponse
|
||||||
|
{
|
||||||
|
Message = "Success",
|
||||||
|
Status = "200",
|
||||||
|
Data = new { rows = events, totalCount }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================================
|
||||||
|
// GET EVENTS FOR CALENDAR (Date Range Filter)
|
||||||
|
// ===================================================================================
|
||||||
|
[HttpGet]
|
||||||
|
[Route("GetEventsForCalendar")]
|
||||||
|
public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = _db.Events.Where(e => e.IsDeleted != true);
|
||||||
|
|
||||||
|
// Filter by date range if provided
|
||||||
|
if (start.HasValue && end.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(e =>
|
||||||
|
(e.StartDate >= start.Value && e.StartDate <= end.Value) ||
|
||||||
|
(e.EndDate >= start.Value && e.EndDate <= end.Value) ||
|
||||||
|
(e.StartDate <= start.Value && e.EndDate >= end.Value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = query
|
||||||
|
.Select(e => new Event_DTO
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Title = e.Title,
|
||||||
|
Description = e.Description,
|
||||||
|
Location = e.Location,
|
||||||
|
RecordID = e.RecordID,
|
||||||
|
RecordName = e.RecordName,
|
||||||
|
RecordTargetType = e.RecordTargetType,
|
||||||
|
ReminderMinutes = e.ReminderMinutes,
|
||||||
|
EventColor = e.EventColor,
|
||||||
|
StartDate = e.StartDate,
|
||||||
|
StartTime = e.StartTime,
|
||||||
|
EndDate = e.EndDate,
|
||||||
|
EndTime = e.EndTime,
|
||||||
|
AllDay = e.AllDay,
|
||||||
|
CreatedDate = e.CreatedDate,
|
||||||
|
createdby = e.createdby
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +43,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
private readonly Helper.SendMessage _sendMessage;
|
private readonly Helper.SendMessage _sendMessage;
|
||||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||||
|
|
||||||
public WorkOrderController(
|
public WorkOrderController(
|
||||||
|
|
@ -61,6 +62,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
Helper.SendMessage sendMessage,
|
Helper.SendMessage sendMessage,
|
||||||
Helper.VendorPortalTokenService vendorTokens,
|
Helper.VendorPortalTokenService vendorTokens,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
|
IWorkOrderBoardService workOrderBoardService,
|
||||||
IWorkOrderBoardUpdateService boardUpdateService)
|
IWorkOrderBoardUpdateService boardUpdateService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
|
@ -78,10 +80,41 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_sendMessage = sendMessage;
|
_sendMessage = sendMessage;
|
||||||
_vendorTokens = vendorTokens;
|
_vendorTokens = vendorTokens;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_workOrderBoardService = workOrderBoardService;
|
||||||
_boardUpdateService = boardUpdateService;
|
_boardUpdateService = boardUpdateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[HttpGet("board")]
|
||||||
|
public async Task<IActionResult> GetBoard(
|
||||||
|
[FromQuery] DateOnly weekStart,
|
||||||
|
[FromQuery] DateOnly? weekEnd = null,
|
||||||
|
[FromQuery] List<string>? dispatchers = null,
|
||||||
|
[FromQuery] bool myWorkOrders = false,
|
||||||
|
[FromQuery] List<WorkOrderType>? types = null,
|
||||||
|
[FromQuery] string? search = null)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var query = new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart,
|
||||||
|
WeekEnd = weekEnd,
|
||||||
|
Dispatchers = dispatchers,
|
||||||
|
MyWorkOrders = myWorkOrders,
|
||||||
|
Types = types,
|
||||||
|
Search = search
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("lookups/dispatchers")]
|
||||||
|
public async Task<IActionResult> GetDispatcherLookups()
|
||||||
|
{
|
||||||
|
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:int}/board")]
|
[HttpPatch("{id:int}/board")]
|
||||||
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
|
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
48
Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs
Normal file
48
Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.DTOs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for POST/PUT requests - Create/Edit Event
|
||||||
|
/// Used to create or update event information
|
||||||
|
/// </summary>
|
||||||
|
public class EditEvent_DTO
|
||||||
|
{
|
||||||
|
public int? Id { get; set; } // Null for create, populated for edit
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Please enter a title")]
|
||||||
|
[StringLength(100, ErrorMessage = "Title cannot be longer than 100 characters")]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[StringLength(150, ErrorMessage = "Location cannot be longer than 150 characters")]
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
public int? RecordID { get; set; }
|
||||||
|
public string? RecordName { get; set; }
|
||||||
|
public string? RecordTargetType { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "The Remind Attendees field is required")]
|
||||||
|
public int ReminderMinutes { get; set; } = 15;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "The Select a Calendar Color field is required")]
|
||||||
|
public string EventColor { get; set; } = "primary";
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Please select a start date")]
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Please select a start time")]
|
||||||
|
public string StartTime { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Please select an end date")]
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Please select an end time")]
|
||||||
|
public string EndTime { get; set; } = null!;
|
||||||
|
|
||||||
|
public bool AllDay { get; set; } = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
Api.SeaHavenIndustries/DTOs/Event_DTO.cs
Normal file
33
Api.SeaHavenIndustries/DTOs/Event_DTO.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.DTOs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for GET responses - Event data
|
||||||
|
/// Used to return event information to the client
|
||||||
|
/// </summary>
|
||||||
|
public class Event_DTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
public int? RecordID { get; set; }
|
||||||
|
public string? RecordName { get; set; }
|
||||||
|
public string? RecordTargetType { get; set; }
|
||||||
|
|
||||||
|
public int ReminderMinutes { get; set; }
|
||||||
|
public string EventColor { get; set; } = null!;
|
||||||
|
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
public string? StartTime { get; set; }
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
public string? EndTime { get; set; }
|
||||||
|
public bool AllDay { get; set; }
|
||||||
|
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public string? createdby { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
22
Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs
Normal file
22
Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Filters
|
||||||
|
{
|
||||||
|
public class ConcurrencyExceptionFilter : IExceptionFilter
|
||||||
|
{
|
||||||
|
public void OnException(ExceptionContext context)
|
||||||
|
{
|
||||||
|
if (context.Exception is not DbUpdateConcurrencyException) return;
|
||||||
|
|
||||||
|
context.Result = new ConflictObjectResult(new
|
||||||
|
{
|
||||||
|
status = "Conflict",
|
||||||
|
message = "The record was modified by another user. Refresh and retry.",
|
||||||
|
code = 409
|
||||||
|
});
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,10 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
builder.Services.AddControllers().AddJsonOptions(options =>
|
builder.Services.AddControllers(options =>
|
||||||
|
{
|
||||||
|
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||||
|
}).AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<FollowUps> FollowUps { get; set; }
|
public DbSet<FollowUps> FollowUps { get; set; }
|
||||||
public DbSet<PMSchedules> PMSchedules { get; set; }
|
public DbSet<PMSchedules> PMSchedules { get; set; }
|
||||||
public DbSet<ContactDetails> ContactDetails { get; set; }
|
public DbSet<ContactDetails> ContactDetails { get; set; }
|
||||||
|
public DbSet<Events> Events { get; set; }
|
||||||
|
|
||||||
// Employee related DbSets
|
// Employee related DbSets
|
||||||
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }
|
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,171 @@
|
||||||
namespace Data.SeaHavenIndustries.Enums
|
namespace Data.SeaHavenIndustries.Enums
|
||||||
{
|
{
|
||||||
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
|
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
|
||||||
public enum WorkOrderStatus
|
public enum WorkOrderStatus
|
||||||
{
|
{
|
||||||
Open = 1,
|
Open = 1,
|
||||||
InProgress = 2,
|
InProgress = 2,
|
||||||
Completed = 3,
|
Completed = 3,
|
||||||
Cancelled = 4,
|
Cancelled = 4,
|
||||||
OnHold = 5
|
OnHold = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
|
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
|
||||||
public enum LifecycleStatus
|
public enum LifecycleStatus
|
||||||
{
|
{
|
||||||
Incomplete = 1,
|
Incomplete = 1,
|
||||||
Pending = 2,
|
Pending = 2,
|
||||||
Scheduled = 3,
|
Scheduled = 3,
|
||||||
EnRoute = 4,
|
EnRoute = 4,
|
||||||
OnSite = 5,
|
OnSite = 5,
|
||||||
InProgress = 6,
|
InProgress = 6,
|
||||||
Completed = 7,
|
Completed = 7,
|
||||||
Rescheduled = 8,
|
Rescheduled = 8,
|
||||||
Canceled = 9,
|
Canceled = 9,
|
||||||
PendingQuote = 10
|
PendingQuote = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
|
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
|
||||||
public enum WorkOrderType
|
public enum WorkOrderType
|
||||||
{
|
{
|
||||||
PO = 1,
|
PO = 1,
|
||||||
PM = 2,
|
PM = 2,
|
||||||
Emergency = 3,
|
Emergency = 3,
|
||||||
Project = 4,
|
Project = 4,
|
||||||
Inspection = 5,
|
Inspection = 5,
|
||||||
Reactive = 6,
|
Reactive = 6,
|
||||||
AddOn = 7,
|
AddOn = 7,
|
||||||
Other = 99
|
Other = 99
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DocStatus
|
public enum DocStatus
|
||||||
{
|
{
|
||||||
Yes = 1,
|
Yes = 1,
|
||||||
No = 2,
|
No = 2,
|
||||||
NotNeeded = 3
|
NotNeeded = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum OperationalFlags
|
public enum OperationalFlags
|
||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
PastDue = 1
|
PastDue = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AuditEventType
|
public enum AuditEventType
|
||||||
{
|
{
|
||||||
Manual = 1,
|
Manual = 1,
|
||||||
System = 2,
|
System = 2,
|
||||||
Sync = 3,
|
Sync = 3,
|
||||||
Vendor = 4
|
Vendor = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AuditActorType
|
public enum AuditActorType
|
||||||
{
|
{
|
||||||
Dispatcher = 1,
|
Dispatcher = 1,
|
||||||
Vendor = 2,
|
Vendor = 2,
|
||||||
System = 3,
|
System = 3,
|
||||||
Sync = 4
|
Sync = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AuditActionType
|
public enum AuditActionType
|
||||||
{
|
{
|
||||||
FieldChanged = 1,
|
FieldChanged = 1,
|
||||||
StatusChanged = 2,
|
StatusChanged = 2,
|
||||||
AssignmentChanged = 3,
|
AssignmentChanged = 3,
|
||||||
WeekRolled = 4,
|
WeekRolled = 4,
|
||||||
SyncRejected = 5,
|
SyncRejected = 5,
|
||||||
Create = 6,
|
Create = 6,
|
||||||
Delete = 7
|
Delete = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkOrderPriority
|
public enum WorkOrderPriority
|
||||||
{
|
{
|
||||||
Low = 1,
|
Low = 1,
|
||||||
Medium = 2,
|
Medium = 2,
|
||||||
High = 3,
|
High = 3,
|
||||||
Critical = 4
|
Critical = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AuditAction
|
public enum AuditAction
|
||||||
{
|
{
|
||||||
Create = 1,
|
Create = 1,
|
||||||
Edit = 2,
|
Edit = 2,
|
||||||
Delete = 3,
|
Delete = 3,
|
||||||
StatusChange = 4,
|
StatusChange = 4,
|
||||||
AssignmentChange = 5,
|
AssignmentChange = 5,
|
||||||
Dispatch = 6,
|
Dispatch = 6,
|
||||||
DispatchUpdate = 7,
|
DispatchUpdate = 7,
|
||||||
DispatchVerify = 8
|
DispatchVerify = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ContactType
|
public enum ContactType
|
||||||
{
|
{
|
||||||
Primary = 1,
|
Primary = 1,
|
||||||
Secondary = 2,
|
Secondary = 2,
|
||||||
Billing = 3,
|
Billing = 3,
|
||||||
Technical = 4
|
Technical = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AssetStatus
|
public enum AssetStatus
|
||||||
{
|
{
|
||||||
Active = 1,
|
Active = 1,
|
||||||
Inactive = 2,
|
Inactive = 2,
|
||||||
UnderMaintenance = 3,
|
UnderMaintenance = 3,
|
||||||
Retired = 4
|
Retired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DispatchStatus
|
public enum DispatchStatus
|
||||||
{
|
{
|
||||||
Pending = 1,
|
Pending = 1,
|
||||||
Assigned = 2,
|
Assigned = 2,
|
||||||
InProgress = 3,
|
InProgress = 3,
|
||||||
Completed = 4,
|
Completed = 4,
|
||||||
Cancelled = 5
|
Cancelled = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum FollowUpStatus
|
public enum FollowUpStatus
|
||||||
{
|
{
|
||||||
Open = 1,
|
Open = 1,
|
||||||
InProgress = 2,
|
InProgress = 2,
|
||||||
Resolved = 3,
|
Resolved = 3,
|
||||||
Closed = 4
|
Closed = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum PMScheduleFrequency
|
public enum PMScheduleFrequency
|
||||||
{
|
{
|
||||||
Daily = 1,
|
Daily = 1,
|
||||||
Weekly = 2,
|
Weekly = 2,
|
||||||
Monthly = 3,
|
Monthly = 3,
|
||||||
Quarterly = 4,
|
Quarterly = 4,
|
||||||
SemiAnnual = 5,
|
SemiAnnual = 5,
|
||||||
Annual = 6
|
Annual = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CommentType
|
public enum CommentType
|
||||||
{
|
{
|
||||||
General = 1,
|
General = 1,
|
||||||
Internal = 2,
|
Internal = 2,
|
||||||
Customer = 3,
|
Customer = 3,
|
||||||
Vendor = 4,
|
Vendor = 4,
|
||||||
System = 5
|
System = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum RecordType
|
public enum RecordType
|
||||||
{
|
{
|
||||||
WorkOrder = 1,
|
WorkOrder = 1,
|
||||||
Dispatch = 2,
|
Dispatch = 2,
|
||||||
Quote = 3,
|
Quote = 3,
|
||||||
Contact = 4,
|
Contact = 4,
|
||||||
Asset = 5
|
Asset = 5
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public enum WorkOrderMediaCategory
|
||||||
|
{
|
||||||
|
Before = 1,
|
||||||
|
After = 2,
|
||||||
|
Extra = 3,
|
||||||
|
Completion = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1975,6 +1975,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Severity")
|
b.Property<string>("Severity")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Service")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
b.Property<string>("SignOffAttachment")
|
b.Property<string>("SignOffAttachment")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
type: "int",
|
type: "int",
|
||||||
nullable: true);
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Service",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Notes",
|
name: "Notes",
|
||||||
table: "WorkOrderContacts",
|
table: "WorkOrderContacts",
|
||||||
|
|
@ -367,17 +374,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
migrationBuilder.Sql("""
|
migrationBuilder.Sql("""
|
||||||
UPDATE workOrders SET LifecycleStatus = CASE
|
UPDATE workOrders SET LifecycleStatus = CASE
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open') THEN 1
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open', 'incomplete') THEN 1
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 2
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending', 'onhold', 'on hold') THEN 2
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 3
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 3
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete') THEN 4
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('en route', 'enroute') THEN 4
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 5
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('on site', 'onsite') THEN 5
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('onhold', 'on hold') THEN 6
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 6
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingvendor', 'pending vendor') THEN 7
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete', 'done', 'closed') THEN 7
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingapproval', 'pending approval') THEN 8
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('rescheduled') THEN 8
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('needsreview', 'needs review') THEN 9
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 9
|
||||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('closed') THEN 10
|
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending quote', 'pendingquote', 'pendingapproval', 'pending approval') THEN 10
|
||||||
ELSE 9
|
ELSE 1
|
||||||
END
|
END
|
||||||
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
|
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
|
||||||
""");
|
""");
|
||||||
|
|
@ -477,6 +484,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
name: "WorkOrderType",
|
name: "WorkOrderType",
|
||||||
table: "workOrders");
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Service",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Notes",
|
name: "Notes",
|
||||||
table: "WorkOrderContacts");
|
table: "WorkOrderContacts");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class Phase1_BoardIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
|
||||||
|
END
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1972,6 +1972,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Severity")
|
b.Property<string>("Severity")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Service")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
b.Property<string>("SignOffAttachment")
|
b.Property<string>("SignOffAttachment")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -2027,10 +2031,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
b.HasIndex("AssignTo");
|
b.HasIndex("AssignTo");
|
||||||
|
|
||||||
|
b.HasIndex("AssignTo", "ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("LocationId");
|
b.HasIndex("LocationId");
|
||||||
|
|
||||||
b.HasIndex("PrimaryDispatchId");
|
b.HasIndex("PrimaryDispatchId");
|
||||||
|
|
||||||
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
|
b.HasIndex("WorkOrderType");
|
||||||
|
|
||||||
b.ToTable("workOrders");
|
b.ToTable("workOrders");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
49
Data.SeaHavenIndustries/Models/Events.cs
Normal file
49
Data.SeaHavenIndustries/Models/Events.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Events entity for Calendar/Events module
|
||||||
|
/// Tracks calendar events, appointments, meetings, etc.
|
||||||
|
/// </summary>
|
||||||
|
public class Events : FullAuditEntity
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[StringLength(150)]
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
// Related Record (can link to Accounts, Contacts, Work Orders, etc.)
|
||||||
|
public int? RecordID { get; set; }
|
||||||
|
public string? RecordName { get; set; }
|
||||||
|
public string? RecordTargetType { get; set; }
|
||||||
|
|
||||||
|
// Reminder Settings
|
||||||
|
public int ReminderMinutes { get; set; } = 15; // Default: 15 minutes before
|
||||||
|
|
||||||
|
// Color for calendar display
|
||||||
|
[StringLength(20)]
|
||||||
|
public string EventColor { get; set; } = "primary"; // primary, success, info, warning, danger
|
||||||
|
|
||||||
|
// Schedule
|
||||||
|
[Required]
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(10)]
|
||||||
|
public string? StartTime { get; set; } // e.g., "08:00 AM"
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(10)]
|
||||||
|
public string? EndTime { get; set; } // e.g., "08:30 AM"
|
||||||
|
|
||||||
|
public bool AllDay { get; set; } = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,10 +4,6 @@ using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace Data.SeaHavenIndustries
|
namespace Data.SeaHavenIndustries
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Work Order aggregate root. Domain slices (Core, Scheduling, Tracking, Analytics, Completion)
|
|
||||||
/// are persisted as flat columns on workOrders for Fase 0 backward compatibility.
|
|
||||||
/// </summary>
|
|
||||||
public class WorkOrder : FullAuditEntity
|
public class WorkOrder : FullAuditEntity
|
||||||
{
|
{
|
||||||
// --- Core slice ---
|
// --- Core slice ---
|
||||||
|
|
@ -30,6 +26,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public LifecycleStatus? LifecycleStatus { get; set; }
|
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||||
public string? LegacyStatus { get; set; }
|
public string? LegacyStatus { get; set; }
|
||||||
public WorkOrderType? WorkOrderType { get; set; }
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||||
|
public string? Service { get; set; }
|
||||||
public int? PrimaryDispatchId { get; set; }
|
public int? PrimaryDispatchId { get; set; }
|
||||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||||
|
|
|
||||||
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace SeaHaven.DataServices.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal file
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace SeaHaven.DataServices.DTOs
|
||||||
|
{
|
||||||
|
public record WorkOrderBoardQuery(
|
||||||
|
DateOnly ScheduledFrom,
|
||||||
|
DateOnly ScheduledTo,
|
||||||
|
string? Search,
|
||||||
|
IReadOnlyList<string>? AssigneeIds,
|
||||||
|
IReadOnlyList<string>? DbStatuses,
|
||||||
|
int? LocationId);
|
||||||
|
}
|
||||||
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal file
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace SeaHaven.DataServices.DTOs
|
||||||
|
{
|
||||||
|
public record WorkOrderBoardRowData(
|
||||||
|
int Id,
|
||||||
|
string? WoNumber,
|
||||||
|
string? SiteCode,
|
||||||
|
string? LocationLabel,
|
||||||
|
DateTime? ScheduledDate,
|
||||||
|
DateTime? ScheduledStart,
|
||||||
|
DateTime? DueDate,
|
||||||
|
string? WorkOrderType,
|
||||||
|
string? ServiceType,
|
||||||
|
string? AssigneeId,
|
||||||
|
string? AssigneeName,
|
||||||
|
string? VendorCompany,
|
||||||
|
string? VendorTechnician,
|
||||||
|
string? Status,
|
||||||
|
string? PocName,
|
||||||
|
string? PocPhone);
|
||||||
|
}
|
||||||
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Exceptions
|
||||||
|
{
|
||||||
|
public class BoardQueryException : Exception
|
||||||
|
{
|
||||||
|
public BoardQueryException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,5 +134,24 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
&& w.TargetWeek >= dateFrom
|
&& w.TargetWeek >= dateFrom
|
||||||
&& w.TargetWeek <= dateTo));
|
&& w.TargetWeek <= dateTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string? search)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(search))
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
return query.Where(w =>
|
||||||
|
(w.SiteCode ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
||||||
|
|| (w.Service ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Problem ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.Status ?? "").ToLower().Contains(s));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
|
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
|
||||||
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||||
|
|
||||||
var baseQuery = _context.workOrders
|
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
.AsNoTracking()
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||||
.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||||
baseQuery = ApplyDispatcherFilter(baseQuery, query);
|
|
||||||
baseQuery = ApplyTypeFilter(baseQuery, query);
|
|
||||||
|
|
||||||
var scheduledBase = baseQuery.Where(w =>
|
var scheduledBase = baseQuery.Where(w =>
|
||||||
(w.ScheduledDate != null
|
(w.ScheduledDate != null
|
||||||
|
|
@ -42,12 +40,16 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
{
|
{
|
||||||
scheduledBase = ApplySearchFilter(scheduledBase, query.Search);
|
scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search);
|
||||||
unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
|
unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search);
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(scheduledBase, isUnscheduled: false);
|
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||||
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(unscheduledBase, isUnscheduled: true);
|
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
|
||||||
|
isUnscheduled: false);
|
||||||
|
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||||
|
unscheduledBase.OrderBy(w => w.InternalWONumber),
|
||||||
|
isUnscheduled: true);
|
||||||
|
|
||||||
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
|
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
|
||||||
}
|
}
|
||||||
|
|
@ -89,52 +91,5 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
&& w.InternalWONumber == normalizedWoNumber);
|
&& w.InternalWONumber == normalizedWoNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<WorkOrder> ApplyDispatcherFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
|
|
||||||
{
|
|
||||||
if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId))
|
|
||||||
return query.Where(w => w.AssignTo == boardQuery.CurrentUserId);
|
|
||||||
|
|
||||||
if (boardQuery.Dispatchers == null || boardQuery.Dispatchers.Count == 0)
|
|
||||||
return query;
|
|
||||||
|
|
||||||
var ids = boardQuery.Dispatchers
|
|
||||||
.Where(d => d != "__unassigned__")
|
|
||||||
.ToList();
|
|
||||||
var includeUnassigned = boardQuery.Dispatchers.Contains("__unassigned__");
|
|
||||||
|
|
||||||
if (includeUnassigned && ids.Count == 0)
|
|
||||||
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
|
|
||||||
|
|
||||||
if (includeUnassigned && ids.Count > 0)
|
|
||||||
{
|
|
||||||
return query.Where(w =>
|
|
||||||
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IQueryable<WorkOrder> ApplyTypeFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
|
|
||||||
{
|
|
||||||
if (boardQuery.Types == null || boardQuery.Types.Count == 0)
|
|
||||||
return query;
|
|
||||||
|
|
||||||
return query.Where(w => w.WorkOrderType != null && boardQuery.Types.Contains(w.WorkOrderType.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string search)
|
|
||||||
{
|
|
||||||
var s = search.Trim().ToLower();
|
|
||||||
return query.Where(w =>
|
|
||||||
(w.SiteCode ?? "").ToLower().Contains(s)
|
|
||||||
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
|
||||||
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
|
||||||
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
|
||||||
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
|
||||||
|| (w.Trade ?? "").ToLower().Contains(s)
|
|
||||||
|| (w.Problem ?? "").ToLower().Contains(s)
|
|
||||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
|
||||||
|| (w.Status ?? "").ToLower().Contains(s));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record PrimaryDispatchProjection(
|
||||||
|
int WorkOrderId,
|
||||||
|
int? DispatchId,
|
||||||
|
string? VendorName,
|
||||||
|
DateTime? ApptDate,
|
||||||
|
string? DispatchStatus);
|
||||||
|
}
|
||||||
34
SeaHaven.Services.Tests/PagedResultTests.cs
Normal file
34
SeaHaven.Services.Tests/PagedResultTests.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public class PagedResultTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 10, 0)]
|
||||||
|
[InlineData(10, 10, 1)]
|
||||||
|
[InlineData(25, 10, 3)]
|
||||||
|
[InlineData(100, 25, 4)]
|
||||||
|
public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
|
||||||
|
{
|
||||||
|
var result = new PagedResult<string> { TotalCount = totalCount, PageSize = pageSize, Page = 1 };
|
||||||
|
|
||||||
|
result.TotalPages.Should().Be(expectedPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
|
||||||
|
{
|
||||||
|
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
|
||||||
|
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
|
||||||
|
{
|
||||||
|
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
|
||||||
|
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj
Normal file
23
SeaHaven.Services.Tests/SeaHaven.Services.Tests.csproj
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
104
SeaHaven.Services.Tests/ValidationTests.cs
Normal file
104
SeaHaven.Services.Tests/ValidationTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public class CreateAssetValidationTests
|
||||||
|
{
|
||||||
|
private readonly CreateAssetValidation _validator = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenNameEmpty_Fails()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(new CreateAssetDTO { Name = "" });
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenNameTooLong_Fails()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(new CreateAssetDTO { Name = new string('a', 201) });
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenLocationIdZero_Fails()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(new CreateAssetDTO { Name = "Pump", LocationId = 0 });
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateAssetDTO.LocationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenValid_Passes()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(new CreateAssetDTO { Name = "Pump", LocationId = 5 });
|
||||||
|
|
||||||
|
result.IsValid.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateContactValidationTests
|
||||||
|
{
|
||||||
|
private readonly CreateContactValidation _validator = new();
|
||||||
|
|
||||||
|
private static CreateContactDTO Valid() => new()
|
||||||
|
{
|
||||||
|
FirstName = "Ada",
|
||||||
|
LastName = "Lovelace",
|
||||||
|
Email = "ada@example.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenNamesMissing_Fails()
|
||||||
|
{
|
||||||
|
var dto = Valid();
|
||||||
|
dto.FirstName = "";
|
||||||
|
dto.LastName = "";
|
||||||
|
|
||||||
|
var result = _validator.Validate(dto);
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.FirstName));
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.LastName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenEmailMalformed_Fails()
|
||||||
|
{
|
||||||
|
var dto = Valid();
|
||||||
|
dto.Email = "not-an-email";
|
||||||
|
|
||||||
|
var result = _validator.Validate(dto);
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenPhoneTooLong_Fails()
|
||||||
|
{
|
||||||
|
var dto = Valid();
|
||||||
|
dto.Phone = new string('1', 21);
|
||||||
|
|
||||||
|
var result = _validator.Validate(dto);
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateContactDTO.Phone));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenValid_Passes()
|
||||||
|
{
|
||||||
|
var result = _validator.Validate(Valid());
|
||||||
|
|
||||||
|
result.IsValid.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
74
SeaHaven.Services.Tests/WorkOrderMappingTests.cs
Normal file
74
SeaHaven.Services.Tests/WorkOrderMappingTests.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderMappingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MapToModel_WithValidEnumStrings_ParsesStatusAndPriority()
|
||||||
|
{
|
||||||
|
var entity = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
WorkerOrderTitle = "Fix pump",
|
||||||
|
Status = "InProgress",
|
||||||
|
Priority = "High"
|
||||||
|
};
|
||||||
|
|
||||||
|
var dto = WorkOrderDTO.MapToModel(entity);
|
||||||
|
|
||||||
|
dto.Id.Should().Be(7);
|
||||||
|
dto.WorkerOrderTitle.Should().Be("Fix pump");
|
||||||
|
dto.Status.Should().Be(WorkOrderStatus.InProgress);
|
||||||
|
dto.Priority.Should().Be(WorkOrderPriority.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapToModel_WithUnparseableEnumStrings_FallsBackToDefaults()
|
||||||
|
{
|
||||||
|
var entity = new WorkOrder
|
||||||
|
{
|
||||||
|
WorkerOrderTitle = "Garbage",
|
||||||
|
Status = "not-a-status",
|
||||||
|
Priority = "not-a-priority"
|
||||||
|
};
|
||||||
|
|
||||||
|
var dto = WorkOrderDTO.MapToModel(entity);
|
||||||
|
|
||||||
|
dto.Status.Should().Be(WorkOrderStatus.Open);
|
||||||
|
dto.Priority.Should().Be(WorkOrderPriority.Medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapToEntity_SerializesEnumsToStrings()
|
||||||
|
{
|
||||||
|
var dto = new WorkOrderDTO
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkerOrderTitle = "Inspect",
|
||||||
|
Status = WorkOrderStatus.Completed,
|
||||||
|
Priority = WorkOrderPriority.Critical
|
||||||
|
};
|
||||||
|
|
||||||
|
var entity = WorkOrderDTO.MapToEntity(dto);
|
||||||
|
|
||||||
|
entity.Status.Should().Be("Completed");
|
||||||
|
entity.Priority.Should().Be("Critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateMapToEntity_WhenStatusAndPriorityNull_AppliesDefaults()
|
||||||
|
{
|
||||||
|
var dto = new CreateWorkOrderDTO { WorkerOrderTitle = "New" };
|
||||||
|
|
||||||
|
var entity = CreateWorkOrderDTO.MapToEntity(dto);
|
||||||
|
|
||||||
|
entity.Status.Should().Be("Open");
|
||||||
|
entity.Priority.Should().Be("Medium");
|
||||||
|
entity.CreatedDate.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace SeaHaven.Services.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int MaxWindowDays = 90;
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
SeaHaven.Services/Constants/WorkOrderFieldNames.cs
Normal file
26
SeaHaven.Services/Constants/WorkOrderFieldNames.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace SeaHaven.Services.Constants
|
||||||
|
{
|
||||||
|
public static class WorkOrderFieldNames
|
||||||
|
{
|
||||||
|
public const string LifecycleStatus = nameof(LifecycleStatus);
|
||||||
|
public const string AssignTo = nameof(AssignTo);
|
||||||
|
public const string ScheduledDate = nameof(ScheduledDate);
|
||||||
|
public const string ScheduledEnd = nameof(ScheduledEnd);
|
||||||
|
public const string TargetWeek = nameof(TargetWeek);
|
||||||
|
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
|
||||||
|
public const string DueDate = nameof(DueDate);
|
||||||
|
public const string Description = nameof(Description);
|
||||||
|
public const string WorkerOrderTitle = nameof(WorkerOrderTitle);
|
||||||
|
public const string SiteCode = nameof(SiteCode);
|
||||||
|
public const string Building = nameof(Building);
|
||||||
|
public const string LocationId = nameof(LocationId);
|
||||||
|
public const string WorkOrderType = nameof(WorkOrderType);
|
||||||
|
public const string Service = nameof(Service);
|
||||||
|
public const string DocStatus = nameof(DocStatus);
|
||||||
|
public const string Trade = nameof(Trade);
|
||||||
|
public const string Problem = nameof(Problem);
|
||||||
|
public const string Priority = nameof(Priority);
|
||||||
|
public const string InternalWONumber = nameof(InternalWONumber);
|
||||||
|
public const string Status = nameof(Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var trimmed = value.Trim();
|
var trimmed = value.Trim();
|
||||||
var parts = trimmed.Split(new[] { '–', '—', '-' }, StringSplitOptions.RemoveEmptyEntries)
|
var parts = trimmed.Split(new[] { '–', '—', '-' })
|
||||||
.Select(p => p.Trim())
|
.Select(p => p.Trim())
|
||||||
.Where(p => !string.IsNullOrEmpty(p))
|
.Where(p => !string.IsNullOrEmpty(p))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,25 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Helpers
|
namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderDerivedFields
|
public static class WorkOrderDerivedFields
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||||
|
{
|
||||||
|
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
if (workOrder.ScheduledDate == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsPastDue(
|
public static bool IsPastDue(
|
||||||
DateTime? scheduledDate,
|
DateTime? scheduledDate,
|
||||||
LifecycleStatus? lifecycleStatus,
|
LifecycleStatus? lifecycleStatus,
|
||||||
|
|
@ -19,8 +35,36 @@ namespace SeaHaven.Services.Helpers
|
||||||
return scheduledDate.Value.Date < today;
|
return scheduledDate.Value.Date < today;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
|
/// <summary>
|
||||||
=> LifecycleStatusMapper.IsTerminal(lifecycleStatus);
|
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
||||||
|
/// </summary>
|
||||||
|
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
|
||||||
|
ApplyAutoScheduleIfEligible(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
|
||||||
|
/// Week-only targets do not auto-schedule.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (workOrder.ScheduleWeekOnly == true)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (workOrder.ScheduledDate == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static string? GetDayGroup(DateTime? scheduledDate)
|
public static string? GetDayGroup(DateTime? scheduledDate)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderOperationalWeek
|
||||||
|
{
|
||||||
|
public static DateOnly GetMondayOfWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
|
||||||
|
return date.AddDays(-daysFromMonday);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
|
||||||
|
=> GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
|
||||||
|
|
||||||
|
public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
|
||||||
|
=> weekStartMonday.AddDays(4);
|
||||||
|
|
||||||
|
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
|
||||||
|
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
|
|
@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly",
|
WorkOrderFieldNames.LifecycleStatus,
|
||||||
"DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId",
|
WorkOrderFieldNames.AssignTo,
|
||||||
"WorkOrderType", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status"
|
WorkOrderFieldNames.ScheduledDate,
|
||||||
|
WorkOrderFieldNames.ScheduledEnd,
|
||||||
|
WorkOrderFieldNames.TargetWeek,
|
||||||
|
WorkOrderFieldNames.ScheduleWeekOnly,
|
||||||
|
WorkOrderFieldNames.DueDate,
|
||||||
|
WorkOrderFieldNames.Description,
|
||||||
|
WorkOrderFieldNames.WorkerOrderTitle,
|
||||||
|
WorkOrderFieldNames.SiteCode,
|
||||||
|
WorkOrderFieldNames.Building,
|
||||||
|
WorkOrderFieldNames.LocationId,
|
||||||
|
WorkOrderFieldNames.WorkOrderType,
|
||||||
|
WorkOrderFieldNames.Service,
|
||||||
|
WorkOrderFieldNames.DocStatus,
|
||||||
|
WorkOrderFieldNames.Trade,
|
||||||
|
WorkOrderFieldNames.Problem,
|
||||||
|
WorkOrderFieldNames.Priority,
|
||||||
|
WorkOrderFieldNames.InternalWONumber,
|
||||||
|
WorkOrderFieldNames.Status,
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IWorkOrderFieldLockService _fieldLocks;
|
private readonly IWorkOrderFieldLockService _fieldLocks;
|
||||||
private readonly IWorkOrderAuditService _audit;
|
|
||||||
|
|
||||||
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit)
|
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
|
||||||
{
|
{
|
||||||
_fieldLocks = fieldLocks;
|
_fieldLocks = fieldLocks;
|
||||||
_audit = audit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
|
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
|
||||||
|
|
||||||
public bool CanSyncOverwrite(int workOrderId, string fieldName)
|
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
|
||||||
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
|
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
|
||||||
|
|
||||||
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
|
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
|
||||||
{
|
{
|
||||||
|
|
@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyField(context.WorkOrder, fieldName, incomingValue);
|
return TryApplyField(context.WorkOrder, fieldName, incomingValue);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
|
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
|
||||||
{
|
{
|
||||||
"Description" => wo.Description,
|
WorkOrderFieldNames.Description => wo.Description,
|
||||||
"WorkerOrderTitle" => wo.WorkerOrderTitle,
|
WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
|
||||||
"Status" => wo.Status,
|
WorkOrderFieldNames.Status => wo.Status,
|
||||||
"DueDate" => wo.DueDate?.ToString("O"),
|
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
|
||||||
"SiteCode" => wo.SiteCode,
|
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
|
||||||
"Building" => wo.Building,
|
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
|
||||||
"Priority" => wo.Priority,
|
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
|
||||||
"Severity" => wo.Severity,
|
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
|
||||||
|
WorkOrderFieldNames.SiteCode => wo.SiteCode,
|
||||||
|
WorkOrderFieldNames.Building => wo.Building,
|
||||||
|
WorkOrderFieldNames.Priority => wo.Priority,
|
||||||
|
WorkOrderFieldNames.AssignTo => wo.AssignTo,
|
||||||
|
WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(),
|
||||||
|
WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(),
|
||||||
|
WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(),
|
||||||
|
WorkOrderFieldNames.Service => wo.Service,
|
||||||
|
WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(),
|
||||||
|
WorkOrderFieldNames.Trade => wo.Trade,
|
||||||
|
WorkOrderFieldNames.Problem => wo.Problem,
|
||||||
|
WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber,
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue)
|
private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
|
||||||
{
|
{
|
||||||
switch (fieldName)
|
switch (fieldName)
|
||||||
{
|
{
|
||||||
case "Description":
|
case WorkOrderFieldNames.Description:
|
||||||
wo.Description = incomingValue.ToString();
|
wo.Description = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "WorkerOrderTitle":
|
case WorkOrderFieldNames.WorkerOrderTitle:
|
||||||
wo.WorkerOrderTitle = incomingValue.ToString();
|
wo.WorkerOrderTitle = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "Status":
|
case WorkOrderFieldNames.Status:
|
||||||
wo.Status = incomingValue.ToString();
|
wo.Status = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "DueDate" when incomingValue is DateTime dt:
|
case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
|
||||||
wo.DueDate = dt;
|
wo.DueDate = dueDate;
|
||||||
break;
|
return true;
|
||||||
case "SiteCode":
|
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
|
||||||
|
wo.ScheduledDate = scheduledDate;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
|
||||||
|
wo.ScheduledEnd = scheduledEnd;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek):
|
||||||
|
wo.TargetWeek = targetWeek;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly):
|
||||||
|
wo.ScheduleWeekOnly = scheduleWeekOnly;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.SiteCode:
|
||||||
wo.SiteCode = incomingValue.ToString();
|
wo.SiteCode = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "Building":
|
case WorkOrderFieldNames.Building:
|
||||||
wo.Building = incomingValue.ToString();
|
wo.Building = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "Priority":
|
case WorkOrderFieldNames.Priority:
|
||||||
wo.Priority = incomingValue.ToString();
|
wo.Priority = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
case "Severity":
|
case WorkOrderFieldNames.AssignTo:
|
||||||
wo.Severity = incomingValue.ToString();
|
wo.AssignTo = incomingValue.ToString();
|
||||||
break;
|
return true;
|
||||||
|
case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId):
|
||||||
|
wo.LocationId = locationId;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus):
|
||||||
|
wo.LifecycleStatus = lifecycleStatus;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType):
|
||||||
|
wo.WorkOrderType = workOrderType;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.Service:
|
||||||
|
wo.Service = incomingValue.ToString();
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus):
|
||||||
|
wo.DocStatus = docStatus;
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.Trade:
|
||||||
|
wo.Trade = incomingValue.ToString();
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.Problem:
|
||||||
|
wo.Problem = incomingValue.ToString();
|
||||||
|
return true;
|
||||||
|
case WorkOrderFieldNames.InternalWONumber:
|
||||||
|
wo.InternalWONumber = incomingValue.ToString();
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDateTime(object value, out DateTime result)
|
||||||
|
{
|
||||||
|
if (value is DateTime dt)
|
||||||
|
{
|
||||||
|
result = dt;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.TryParse(value.ToString(), out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDateOnly(object value, out DateOnly result)
|
||||||
|
{
|
||||||
|
if (value is DateOnly dateOnly)
|
||||||
|
{
|
||||||
|
result = dateOnly;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is DateTime dt)
|
||||||
|
{
|
||||||
|
result = DateOnly.FromDateTime(dt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateOnly.TryParse(value.ToString(), out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseBool(object value, out bool result)
|
||||||
|
{
|
||||||
|
if (value is bool b)
|
||||||
|
{
|
||||||
|
result = b;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bool.TryParse(value.ToString(), out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseInt(object value, out int result)
|
||||||
|
{
|
||||||
|
if (value is int i)
|
||||||
|
{
|
||||||
|
result = i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return int.TryParse(value.ToString(), out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseEnum<TEnum>(object value, out TEnum result) where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (value is TEnum enumValue)
|
||||||
|
{
|
||||||
|
result = enumValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enum.TryParse(value.ToString(), true, out result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public async Task<bool> IsLockedAsync(int workOrderId, string fieldName)
|
public async Task<bool> IsLockedAsync(int workOrderId, string fieldName)
|
||||||
{
|
{
|
||||||
|
if (IsTracked(workOrderId, fieldName))
|
||||||
|
return true;
|
||||||
|
|
||||||
return await _context.WorkOrderFieldLocks
|
return await _context.WorkOrderFieldLocks
|
||||||
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
|
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
|
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
|
||||||
{
|
{
|
||||||
var exists = await IsLockedAsync(workOrderId, fieldName);
|
if (IsTracked(workOrderId, fieldName))
|
||||||
if (exists) return;
|
return;
|
||||||
|
|
||||||
|
if (await IsLockedAsync(workOrderId, fieldName))
|
||||||
|
return;
|
||||||
|
|
||||||
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
LockedAt = DateTime.UtcNow,
|
LockedAt = DateTime.UtcNow,
|
||||||
LockedByUserId = userId
|
LockedByUserId = userId
|
||||||
});
|
});
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsTracked(int workOrderId, string fieldName)
|
||||||
|
=> _context.WorkOrderFieldLocks.Local.Any(l =>
|
||||||
|
l.WorkOrderId == workOrderId &&
|
||||||
|
string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
IReadOnlySet<string> ShocOwnedFields { get; }
|
IReadOnlySet<string> ShocOwnedFields { get; }
|
||||||
|
|
||||||
bool CanSyncOverwrite(int workOrderId, string fieldName);
|
Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
|
||||||
|
|
||||||
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
|
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
SeaHavenIndustries.Tests/ConcurrencyExceptionFilterTests.cs
Normal file
50
SeaHavenIndustries.Tests/ConcurrencyExceptionFilterTests.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Api.SeaHavenIndustries.Filters;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class ConcurrencyExceptionFilterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void OnException_DbUpdateConcurrencyException_Returns409Conflict()
|
||||||
|
{
|
||||||
|
var filter = new ConcurrencyExceptionFilter();
|
||||||
|
var context = CreateExceptionContext(new DbUpdateConcurrencyException());
|
||||||
|
|
||||||
|
filter.OnException(context);
|
||||||
|
|
||||||
|
Assert.True(context.ExceptionHandled);
|
||||||
|
var result = Assert.IsType<ConflictObjectResult>(context.Result);
|
||||||
|
Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnException_OtherException_DoesNotHandle()
|
||||||
|
{
|
||||||
|
var filter = new ConcurrencyExceptionFilter();
|
||||||
|
var context = CreateExceptionContext(new InvalidOperationException("other"));
|
||||||
|
|
||||||
|
filter.OnException(context);
|
||||||
|
|
||||||
|
Assert.False(context.ExceptionHandled);
|
||||||
|
Assert.Null(context.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExceptionContext CreateExceptionContext(Exception exception)
|
||||||
|
{
|
||||||
|
var actionContext = new ActionContext(
|
||||||
|
new DefaultHttpContext(),
|
||||||
|
new RouteData(),
|
||||||
|
new ActionDescriptor());
|
||||||
|
|
||||||
|
return new ExceptionContext(actionContext, [])
|
||||||
|
{
|
||||||
|
Exception = exception
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
namespace SeaHavenIndustries.Tests;
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
@ -7,43 +8,33 @@ public class LifecycleStatusMapperTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Open", LifecycleStatus.Incomplete)]
|
[InlineData("Open", LifecycleStatus.Incomplete)]
|
||||||
|
[InlineData("Incomplete", LifecycleStatus.Incomplete)]
|
||||||
[InlineData("InProgress", LifecycleStatus.InProgress)]
|
[InlineData("InProgress", LifecycleStatus.InProgress)]
|
||||||
[InlineData("In Progress", LifecycleStatus.InProgress)]
|
[InlineData("In Progress", LifecycleStatus.InProgress)]
|
||||||
[InlineData("Completed", LifecycleStatus.Completed)]
|
[InlineData("Completed", LifecycleStatus.Completed)]
|
||||||
[InlineData("Cancelled", LifecycleStatus.Canceled)]
|
[InlineData("Cancelled", LifecycleStatus.Canceled)]
|
||||||
[InlineData("On Hold", LifecycleStatus.Pending)]
|
[InlineData("On Hold", LifecycleStatus.Pending)]
|
||||||
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
|
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
|
||||||
|
[InlineData("En Route", LifecycleStatus.EnRoute)]
|
||||||
|
[InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
|
||||||
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
|
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
|
||||||
{
|
{
|
||||||
var result = LifecycleStatusMapper.FromLegacyStatus(input);
|
var result = LifecycleStatusMapper.FromLegacyStatus(input);
|
||||||
Assert.Equal(expected, result);
|
Assert.Equal(expected, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Incomplete", LifecycleStatus.Incomplete)]
|
|
||||||
[InlineData("En Route", LifecycleStatus.EnRoute)]
|
|
||||||
[InlineData("On Site", LifecycleStatus.OnSite)]
|
|
||||||
[InlineData("Rescheduled", LifecycleStatus.Rescheduled)]
|
|
||||||
[InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
|
|
||||||
public void ParseLifecycleStatus_MapsFeLabels(string input, LifecycleStatus expected)
|
|
||||||
{
|
|
||||||
var result = LifecycleStatusMapper.ParseLifecycleStatus(input);
|
|
||||||
Assert.Equal(expected, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(LifecycleStatus.EnRoute, "En Route")]
|
|
||||||
[InlineData(LifecycleStatus.OnSite, "On Site")]
|
|
||||||
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
|
|
||||||
public void ToFeLabel_ReturnsFrontendLabels(LifecycleStatus status, string expected)
|
|
||||||
{
|
|
||||||
Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromLegacyStatus_Unknown_ReturnsNull()
|
public void FromLegacyStatus_Unknown_ReturnsNull()
|
||||||
{
|
{
|
||||||
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
|
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
|
||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LifecycleStatus.EnRoute, "En Route")]
|
||||||
|
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
|
||||||
|
public void ToFeLabel_ReturnsBoardLabel(LifecycleStatus status, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
|
||||||
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
||||||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests
|
||||||
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
{
|
{
|
||||||
WorkOrderId = 10,
|
WorkOrderId = 10,
|
||||||
FieldName = "Description",
|
FieldName = WorkOrderFieldNames.Description,
|
||||||
LockedAt = DateTime.UtcNow
|
LockedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
var locks = new WorkOrderFieldLockService(context);
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
var audit = new WorkOrderAuditService(context, locks);
|
var audit = new WorkOrderAuditService(context, locks);
|
||||||
var policy = new SyncFieldMergePolicy(locks, audit);
|
var policy = new SyncFieldMergePolicy(locks);
|
||||||
|
|
||||||
var syncContext = new WorkOrderSyncContext
|
var syncContext = new WorkOrderSyncContext
|
||||||
{
|
{
|
||||||
|
|
@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests
|
||||||
Audit = audit
|
Audit = audit
|
||||||
};
|
};
|
||||||
|
|
||||||
var applied = await policy.TryApplyAsync(syncContext, "Description", "Sync value");
|
var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "Sync value");
|
||||||
|
|
||||||
Assert.False(applied);
|
Assert.False(applied);
|
||||||
Assert.Equal("SHOC edited", wo.Description);
|
Assert.Equal("SHOC edited", wo.Description);
|
||||||
|
|
@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests
|
||||||
|
|
||||||
var locks = new WorkOrderFieldLockService(context);
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
var audit = new WorkOrderAuditService(context, locks);
|
var audit = new WorkOrderAuditService(context, locks);
|
||||||
var policy = new SyncFieldMergePolicy(locks, audit);
|
var policy = new SyncFieldMergePolicy(locks);
|
||||||
|
|
||||||
var syncContext = new WorkOrderSyncContext
|
var syncContext = new WorkOrderSyncContext
|
||||||
{
|
{
|
||||||
|
|
@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests
|
||||||
Audit = audit
|
Audit = audit
|
||||||
};
|
};
|
||||||
|
|
||||||
var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync");
|
var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "From sync");
|
||||||
|
|
||||||
Assert.True(applied);
|
Assert.True(applied);
|
||||||
Assert.Equal("From sync", wo.Description);
|
Assert.Equal("From sync", wo.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryApply_UnsupportedField_ReturnsFalse()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var wo = new WorkOrder { Id = 12 };
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, locks);
|
||||||
|
var policy = new SyncFieldMergePolicy(locks);
|
||||||
|
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = wo,
|
||||||
|
FieldLocks = locks,
|
||||||
|
Audit = audit
|
||||||
|
};
|
||||||
|
|
||||||
|
var applied = await policy.TryApplyAsync(syncContext, "NotARealField", "value");
|
||||||
|
|
||||||
|
Assert.False(applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WorkOrderFieldNames.LifecycleStatus, LifecycleStatus.Scheduled)]
|
||||||
|
[InlineData(WorkOrderFieldNames.Service, "PM")]
|
||||||
|
[InlineData(WorkOrderFieldNames.DueDate, "2026-07-01T10:00:00Z")]
|
||||||
|
public async Task TryApply_ShocOwnedFields_AppliesValue(string fieldName, object incomingValue)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var wo = new WorkOrder { Id = 13 };
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, locks);
|
||||||
|
var policy = new SyncFieldMergePolicy(locks);
|
||||||
|
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = wo,
|
||||||
|
FieldLocks = locks,
|
||||||
|
Audit = audit
|
||||||
|
};
|
||||||
|
|
||||||
|
var applied = await policy.TryApplyAsync(syncContext, fieldName, incomingValue);
|
||||||
|
|
||||||
|
Assert.True(applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanSyncOverwriteAsync_WhenLocked_ReturnsFalse()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
|
{
|
||||||
|
WorkOrderId = 20,
|
||||||
|
FieldName = WorkOrderFieldNames.AssignTo,
|
||||||
|
LockedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
|
var policy = new SyncFieldMergePolicy(locks);
|
||||||
|
|
||||||
|
var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo);
|
||||||
|
|
||||||
|
Assert.False(canOverwrite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests
|
||||||
Assert.Equal("StatusChanged", logs[0].Action);
|
Assert.Equal("StatusChanged", logs[0].Action);
|
||||||
Assert.Equal("Manual", logs[0].EventType);
|
Assert.Equal("Manual", logs[0].EventType);
|
||||||
Assert.Equal("Dispatcher", logs[0].ActorType);
|
Assert.Equal("Dispatcher", logs[0].ActorType);
|
||||||
Assert.Equal("LifecycleStatus", logs[0].FieldName);
|
Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests
|
||||||
|
|
||||||
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
|
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||||
Assert.Single(fieldLocks);
|
Assert.Single(fieldLocks);
|
||||||
Assert.Equal("LifecycleStatus", fieldLocks[0].FieldName);
|
Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,13 +170,12 @@ public class WorkOrderBoardServiceTests
|
||||||
{
|
{
|
||||||
await using var context = CreateContext();
|
await using var context = CreateContext();
|
||||||
var weekStart = new DateOnly(2026, 6, 22);
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
var utcPast = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
InternalWONumber = "10000000001",
|
InternalWONumber = "10000000001",
|
||||||
ScheduledDate = utcPast.AddDays(-2),
|
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -193,7 +192,7 @@ public class WorkOrderBoardServiceTests
|
||||||
Assert.Equal(1, response.Counts.Total);
|
Assert.Equal(1, response.Counts.Total);
|
||||||
Assert.Single(response.Scheduled);
|
Assert.Single(response.Scheduled);
|
||||||
Assert.True(response.Scheduled[0].IsPastDue);
|
Assert.True(response.Scheduled[0].IsPastDue);
|
||||||
Assert.Equal("monday", response.Scheduled[0].DayGroup);
|
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
|
@ -5,76 +6,70 @@ namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public class WorkOrderDerivedFieldsTests
|
public class WorkOrderDerivedFieldsTests
|
||||||
{
|
{
|
||||||
private static readonly DateTime Today = new(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
|
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
|
||||||
{
|
{
|
||||||
var result = WorkOrderDerivedFields.IsPastDue(
|
var wo = new WorkOrder
|
||||||
Today.AddDays(-1),
|
{
|
||||||
LifecycleStatus.Scheduled,
|
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||||
Today);
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
};
|
||||||
|
|
||||||
Assert.True(result);
|
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenScheduledToday_ReturnsFalse()
|
public void IsPastDue_WhenCompleted_ReturnsFalse()
|
||||||
{
|
{
|
||||||
var result = WorkOrderDerivedFields.IsPastDue(
|
var wo = new WorkOrder
|
||||||
Today,
|
{
|
||||||
LifecycleStatus.Scheduled,
|
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||||
Today);
|
LifecycleStatus = LifecycleStatus.Completed
|
||||||
|
};
|
||||||
|
|
||||||
Assert.False(result);
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenComplete_ReturnsFalse()
|
public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
|
||||||
{
|
{
|
||||||
var result = WorkOrderDerivedFields.IsPastDue(
|
var wo = new WorkOrder
|
||||||
Today.AddDays(-3),
|
{
|
||||||
LifecycleStatus.Completed,
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
Today);
|
ScheduledDate = new DateTime(2026, 6, 25),
|
||||||
|
AssignTo = "dispatcher-1"
|
||||||
|
};
|
||||||
|
|
||||||
Assert.False(result);
|
Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenNoScheduledDate_ReturnsFalse()
|
public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
|
||||||
{
|
{
|
||||||
var result = WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Incomplete, Today);
|
var wo = new WorkOrder
|
||||||
Assert.False(result);
|
{
|
||||||
}
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25),
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
ScheduleWeekOnly = true,
|
||||||
|
TargetWeek = new DateOnly(2026, 6, 22)
|
||||||
|
};
|
||||||
|
|
||||||
[Theory]
|
Assert.False(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
|
||||||
[InlineData(DayOfWeek.Monday, "monday")]
|
Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
|
||||||
[InlineData(DayOfWeek.Friday, "friday")]
|
|
||||||
[InlineData(DayOfWeek.Saturday, null)]
|
|
||||||
public void GetDayGroup_MapsWeekdays(DayOfWeek day, string? expected)
|
|
||||||
{
|
|
||||||
var date = new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
while (date.DayOfWeek != day)
|
|
||||||
date = date.AddDays(1);
|
|
||||||
|
|
||||||
Assert.Equal(expected, WorkOrderDerivedFields.GetDayGroup(date));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatApptTime_WithStartAndEnd_ReturnsRange()
|
public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
|
||||||
{
|
{
|
||||||
var start = new DateTime(2026, 6, 24, 7, 0, 0);
|
var wo = new WorkOrder
|
||||||
var end = new DateTime(2026, 6, 24, 9, 0, 0);
|
{
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25),
|
||||||
|
AssignTo = "dispatcher-1"
|
||||||
|
};
|
||||||
|
|
||||||
var result = WorkOrderDerivedFields.FormatApptTime(start, end);
|
WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||||
Assert.Equal("07:00 – 09:00", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FormatApptTime_WithStartOnly_ReturnsTime()
|
|
||||||
{
|
|
||||||
var start = new DateTime(2026, 6, 24, 7, 30, 0);
|
|
||||||
Assert.Equal("07:30", WorkOrderDerivedFields.FormatApptTime(start, null));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
Normal file
53
SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.Constants;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderFieldLockServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
|
|
||||||
|
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1");
|
||||||
|
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2");
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||||
|
Assert.Single(fieldLocks);
|
||||||
|
Assert.Equal(WorkOrderFieldNames.Description, fieldLocks[0].FieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LockFieldAsync_WhenAlreadyPersisted_IsIdempotent()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
|
{
|
||||||
|
WorkOrderId = 2,
|
||||||
|
FieldName = WorkOrderFieldNames.AssignTo,
|
||||||
|
LockedAt = DateTime.UtcNow,
|
||||||
|
LockedByUserId = "user-1"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var locks = new WorkOrderFieldLockService(context);
|
||||||
|
await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2");
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||||
|
Assert.Single(fieldLocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
Normal file
32
SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderTypeMapperTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("PM", WorkOrderType.PM)]
|
||||||
|
[InlineData("Reactive", WorkOrderType.Reactive)]
|
||||||
|
[InlineData("Emergency", WorkOrderType.Emergency)]
|
||||||
|
[InlineData("Add-On", WorkOrderType.AddOn)]
|
||||||
|
public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
|
||||||
|
{
|
||||||
|
Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
|
||||||
|
Assert.Equal(expected, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseFeLabel_Overdue_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WorkOrderType.AddOn, "Add-On")]
|
||||||
|
[InlineData(WorkOrderType.Reactive, "Reactive")]
|
||||||
|
public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services", "SeaHav
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "SeaHaven.DataServices\SeaHaven.DataServices.csproj", "{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "SeaHaven.DataServices\SeaHaven.DataServices.csproj", "{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.SeaHavenIndustries.Tests", "Api.SeaHavenIndustries.Tests\Api.SeaHavenIndustries.Tests.csproj", "{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services.Tests", "SeaHaven.Services.Tests\SeaHaven.Services.Tests.csproj", "{97455198-C479-47F2-8BC8-601B335DFFAB}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHavenIndustries.Tests", "SeaHavenIndustries.Tests\SeaHavenIndustries.Tests.csproj", "{0093B3F5-0180-4C02-A088-6A564E06CEBF}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHavenIndustries.Tests", "SeaHavenIndustries.Tests\SeaHavenIndustries.Tests.csproj", "{0093B3F5-0180-4C02-A088-6A564E06CEBF}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
|
|
@ -41,6 +45,14 @@ Global
|
||||||
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.Build.0 = Release|Any CPU
|
{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1222E2E5-2FB0-4AA8-842C-0D77CFBA5E45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{97455198-C479-47F2-8BC8-601B335DFFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{97455198-C479-47F2-8BC8-601B335DFFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
<PackageReference Include="BlazorGoogleMaps" Version="4.7.7" />
|
<PackageReference Include="BlazorGoogleMaps" Version="4.7.7" />
|
||||||
<PackageReference Include="EPPlus" Version="7.3.0" />
|
<PackageReference Include="EPPlus" Version="7.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8" />
|
||||||
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
|
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
|
||||||
<PackageReference Include="PuppeteerSharp" Version="19.0.1" />
|
<PackageReference Include="PuppeteerSharp" Version="19.0.1" />
|
||||||
<PackageReference Include="Radzen.Blazor" Version="5.1.3" />
|
<PackageReference Include="Radzen.Blazor" Version="5.1.3" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue