mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +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.VendorPortalTokenService _vendorTokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
|
||||
public WorkOrderController(
|
||||
|
|
@ -61,6 +62,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Helper.SendMessage sendMessage,
|
||||
Helper.VendorPortalTokenService vendorTokens,
|
||||
IConfiguration config,
|
||||
IWorkOrderBoardService workOrderBoardService,
|
||||
IWorkOrderBoardUpdateService boardUpdateService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
|
|
@ -78,10 +80,41 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_sendMessage = sendMessage;
|
||||
_vendorTokens = vendorTokens;
|
||||
_config = config;
|
||||
_workOrderBoardService = workOrderBoardService;
|
||||
_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")]
|
||||
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>()
|
||||
.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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<FollowUps> FollowUps { get; set; }
|
||||
public DbSet<PMSchedules> PMSchedules { get; set; }
|
||||
public DbSet<ContactDetails> ContactDetails { get; set; }
|
||||
public DbSet<Events> Events { get; set; }
|
||||
|
||||
// Employee related DbSets
|
||||
public DbSet<EmployeeAddress> EmployeeAddresses { get; set; }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.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>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,162 +1,171 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
|
||||
public enum WorkOrderStatus
|
||||
{
|
||||
Open = 1,
|
||||
InProgress = 2,
|
||||
Completed = 3,
|
||||
Cancelled = 4,
|
||||
OnHold = 5
|
||||
}
|
||||
|
||||
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
|
||||
public enum LifecycleStatus
|
||||
{
|
||||
Incomplete = 1,
|
||||
Pending = 2,
|
||||
Scheduled = 3,
|
||||
EnRoute = 4,
|
||||
OnSite = 5,
|
||||
InProgress = 6,
|
||||
Completed = 7,
|
||||
Rescheduled = 8,
|
||||
Canceled = 9,
|
||||
PendingQuote = 10
|
||||
}
|
||||
|
||||
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
|
||||
public enum WorkOrderType
|
||||
{
|
||||
PO = 1,
|
||||
PM = 2,
|
||||
Emergency = 3,
|
||||
Project = 4,
|
||||
Inspection = 5,
|
||||
Reactive = 6,
|
||||
AddOn = 7,
|
||||
Other = 99
|
||||
}
|
||||
|
||||
public enum DocStatus
|
||||
{
|
||||
Yes = 1,
|
||||
No = 2,
|
||||
NotNeeded = 3
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum OperationalFlags
|
||||
{
|
||||
None = 0,
|
||||
PastDue = 1
|
||||
}
|
||||
|
||||
public enum AuditEventType
|
||||
{
|
||||
Manual = 1,
|
||||
System = 2,
|
||||
Sync = 3,
|
||||
Vendor = 4
|
||||
}
|
||||
|
||||
public enum AuditActorType
|
||||
{
|
||||
Dispatcher = 1,
|
||||
Vendor = 2,
|
||||
System = 3,
|
||||
Sync = 4
|
||||
}
|
||||
|
||||
public enum AuditActionType
|
||||
{
|
||||
FieldChanged = 1,
|
||||
StatusChanged = 2,
|
||||
AssignmentChanged = 3,
|
||||
WeekRolled = 4,
|
||||
SyncRejected = 5,
|
||||
Create = 6,
|
||||
Delete = 7
|
||||
}
|
||||
|
||||
public enum WorkOrderPriority
|
||||
{
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3,
|
||||
Critical = 4
|
||||
}
|
||||
|
||||
public enum AuditAction
|
||||
{
|
||||
Create = 1,
|
||||
Edit = 2,
|
||||
Delete = 3,
|
||||
StatusChange = 4,
|
||||
AssignmentChange = 5,
|
||||
Dispatch = 6,
|
||||
DispatchUpdate = 7,
|
||||
DispatchVerify = 8
|
||||
}
|
||||
|
||||
public enum ContactType
|
||||
{
|
||||
Primary = 1,
|
||||
Secondary = 2,
|
||||
Billing = 3,
|
||||
Technical = 4
|
||||
}
|
||||
|
||||
public enum AssetStatus
|
||||
{
|
||||
Active = 1,
|
||||
Inactive = 2,
|
||||
UnderMaintenance = 3,
|
||||
Retired = 4
|
||||
}
|
||||
|
||||
public enum DispatchStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Assigned = 2,
|
||||
InProgress = 3,
|
||||
Completed = 4,
|
||||
Cancelled = 5
|
||||
}
|
||||
|
||||
public enum FollowUpStatus
|
||||
{
|
||||
Open = 1,
|
||||
InProgress = 2,
|
||||
Resolved = 3,
|
||||
Closed = 4
|
||||
}
|
||||
|
||||
public enum PMScheduleFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
Monthly = 3,
|
||||
Quarterly = 4,
|
||||
SemiAnnual = 5,
|
||||
Annual = 6
|
||||
}
|
||||
|
||||
public enum CommentType
|
||||
{
|
||||
General = 1,
|
||||
Internal = 2,
|
||||
Customer = 3,
|
||||
Vendor = 4,
|
||||
System = 5
|
||||
}
|
||||
|
||||
public enum RecordType
|
||||
{
|
||||
WorkOrder = 1,
|
||||
Dispatch = 2,
|
||||
Quote = 3,
|
||||
Contact = 4,
|
||||
Asset = 5
|
||||
}
|
||||
}
|
||||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
|
||||
public enum WorkOrderStatus
|
||||
{
|
||||
Open = 1,
|
||||
InProgress = 2,
|
||||
Completed = 3,
|
||||
Cancelled = 4,
|
||||
OnHold = 5
|
||||
}
|
||||
|
||||
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
|
||||
public enum LifecycleStatus
|
||||
{
|
||||
Incomplete = 1,
|
||||
Pending = 2,
|
||||
Scheduled = 3,
|
||||
EnRoute = 4,
|
||||
OnSite = 5,
|
||||
InProgress = 6,
|
||||
Completed = 7,
|
||||
Rescheduled = 8,
|
||||
Canceled = 9,
|
||||
PendingQuote = 10
|
||||
}
|
||||
|
||||
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
|
||||
public enum WorkOrderType
|
||||
{
|
||||
PO = 1,
|
||||
PM = 2,
|
||||
Emergency = 3,
|
||||
Project = 4,
|
||||
Inspection = 5,
|
||||
Reactive = 6,
|
||||
AddOn = 7,
|
||||
Other = 99
|
||||
}
|
||||
|
||||
public enum DocStatus
|
||||
{
|
||||
Yes = 1,
|
||||
No = 2,
|
||||
NotNeeded = 3
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum OperationalFlags
|
||||
{
|
||||
None = 0,
|
||||
PastDue = 1
|
||||
}
|
||||
|
||||
public enum AuditEventType
|
||||
{
|
||||
Manual = 1,
|
||||
System = 2,
|
||||
Sync = 3,
|
||||
Vendor = 4
|
||||
}
|
||||
|
||||
public enum AuditActorType
|
||||
{
|
||||
Dispatcher = 1,
|
||||
Vendor = 2,
|
||||
System = 3,
|
||||
Sync = 4
|
||||
}
|
||||
|
||||
public enum AuditActionType
|
||||
{
|
||||
FieldChanged = 1,
|
||||
StatusChanged = 2,
|
||||
AssignmentChanged = 3,
|
||||
WeekRolled = 4,
|
||||
SyncRejected = 5,
|
||||
Create = 6,
|
||||
Delete = 7
|
||||
}
|
||||
|
||||
public enum WorkOrderPriority
|
||||
{
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3,
|
||||
Critical = 4
|
||||
}
|
||||
|
||||
public enum AuditAction
|
||||
{
|
||||
Create = 1,
|
||||
Edit = 2,
|
||||
Delete = 3,
|
||||
StatusChange = 4,
|
||||
AssignmentChange = 5,
|
||||
Dispatch = 6,
|
||||
DispatchUpdate = 7,
|
||||
DispatchVerify = 8
|
||||
}
|
||||
|
||||
public enum ContactType
|
||||
{
|
||||
Primary = 1,
|
||||
Secondary = 2,
|
||||
Billing = 3,
|
||||
Technical = 4
|
||||
}
|
||||
|
||||
public enum AssetStatus
|
||||
{
|
||||
Active = 1,
|
||||
Inactive = 2,
|
||||
UnderMaintenance = 3,
|
||||
Retired = 4
|
||||
}
|
||||
|
||||
public enum DispatchStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Assigned = 2,
|
||||
InProgress = 3,
|
||||
Completed = 4,
|
||||
Cancelled = 5
|
||||
}
|
||||
|
||||
public enum FollowUpStatus
|
||||
{
|
||||
Open = 1,
|
||||
InProgress = 2,
|
||||
Resolved = 3,
|
||||
Closed = 4
|
||||
}
|
||||
|
||||
public enum PMScheduleFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
Monthly = 3,
|
||||
Quarterly = 4,
|
||||
SemiAnnual = 5,
|
||||
Annual = 6
|
||||
}
|
||||
|
||||
public enum CommentType
|
||||
{
|
||||
General = 1,
|
||||
Internal = 2,
|
||||
Customer = 3,
|
||||
Vendor = 4,
|
||||
System = 5
|
||||
}
|
||||
|
||||
public enum RecordType
|
||||
{
|
||||
WorkOrder = 1,
|
||||
Dispatch = 2,
|
||||
Quote = 3,
|
||||
Contact = 4,
|
||||
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")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Service")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("SignOffAttachment")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
|
|||
|
|
@ -264,6 +264,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Service",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "WorkOrderContacts",
|
||||
|
|
@ -367,17 +374,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE workOrders SET LifecycleStatus = CASE
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open') THEN 1
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 2
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 3
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete') THEN 4
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 5
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('onhold', 'on hold') THEN 6
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingvendor', 'pending vendor') THEN 7
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pendingapproval', 'pending approval') THEN 8
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('needsreview', 'needs review') THEN 9
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('closed') THEN 10
|
||||
ELSE 9
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open', 'incomplete') THEN 1
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending', 'onhold', 'on hold') THEN 2
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 3
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('en route', 'enroute') THEN 4
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('on site', 'onsite') THEN 5
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 6
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete', 'done', 'closed') THEN 7
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('rescheduled') THEN 8
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 9
|
||||
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending quote', 'pendingquote', 'pendingapproval', 'pending approval') THEN 10
|
||||
ELSE 1
|
||||
END
|
||||
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
|
||||
""");
|
||||
|
|
@ -477,6 +484,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
name: "WorkOrderType",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Service",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Notes",
|
||||
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")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Service")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("SignOffAttachment")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2027,10 +2031,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
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
|
||||
{
|
||||
/// <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
|
||||
{
|
||||
// --- Core slice ---
|
||||
|
|
@ -30,6 +26,8 @@ namespace Data.SeaHavenIndustries
|
|||
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||
public string? LegacyStatus { 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; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
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 <= 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 weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
var baseQuery = _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
baseQuery = ApplyDispatcherFilter(baseQuery, query);
|
||||
baseQuery = ApplyTypeFilter(baseQuery, query);
|
||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||
|
||||
var scheduledBase = baseQuery.Where(w =>
|
||||
(w.ScheduledDate != null
|
||||
|
|
@ -42,12 +40,16 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
scheduledBase = ApplySearchFilter(scheduledBase, query.Search);
|
||||
unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
|
||||
scheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(scheduledBase, query.Search);
|
||||
unscheduledBase = WorkOrderBoardQueryFilters.ApplySearchFilter(unscheduledBase, query.Search);
|
||||
}
|
||||
|
||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(scheduledBase, isUnscheduled: false);
|
||||
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(unscheduledBase, isUnscheduled: true);
|
||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||
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);
|
||||
}
|
||||
|
|
@ -89,52 +91,5 @@ namespace SeaHaven.DataServices.Implementation
|
|||
&& 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;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(new[] { '–', '—', '-' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
var parts = trimmed.Split(new[] { '–', '—', '-' })
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.ToArray();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,25 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
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(
|
||||
DateTime? scheduledDate,
|
||||
LifecycleStatus? lifecycleStatus,
|
||||
|
|
@ -19,8 +35,36 @@ namespace SeaHaven.Services.Helpers
|
|||
return scheduledDate.Value.Date < today;
|
||||
}
|
||||
|
||||
public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
|
||||
=> LifecycleStatusMapper.IsTerminal(lifecycleStatus);
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
|
|
|
|||
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.Enums;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -7,24 +9,39 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"LifecycleStatus", "AssignTo", "ScheduledDate", "ScheduledEnd", "TargetWeek", "ScheduleWeekOnly",
|
||||
"DueDate", "Description", "WorkerOrderTitle", "SiteCode", "Building", "LocationId",
|
||||
"WorkOrderType", "DocStatus", "Trade", "Problem", "Priority", "InternalWONumber", "Status"
|
||||
WorkOrderFieldNames.LifecycleStatus,
|
||||
WorkOrderFieldNames.AssignTo,
|
||||
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 IWorkOrderAuditService _audit;
|
||||
|
||||
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks, IWorkOrderAuditService audit)
|
||||
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
|
||||
{
|
||||
_fieldLocks = fieldLocks;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
|
||||
|
||||
public bool CanSyncOverwrite(int workOrderId, string fieldName)
|
||||
=> !_fieldLocks.IsLockedAsync(workOrderId, fieldName).GetAwaiter().GetResult();
|
||||
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
|
||||
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
|
||||
|
||||
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
|
||||
{
|
||||
|
|
@ -41,52 +58,162 @@ namespace SeaHaven.Services.Implementation
|
|||
return false;
|
||||
}
|
||||
|
||||
ApplyField(context.WorkOrder, fieldName, incomingValue);
|
||||
return true;
|
||||
return TryApplyField(context.WorkOrder, fieldName, incomingValue);
|
||||
}
|
||||
|
||||
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
|
||||
{
|
||||
"Description" => wo.Description,
|
||||
"WorkerOrderTitle" => wo.WorkerOrderTitle,
|
||||
"Status" => wo.Status,
|
||||
"DueDate" => wo.DueDate?.ToString("O"),
|
||||
"SiteCode" => wo.SiteCode,
|
||||
"Building" => wo.Building,
|
||||
"Priority" => wo.Priority,
|
||||
"Severity" => wo.Severity,
|
||||
WorkOrderFieldNames.Description => wo.Description,
|
||||
WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
|
||||
WorkOrderFieldNames.Status => wo.Status,
|
||||
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
|
||||
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
|
||||
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
|
||||
};
|
||||
|
||||
private static void ApplyField(WorkOrder wo, string fieldName, object incomingValue)
|
||||
private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
|
||||
{
|
||||
switch (fieldName)
|
||||
{
|
||||
case "Description":
|
||||
case WorkOrderFieldNames.Description:
|
||||
wo.Description = incomingValue.ToString();
|
||||
break;
|
||||
case "WorkerOrderTitle":
|
||||
return true;
|
||||
case WorkOrderFieldNames.WorkerOrderTitle:
|
||||
wo.WorkerOrderTitle = incomingValue.ToString();
|
||||
break;
|
||||
case "Status":
|
||||
return true;
|
||||
case WorkOrderFieldNames.Status:
|
||||
wo.Status = incomingValue.ToString();
|
||||
break;
|
||||
case "DueDate" when incomingValue is DateTime dt:
|
||||
wo.DueDate = dt;
|
||||
break;
|
||||
case "SiteCode":
|
||||
return true;
|
||||
case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
|
||||
wo.DueDate = dueDate;
|
||||
return true;
|
||||
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();
|
||||
break;
|
||||
case "Building":
|
||||
return true;
|
||||
case WorkOrderFieldNames.Building:
|
||||
wo.Building = incomingValue.ToString();
|
||||
break;
|
||||
case "Priority":
|
||||
return true;
|
||||
case WorkOrderFieldNames.Priority:
|
||||
wo.Priority = incomingValue.ToString();
|
||||
break;
|
||||
case "Severity":
|
||||
wo.Severity = incomingValue.ToString();
|
||||
break;
|
||||
return true;
|
||||
case WorkOrderFieldNames.AssignTo:
|
||||
wo.AssignTo = incomingValue.ToString();
|
||||
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)
|
||||
{
|
||||
if (IsTracked(workOrderId, fieldName))
|
||||
return true;
|
||||
|
||||
return await _context.WorkOrderFieldLocks
|
||||
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
|
||||
}
|
||||
|
||||
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
|
||||
{
|
||||
var exists = await IsLockedAsync(workOrderId, fieldName);
|
||||
if (exists) return;
|
||||
if (IsTracked(workOrderId, fieldName))
|
||||
return;
|
||||
|
||||
if (await IsLockedAsync(workOrderId, fieldName))
|
||||
return;
|
||||
|
||||
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
{
|
||||
|
|
@ -31,7 +37,11 @@ namespace SeaHaven.Services.Implementation
|
|||
LockedAt = DateTime.UtcNow,
|
||||
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; }
|
||||
|
||||
bool CanSyncOverwrite(int workOrderId, string fieldName);
|
||||
Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
|
||||
|
||||
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 SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
|
@ -7,43 +8,33 @@ public class LifecycleStatusMapperTests
|
|||
{
|
||||
[Theory]
|
||||
[InlineData("Open", LifecycleStatus.Incomplete)]
|
||||
[InlineData("Incomplete", LifecycleStatus.Incomplete)]
|
||||
[InlineData("InProgress", LifecycleStatus.InProgress)]
|
||||
[InlineData("In Progress", LifecycleStatus.InProgress)]
|
||||
[InlineData("Completed", LifecycleStatus.Completed)]
|
||||
[InlineData("Cancelled", LifecycleStatus.Canceled)]
|
||||
[InlineData("On Hold", LifecycleStatus.Pending)]
|
||||
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
|
||||
[InlineData("En Route", LifecycleStatus.EnRoute)]
|
||||
[InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
|
||||
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
|
||||
{
|
||||
var result = LifecycleStatusMapper.FromLegacyStatus(input);
|
||||
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]
|
||||
public void FromLegacyStatus_Unknown_ReturnsNull()
|
||||
{
|
||||
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
|
||||
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>
|
||||
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
|
||||
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
||||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
|
|
@ -24,14 +26,14 @@ public class SyncFieldMergePolicyTests
|
|||
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
{
|
||||
WorkOrderId = 10,
|
||||
FieldName = "Description",
|
||||
FieldName = WorkOrderFieldNames.Description,
|
||||
LockedAt = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var locks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, locks);
|
||||
var policy = new SyncFieldMergePolicy(locks, audit);
|
||||
var policy = new SyncFieldMergePolicy(locks);
|
||||
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
|
|
@ -40,7 +42,7 @@ public class SyncFieldMergePolicyTests
|
|||
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.Equal("SHOC edited", wo.Description);
|
||||
|
|
@ -61,7 +63,7 @@ public class SyncFieldMergePolicyTests
|
|||
|
||||
var locks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, locks);
|
||||
var policy = new SyncFieldMergePolicy(locks, audit);
|
||||
var policy = new SyncFieldMergePolicy(locks);
|
||||
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
|
|
@ -70,9 +72,80 @@ public class SyncFieldMergePolicyTests
|
|||
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.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.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ public class WorkOrderAuditServiceTests
|
|||
Assert.Equal("StatusChanged", logs[0].Action);
|
||||
Assert.Equal("Manual", logs[0].EventType);
|
||||
Assert.Equal("Dispatcher", logs[0].ActorType);
|
||||
Assert.Equal("LifecycleStatus", logs[0].FieldName);
|
||||
Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -50,6 +51,6 @@ public class WorkOrderAuditServiceTests
|
|||
|
||||
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
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();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
var utcPast = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "10000000001",
|
||||
ScheduledDate = utcPast.AddDays(-2),
|
||||
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
|
|
@ -193,7 +192,7 @@ public class WorkOrderBoardServiceTests
|
|||
Assert.Equal(1, response.Counts.Total);
|
||||
Assert.Single(response.Scheduled);
|
||||
Assert.True(response.Scheduled[0].IsPastDue);
|
||||
Assert.Equal("monday", response.Scheduled[0].DayGroup);
|
||||
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
|
|
@ -5,76 +6,70 @@ namespace SeaHavenIndustries.Tests;
|
|||
|
||||
public class WorkOrderDerivedFieldsTests
|
||||
{
|
||||
private static readonly DateTime Today = new(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
|
||||
{
|
||||
var result = WorkOrderDerivedFields.IsPastDue(
|
||||
Today.AddDays(-1),
|
||||
LifecycleStatus.Scheduled,
|
||||
Today);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
};
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenScheduledToday_ReturnsFalse()
|
||||
public void IsPastDue_WhenCompleted_ReturnsFalse()
|
||||
{
|
||||
var result = WorkOrderDerivedFields.IsPastDue(
|
||||
Today,
|
||||
LifecycleStatus.Scheduled,
|
||||
Today);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||
LifecycleStatus = LifecycleStatus.Completed
|
||||
};
|
||||
|
||||
Assert.False(result);
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenComplete_ReturnsFalse()
|
||||
public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
|
||||
{
|
||||
var result = WorkOrderDerivedFields.IsPastDue(
|
||||
Today.AddDays(-3),
|
||||
LifecycleStatus.Completed,
|
||||
Today);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1"
|
||||
};
|
||||
|
||||
Assert.False(result);
|
||||
Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
|
||||
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastDue_WhenNoScheduledDate_ReturnsFalse()
|
||||
public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
|
||||
{
|
||||
var result = WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Incomplete, Today);
|
||||
Assert.False(result);
|
||||
}
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1",
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22)
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(DayOfWeek.Monday, "monday")]
|
||||
[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));
|
||||
Assert.False(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
|
||||
Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatApptTime_WithStartAndEnd_ReturnsRange()
|
||||
public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
|
||||
{
|
||||
var start = new DateTime(2026, 6, 24, 7, 0, 0);
|
||||
var end = new DateTime(2026, 6, 24, 9, 0, 0);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
AssignTo = "dispatcher-1"
|
||||
};
|
||||
|
||||
var result = WorkOrderDerivedFields.FormatApptTime(start, end);
|
||||
|
||||
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));
|
||||
WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);
|
||||
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.DataServices", "SeaHaven.DataServices\SeaHaven.DataServices.csproj", "{60DF3943-9BC3-4444-8E7A-EEC34DDC7806}"
|
||||
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}"
|
||||
EndProject
|
||||
Global
|
||||
|
|
@ -41,6 +45,14 @@ Global
|
|||
{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.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.Build.0 = Debug|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="EPPlus" Version="7.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8" />
|
||||
<PackageReference Include="PasswordGenerator" Version="2.1.0" />
|
||||
<PackageReference Include="PuppeteerSharp" Version="19.0.1" />
|
||||
<PackageReference Include="Radzen.Blazor" Version="5.1.3" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue