Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,26 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Base controller providing access to current user context
/// </summary>
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>
/// Gets the current authenticated user from claims.
/// Returns null if not authenticated.
/// </summary>
protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
? User.ToUserInfo()
: null;
/// <summary>
/// Gets the current username from claims.
/// </summary>
protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
}
@@ -0,0 +1,100 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Authentication endpoints for Blazor WASM client
/// </summary>
[Route("api/auth")]
[ApiController]
public class AuthController : ApiControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
public AuthController(
IAuthService authService,
ILogger<AuthController> logger)
{
_authService = authService;
_logger = logger;
}
/// <summary>
/// Authenticates a user and creates a session cookie
/// </summary>
/// <param name="request">Login credentials</param>
/// <param name="ct">Cancellation token</param>
/// <returns>User info on success, 401 on failure</returns>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<UserInfo>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
var result = await _authService.AuthenticateAsync(
request.Username, request.Password, ct);
if (!result.Success)
{
_logger.LogWarning("Failed login attempt for user {Username}", request.Username);
return Unauthorized(new { message = result.ErrorMessage });
}
// Sign out existing session
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Create claims identity from user info
var identity = ClaimsExtensions.FromUserInfo(result.User!);
var principal = new ClaimsPrincipal(identity);
// Sign in with non-persistent cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = false });
_logger.LogInformation("User {Username} logged in successfully", request.Username);
return Ok(result.User);
}
/// <summary>
/// Logs out the current user
/// </summary>
/// <returns>200 OK on success</returns>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Logout()
{
var username = CurrentUserName;
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_logger.LogInformation("User {Username} logged out", username);
return Ok();
}
/// <summary>
/// Gets the current authenticated user's information
/// </summary>
/// <returns>User info on success, 401 if not authenticated</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<UserInfo> GetCurrentUser()
{
return Ok(CurrentUser);
}
}
@@ -0,0 +1,34 @@
using JdeScoping.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Handles file upload/download operations for Excel templates.
/// </summary>
[Authorize]
[ApiController]
[Route("api/fileio")]
public partial class FileIOController : ApiControllerBase
{
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private readonly ILotFinderRepository _repository;
private readonly IExcelParserService _parserService;
private readonly IExcelTemplateService _templateService;
private readonly ILogger<FileIOController> _logger;
public FileIOController(
ILotFinderRepository repository,
IExcelParserService parserService,
IExcelTemplateService templateService,
ILogger<FileIOController> logger)
{
_repository = repository;
_parserService = parserService;
_templateService = templateService;
_logger = logger;
}
}
@@ -0,0 +1,75 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Component lot file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing component lot/item pairs and returns the matched lots
/// </summary>
[HttpPost("componentlots/upload")]
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var lotViewModels = _parserService.ParseComponentLots(stream);
var lots = await _repository.LookupLotsAsync(lotViewModels, ct);
var viewModels = lots
.Select(l => l.ToViewModel())
.DistinctBy(l => new { l.LotNumber, l.ItemNumber })
.OrderBy(l => l.LotNumber)
.ToArray();
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded component lots file");
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current component lot data
/// </summary>
[HttpPost("componentlots/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadComponentLots([FromBody] List<LotViewModel>? lotNumbers)
{
var sourceData = (lotNumbers ?? [])
.Select(l => new object?[] { l.LotNumber, l.ItemNumber })
.ToArray();
var headers = new[] { "Component Lot Number", "Component Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "component_lot_template.xlsx");
}
}
@@ -0,0 +1,74 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Item file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing item numbers and returns the matched items
/// </summary>
[HttpPost("items/upload")]
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var itemNumbers = _parserService.ParseItems(stream);
var items = await _repository.LookupItemsAsync(itemNumbers, ct);
var viewModels = items
.Select(i => i.ToViewModel())
.DistinctBy(i => new { i.ItemNumber, i.Description })
.ToArray();
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded items file");
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current item data
/// </summary>
[HttpPost("items/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadItems([FromBody] List<ItemViewModel>? items)
{
var sourceData = (items ?? [])
.Select(i => new object?[] { i.ItemNumber })
.ToArray();
var headers = new[] { "Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_number_template.xlsx");
}
}
@@ -0,0 +1,66 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Part operations file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing part operations and returns the parsed data
/// </summary>
[HttpPost("partoperations/upload")]
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
{
if (file is null)
{
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var partOperations = _parserService.ParsePartOperations(stream);
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = true,
Data = partOperations.ToArray()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded part operations file");
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current part operation data
/// </summary>
[HttpPost("partoperations/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadPartOperations([FromBody] List<PartOperationViewModel>? partOperations)
{
var sourceData = (partOperations ?? [])
.Select(po => new object?[] { po.ItemNumber, po.OperationNumber, po.MisNumber, po.MisRevision })
.ToArray();
var headers = new[] { "Item Number", "Operation Number", "MIS Number", "MIS Revision" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_operations_mis_template.xlsx");
}
}
@@ -0,0 +1,71 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Work order file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing work order numbers and returns the matched work orders
/// </summary>
[HttpPost("workorders/upload")]
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var workOrderNumbers = _parserService.ParseWorkOrders(stream);
var workOrders = await _repository.LookupWorkordersAsync(workOrderNumbers, ct);
var viewModels = workOrders
.Select(wo => wo.ToViewModel())
.DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
.OrderBy(wo => wo.WorkOrderNumber)
.ToArray();
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded work order file");
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current work order data
/// </summary>
[HttpPost("workorders/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadWorkOrders([FromBody] List<long>? workOrders)
{
var data = _templateService.GenerateSingleColumn(workOrders ?? [], "Work Order Number");
return File(data, ContentType, "work_order_template.xlsx");
}
}
@@ -0,0 +1,93 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Lookup/autocomplete endpoints (no authorization required)
/// </summary>
[Route("api/lookup")]
[ApiController]
public class LookupController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
public LookupController(ILotFinderRepository repository)
{
_repository = repository;
}
/// <summary>
/// Searches for items matching the query
/// </summary>
/// <param name="q">Search query for item number or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("items")]
[ProducesResponseType(typeof(IEnumerable<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
[FromQuery] string q,
CancellationToken ct)
{
var items = await _repository.SearchItemsAsync(q ?? string.Empty, ct);
var viewModels = items
.OrderBy(i => i.ItemNumber)
.Select(i => i.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for profit centers matching the query
/// </summary>
/// <param name="q">Search query for profit center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("profit-centers")]
[ProducesResponseType(typeof(IEnumerable<ProfitCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchProfitCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(pc => pc.Code)
.Select(pc => pc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for work centers matching the query
/// </summary>
/// <param name="q">Search query for work center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("work-centers")]
[ProducesResponseType(typeof(IEnumerable<WorkCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchWorkCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(wc => wc.Code)
.Select(wc => wc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for operators (JDE users) matching the query
/// </summary>
/// <param name="q">Search query for operator name or ID</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("operators")]
[ProducesResponseType(typeof(IEnumerable<JdeUserViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<JdeUserViewModel>>> FindOperators(
[FromQuery] string q,
CancellationToken ct)
{
var users = await _repository.SearchUsersAsync(q ?? string.Empty, ct);
var viewModels = users
.OrderBy(u => u.FullName)
.Select(u => u.ToViewModel());
return Ok(viewModels);
}
}
@@ -0,0 +1,163 @@
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Search management controller
/// </summary>
[Route("api/search")]
[ApiController]
[Authorize]
public class SearchController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchController> _logger;
public SearchController(
ILotFinderRepository repository,
IHubContext<StatusHub> hubContext,
ILogger<SearchController> logger)
{
_repository = repository;
_hubContext = hubContext;
_logger = logger;
}
/// <summary>
/// Gets all searches for the current user
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(CancellationToken ct)
{
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
var viewModels = searches
.OrderByDescending(s => s.StartDt)
.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets all queued searches
/// </summary>
[HttpGet("queue")]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(CancellationToken ct)
{
var searches = await _repository.GetQueuedSearchesAsync(ct);
var viewModels = searches.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets a single search by ID
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
{
var search = await _repository.GetSearchAsync(id, ct);
if (search is null)
{
return NotFound();
}
return Ok(new SearchViewModel(search));
}
/// <summary>
/// Copies an existing search for the current user (returns copy without persisting)
/// </summary>
[HttpGet("{id:int}/copy")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
{
var original = await _repository.GetSearchAsync(id, ct);
if (original is null)
{
return NotFound();
}
// Return a copy with reset status/timestamps (not persisted until user submits)
var copy = new Search
{
Id = 0,
UserName = CurrentUserName!,
Name = original.Name,
Status = SearchStatus.New,
SubmitDt = null,
StartDt = null,
EndDt = null,
CriteriaJson = original.CriteriaJson
};
return Ok(new SearchViewModel(copy));
}
/// <summary>
/// Creates a new search
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
public async Task<ActionResult<int>> CreateSearch(
[FromBody] SearchViewModel viewModel,
CancellationToken ct)
{
var search = viewModel.ToEntity();
search.UserName = CurrentUserName!;
var searchId = await _repository.SubmitSearchAsync(search, ct);
// Publish to SignalR (best-effort, swallow exceptions)
try
{
var searchUpdate = new SearchUpdate
{
Id = searchId,
UserName = CurrentUserName!,
Name = search.Name,
Status = search.Status,
Timestamp = DateTime.UtcNow
};
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish search update to SignalR");
}
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
}
/// <summary>
/// Downloads search results as an Excel file
/// </summary>
[HttpGet("{id:int}/results")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetResults(int id, CancellationToken ct)
{
var data = await _repository.GetSearchResultsAsync(id, ct);
if (data is null || data.Length == 0)
{
return NotFound();
}
return File(
data,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"search_results.xlsx");
}
}