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");
}
}
@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Options;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for registering Web API services.
/// </summary>
public static class ApiDependencyInjection
{
/// <summary>
/// Adds Web API services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddWebApi(
this IServiceCollection services,
IConfiguration configuration)
{
// Read auth options for cookie configuration (binding handled by Infrastructure)
var authOptions = configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>() ?? new AuthOptions();
// Register memory cache for file downloads
services.AddMemoryCache();
// Configure SignalR
services.AddSignalR();
// Configure cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = authOptions.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
options.SlidingExpiration = true;
// Return 401 instead of redirect for API requests (Blazor WASM)
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
services.AddAuthorization();
// Configure controllers with JSON options
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Configure Swagger/OpenAPI
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "JDE Scoping Tool API",
Version = "v1",
Description = "API for the JDE Scoping Tool application"
});
});
return services;
}
/// <summary>
/// Configures Web API middleware.
/// </summary>
/// <param name="app">Web application.</param>
/// <returns>Web application for chaining.</returns>
public static WebApplication UseWebApi(this WebApplication app)
{
// Use Swagger in development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
return app;
}
}
@@ -0,0 +1,55 @@
using System.Security.Claims;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace JdeScoping.Api.Extensions;
/// <summary>
/// Extension methods for ClaimsPrincipal
/// </summary>
public static class ClaimsExtensions
{
/// <summary>
/// Converts a ClaimsPrincipal to a UserInfo instance
/// </summary>
/// <param name="principal">Claims principal to extract user info from</param>
/// <returns>UserInfo populated from claims</returns>
public static UserInfo? ToUserInfo(this ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true)
{
return null;
}
return new UserInfo
{
Dn = principal.FindFirstValue("dn") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty,
Username = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty,
FirstName = principal.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty,
LastName = principal.FindFirstValue(ClaimTypes.Surname) ?? string.Empty,
EmailAddress = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty,
Title = principal.FindFirstValue("title") ?? string.Empty
};
}
/// <summary>
/// Creates a ClaimsIdentity from a UserInfo instance
/// </summary>
/// <param name="user">User information to create claims from</param>
/// <returns>ClaimsIdentity with user claims</returns>
public static ClaimsIdentity FromUserInfo(UserInfo user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Dn),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.GivenName, user.FirstName),
new(ClaimTypes.Surname, user.LastName),
new(ClaimTypes.Email, user.EmailAddress),
new("title", user.Title),
new("dn", user.Dn)
};
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
}
}
+71
View File
@@ -0,0 +1,71 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Hubs;
/// <summary>
/// SignalR hub for real-time status updates
/// </summary>
public class StatusHub : Hub
{
private static StatusUpdate _cachedStatus = new()
{
Message = "Unknown",
Timestamp = DateTime.UtcNow
};
private readonly ILogger<StatusHub> _logger;
public StatusHub(ILogger<StatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Called by worker service to update status.
/// Caches the update and broadcasts to all clients.
/// </summary>
/// <param name="statusUpdate">Status update to broadcast</param>
public async Task SetStatus(StatusUpdate statusUpdate)
{
_cachedStatus = statusUpdate;
await Clients.All.SendAsync("statusUpdate", statusUpdate);
_logger.LogDebug("Status updated: {Message}", statusUpdate.Message);
}
/// <summary>
/// Called by clients to get initial cached status on connection.
/// </summary>
/// <returns>The most recent status update</returns>
public StatusUpdate GetCachedStatus()
{
return _cachedStatus;
}
/// <summary>
/// Called by controllers/services to broadcast search updates.
/// </summary>
/// <param name="searchUpdate">Search update to broadcast</param>
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
{
await Clients.All.SendAsync("searchUpdate", searchUpdate);
_logger.LogDebug("Search update published: ID={Id}, Status={Status}", searchUpdate.Id, searchUpdate.Status);
}
/// <inheritdoc />
public override Task OnConnectedAsync()
{
_logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
return base.OnConnectedAsync();
}
/// <inheritdoc />
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />
<ProjectReference Include="..\JdeScoping.Infrastructure\JdeScoping.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
namespace JdeScoping.Api.Models;
/// <summary>
/// Result of a file upload operation
/// </summary>
/// <typeparam name="T">Type of data parsed from the uploaded file</typeparam>
public class FileUploadResult<T>
{
/// <summary>
/// Whether the upload was successful
/// </summary>
public bool WasSuccessful { get; set; }
/// <summary>
/// Error message if the upload failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Parsed data from the uploaded file
/// </summary>
public T[]? Data { get; set; }
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Api.Models;
/// <summary>
/// Login request payload
/// </summary>
public class LoginRequest
{
/// <summary>
/// Username for authentication
/// </summary>
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
/// <summary>
/// Password for authentication
/// </summary>
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}
+22
View File
@@ -0,0 +1,22 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<RedirectToLogin />
}
else
{
<NotAuthorized />
}
</NotAuthorized>
<Authorizing>
<LoadingIndicator Message="Checking authorization..." />
</Authorizing>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>
@@ -0,0 +1,121 @@
using System.Net.Http.Json;
using System.Security.Claims;
using JdeScoping.Client.Models;
using Microsoft.AspNetCore.Components.Authorization;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Provides authentication state by checking cookie auth status via API.
/// Works with cookie-based authentication where the browser automatically
/// sends cookies with each request.
/// </summary>
public class AuthStateProvider : AuthenticationStateProvider
{
private readonly IUserStorageService _userStorage;
private readonly HttpClient _httpClient;
private readonly ClaimsPrincipal _anonymous = new(new ClaimsIdentity());
public AuthStateProvider(IUserStorageService userStorage, HttpClient httpClient)
{
_userStorage = userStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
// First check cached user info
var cachedUser = await _userStorage.GetUserAsync();
if (cachedUser != null)
{
// Validate session is still active by calling the API
var validatedUser = await ValidateSessionAsync();
if (validatedUser != null)
{
return CreateAuthState(validatedUser);
}
// Session expired, clear cached data
await _userStorage.RemoveUserAsync();
}
return new AuthenticationState(_anonymous);
}
/// <summary>
/// Validates the current session by calling /api/auth/me.
/// Returns null if not authenticated.
/// </summary>
private async Task<UserInfoViewModel?> ValidateSessionAsync()
{
try
{
var response = await _httpClient.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<UserInfoViewModel>();
}
}
catch
{
// Network error or other issue - treat as not authenticated
}
return null;
}
/// <summary>
/// Creates an authenticated state from user info.
/// </summary>
private static AuthenticationState CreateAuthState(UserInfoViewModel user)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.GivenName, user.FirstName),
new(ClaimTypes.Surname, user.LastName),
new("display_name", user.DisplayName),
new(ClaimTypes.Email, user.EmailAddress)
};
var identity = new ClaimsIdentity(claims, "cookie");
var principal = new ClaimsPrincipal(identity);
return new AuthenticationState(principal);
}
/// <summary>
/// Called after successful login to update auth state.
/// </summary>
public async Task MarkUserAsAuthenticated(UserInfoViewModel user)
{
await _userStorage.SetUserAsync(user);
NotifyAuthenticationStateChanged(Task.FromResult(CreateAuthState(user)));
}
/// <summary>
/// Notifies that authentication state has changed.
/// </summary>
public void NotifyAuthenticationStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
/// <summary>
/// Logs out the user by removing cached data.
/// </summary>
public async Task LogoutAsync()
{
await _userStorage.RemoveUserAsync();
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
}
/// <summary>
/// Gets the current username from the cached user info.
/// </summary>
public async Task<string?> GetUsernameAsync()
{
var user = await _userStorage.GetUserAsync();
return user?.Username;
}
}
@@ -0,0 +1,26 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Service for storing and retrieving user info in browser storage.
/// Used for cookie-based authentication where the API returns UserInfo
/// and cookies handle the actual auth.
/// </summary>
public interface IUserStorageService
{
/// <summary>
/// Gets the stored user info.
/// </summary>
Task<UserInfoViewModel?> GetUserAsync();
/// <summary>
/// Stores the user info.
/// </summary>
Task SetUserAsync(UserInfoViewModel user);
/// <summary>
/// Removes the stored user info.
/// </summary>
Task RemoveUserAsync();
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using JdeScoping.Client.Models;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Stores user info in browser sessionStorage via JS interop.
/// Uses sessionStorage (not localStorage) so it clears on browser close,
/// matching cookie session behavior.
/// </summary>
public class UserStorageService : IUserStorageService
{
private const string UserKey = "jdescoping_user";
private readonly IJSRuntime _jsRuntime;
public UserStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<UserInfoViewModel?> GetUserAsync()
{
try
{
var json = await _jsRuntime.InvokeAsync<string?>("jdeScopingInterop.getSessionStorage", UserKey);
if (string.IsNullOrEmpty(json))
{
return null;
}
return JsonSerializer.Deserialize<UserInfoViewModel>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
return null;
}
}
public async Task SetUserAsync(UserInfoViewModel user)
{
var json = JsonSerializer.Serialize(user);
await _jsRuntime.InvokeVoidAsync("jdeScopingInterop.setSessionStorage", UserKey, json);
}
public async Task RemoveUserAsync()
{
await _jsRuntime.InvokeVoidAsync("jdeScopingInterop.removeSessionStorage", UserKey);
}
}
@@ -0,0 +1,100 @@
@* Component lot filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Component Lot</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="componentLotFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@ComponentLots" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="LotNumber" Title="Lot Number" />
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of component lots: @ComponentLots.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
[Parameter]
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
var lotData = ComponentLots.Select(cl => new { cl.LotNumber, cl.ItemNumber }).ToList();
await FileService.DownloadTemplateAsync("componentlots", lotData);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('componentLotFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<ComponentLotViewModel>("componentlots", stream, e.File.Name);
if (result.WasSuccessful)
{
ComponentLots.Clear();
ComponentLots.AddRange(result.Data);
await ComponentLotsChanged.InvokeAsync(ComponentLots);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} component lots.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all component lots?", "Confirm Clear");
if (confirmed == true)
{
ComponentLots.Clear();
await ComponentLotsChanged.InvokeAsync(ComponentLots);
}
}
}
@@ -0,0 +1,169 @@
@* Item number filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Item Number</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="itemNumberFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Item Number" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="ItemNumber"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search items (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Items" TItem="ItemViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ItemViewModel" Property="ItemNumber" Title="Item Number" Width="150px" />
<RadzenDataGridColumn TItem="ItemViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ItemViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of item numbers: @Items.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<ItemViewModel> Items { get; set; } = [];
[Parameter]
public EventCallback<List<ItemViewModel>> ItemsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private string _searchText = "";
private List<ItemViewModel> _searchResults = [];
private ItemViewModel? _selectedItem;
private bool _isUploading;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindItemsAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.ItemNumber == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Items.Any(i => i.ItemNumber == _selectedItem.ItemNumber))
{
Items.Add(_selectedItem);
await ItemsChanged.InvokeAsync(Items);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(ItemViewModel item)
{
Items.Remove(item);
await ItemsChanged.InvokeAsync(Items);
}
private async Task DownloadTemplateAsync()
{
await FileService.DownloadTemplateAsync("items", Items);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('itemNumberFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<ItemViewModel>("items", stream, e.File.Name);
if (result.WasSuccessful)
{
Items.Clear();
Items.AddRange(result.Data);
await ItemsChanged.InvokeAsync(Items);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} items.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all items?", "Confirm Clear");
if (confirmed == true)
{
Items.Clear();
await ItemsChanged.InvokeAsync(Items);
}
}
}
@@ -0,0 +1,112 @@
@* Operator filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Operator</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="FullName"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search operators (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Operators" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="OperatorViewModel" Property="AddressNumber" Title="Address Number" Width="150px" />
<RadzenDataGridColumn TItem="OperatorViewModel" Property="UserID" Title="User Name" Width="150px" />
<RadzenDataGridColumn TItem="OperatorViewModel" Property="FullName" Title="Full Name" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="OperatorViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<OperatorViewModel> Operators { get; set; } = [];
[Parameter]
public EventCallback<List<OperatorViewModel>> OperatorsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<OperatorViewModel> _searchResults = [];
private OperatorViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindOperatorsAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.FullName == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Operators.Any(i => i.UserId == _selectedItem.UserId))
{
Operators.Add(_selectedItem);
await OperatorsChanged.InvokeAsync(Operators);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(OperatorViewModel item)
{
Operators.Remove(item);
await OperatorsChanged.InvokeAsync(Operators);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all operators?", "Confirm Clear");
if (confirmed == true)
{
Operators.Clear();
await OperatorsChanged.InvokeAsync(Operators);
}
}
}
@@ -0,0 +1,101 @@
@* Part operation/MIS filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter By Item/Operation/MIS</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="partOperationFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@PartOperations" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisNumber" Title="MIS Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisRevision" Title="MIS Revision" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of item / operations: @PartOperations.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<PartOperationViewModel> PartOperations { get; set; } = [];
[Parameter]
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
await FileService.DownloadTemplateAsync("partoperations", PartOperations);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('partOperationFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<PartOperationViewModel>("partoperations", stream, e.File.Name);
if (result.WasSuccessful)
{
PartOperations.Clear();
PartOperations.AddRange(result.Data);
await PartOperationsChanged.InvokeAsync(PartOperations);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} part operations.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all item/operation/MIS entries?", "Confirm Clear");
if (confirmed == true)
{
PartOperations.Clear();
await PartOperationsChanged.InvokeAsync(PartOperations);
}
}
}
@@ -0,0 +1,111 @@
@* Profit center filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Profit Center</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Profit Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search profit centers (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" Width="150px" />
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
[Parameter]
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<ProfitCenterViewModel> _searchResults = [];
private ProfitCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindProfitCentersAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !ProfitCenters.Any(i => i.Code == _selectedItem.Code))
{
ProfitCenters.Add(_selectedItem);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(ProfitCenterViewModel item)
{
ProfitCenters.Remove(item);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all profit centers?", "Confirm Clear");
if (confirmed == true)
{
ProfitCenters.Clear();
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
}
}
@@ -0,0 +1,46 @@
@* Time span filter panel with min/max date pickers *@
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Filter by Timespan</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="5">
<RadzenFormField Text="Min Date" Style="width: 100%;">
<RadzenDatePicker @bind-Value="MinimumDt" DateFormat="MM/dd/yyyy" Disabled="@IsReadOnly"
Min="@_minAllowedDate" Max="@MaxAllowedDate" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="5" Offset="1">
<RadzenFormField Text="Max Date" Style="width: 100%;">
<RadzenDatePicker @bind-Value="MaximumDt" DateFormat="MM/dd/yyyy" Disabled="@IsReadOnly"
Min="@GetMinDateForMax()" Max="@MaxAllowedDate" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@code {
[Parameter]
public DateTime? MinimumDt { get; set; }
[Parameter]
public EventCallback<DateTime?> MinimumDtChanged { get; set; }
[Parameter]
public DateTime? MaximumDt { get; set; }
[Parameter]
public EventCallback<DateTime?> MaximumDtChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
// Business rules: Min >= 2002-11-01, Max <= today
private readonly DateTime _minAllowedDate = new(2002, 11, 1);
private DateTime MaxAllowedDate => DateTime.Today;
private DateTime GetMinDateForMax()
{
return MinimumDt ?? _minAllowedDate;
}
}
@@ -0,0 +1,111 @@
@* Work center filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Center</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Work Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search work centers (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@WorkCenters" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Code" Title="Work Center" Width="150px" />
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="WorkCenterViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
[Parameter]
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<WorkCenterViewModel> _searchResults = [];
private WorkCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindWorkCentersAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !WorkCenters.Any(i => i.Code == _selectedItem.Code))
{
WorkCenters.Add(_selectedItem);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(WorkCenterViewModel item)
{
WorkCenters.Remove(item);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work centers?", "Confirm Clear");
if (confirmed == true)
{
WorkCenters.Clear();
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
}
}
@@ -0,0 +1,100 @@
@* Work order filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Order</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="workOrderFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of work orders: @WorkOrders.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
[Parameter]
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
var workOrderNumbers = WorkOrders.Select(wo => wo.WorkOrderNumber).ToList();
await FileService.DownloadTemplateAsync("workorders", workOrderNumbers);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<WorkOrderViewModel>("workorders", stream, e.File.Name);
if (result.WasSuccessful)
{
WorkOrders.Clear();
WorkOrders.AddRange(result.Data);
await WorkOrdersChanged.InvokeAsync(WorkOrders);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} work orders.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work orders?", "Confirm Clear");
if (confirmed == true)
{
WorkOrders.Clear();
await WorkOrdersChanged.InvokeAsync(WorkOrders);
}
}
}
@@ -0,0 +1,24 @@
@* Loading indicator component with optional message *@
<div class="loading-container">
<RadzenProgressBarCircular ShowValue="false" Mode="ProgressBarMode.Indeterminate" Size="ProgressBarCircularSize.Large" />
@if (!string.IsNullOrEmpty(Message))
{
<RadzenText TextStyle="TextStyle.Body1" class="rz-mt-2">@Message</RadzenText>
}
</div>
<style>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
</style>
@code {
[Parameter]
public string? Message { get; set; }
}
@@ -0,0 +1,9 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
NavigationManager.NavigateTo($"/login?returnUrl={returnUrl}");
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Radzen.Blazor" Version="8.4.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
@inherits LayoutComponentBase
@inject IAuthService AuthService
@inject NavigationManager NavigationManager
<RadzenLayout>
<RadzenHeader class="navbar-fixed-top">
<div class="navbar-container">
<div class="navbar-left">
<a href="/" class="navbar-brand">JDE Scoping Tool</a>
<nav class="navbar-nav">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Searches</NavLink>
<NavLink class="nav-link" href="/search">New Search</NavLink>
<NavLink class="nav-link" href="/search/queue">Search Queue</NavLink>
<NavLink class="nav-link" href="/refresh-status">Refresh Status</NavLink>
</nav>
</div>
<div class="navbar-right">
<AuthorizeView>
<Authorized>
<span class="navbar-user">@context.User.Identity?.Name</span>
<RadzenButton Text="Logout" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@LogoutAsync" />
</Authorized>
<NotAuthorized>
<RadzenButton Text="Login" ButtonStyle="ButtonStyle.Primary" Size="ButtonSize.Small" Click="@GoToLogin" />
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</RadzenHeader>
<RadzenBody class="main-body">
<div class="body-content">
<div class="container-fluid">
@Body
</div>
</div>
</RadzenBody>
<RadzenFooter>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center" Gap="0" class="rz-px-4">
<RadzenText TextStyle="TextStyle.Caption" class="rz-m-0">
JDE Scoping Tool Version 4
</RadzenText>
</RadzenStack>
</RadzenFooter>
</RadzenLayout>
<RadzenDialog />
<RadzenNotification />
<RadzenContextMenu />
<RadzenTooltip />
@code {
private async Task LogoutAsync()
{
await AuthService.LogoutAsync();
NavigationManager.NavigateTo("/login");
}
private void GoToLogin()
{
NavigationManager.NavigateTo("/login");
}
}
@@ -0,0 +1,21 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for component lot filter.
/// </summary>
public class ComponentLotViewModel
{
public string LotNumber { get; set; } = string.Empty;
public string ItemNumber { get; set; } = string.Empty;
public override bool Equals(object? obj)
{
if (obj is ComponentLotViewModel other)
{
return LotNumber == other.LotNumber && ItemNumber == other.ItemNumber;
}
return false;
}
public override int GetHashCode() => HashCode.Combine(LotNumber, ItemNumber);
}
@@ -0,0 +1,24 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for data refresh/sync status display.
/// </summary>
public class DataUpdateViewModel
{
public DateTime StartDt { get; set; }
public DateTime? EndDt { get; set; }
public int BranchRecords { get; set; }
public int ProfitCenterRecords { get; set; }
public int WorkCenterRecords { get; set; }
public int OrgHierarchyRecords { get; set; }
public int StatusCodeRecords { get; set; }
public int UserRecords { get; set; }
public int ItemRecords { get; set; }
public int LotRecords { get; set; }
public int WorkOrderRecords { get; set; }
public int WorkOrderStepRecords { get; set; }
public int WorkOrderComponentRecords { get; set; }
public bool WasSuccessful { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Client.Models;
/// <summary>
/// Login form model with validation.
/// </summary>
public class LoginModel
{
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,22 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for operator filter.
/// </summary>
public class OperatorViewModel
{
public int AddressNumber { get; set; }
public string UserId { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public override bool Equals(object? obj)
{
if (obj is OperatorViewModel other)
{
return UserId == other.UserId;
}
return false;
}
public override int GetHashCode() => UserId.GetHashCode();
}
@@ -0,0 +1,22 @@
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for search criteria filters.
/// </summary>
public class SearchCriteriaViewModel
{
public DateTime? MinimumDt { get; set; }
public DateTime? MaximumDt { get; set; }
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
public List<ItemViewModel> Items { get; set; } = [];
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
public List<OperatorViewModel> Operators { get; set; } = [];
public List<PartOperationViewModel> PartOperations { get; set; } = [];
public bool ExtractMisData { get; set; }
}
@@ -0,0 +1,15 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for search status updates.
/// </summary>
public record SearchUpdate
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime? SubmitDt { get; init; }
public DateTime? StartDt { get; init; }
public DateTime? EndDt { get; init; }
}
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for displaying search information in lists and details.
/// </summary>
public class SearchViewModel
{
public int Id { get; set; }
[Required(ErrorMessage = "Name is required.")]
public string Name { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? SubmitDt { get; set; }
public DateTime? StartDt { get; set; }
public DateTime? EndDt { get; set; }
public SearchCriteriaViewModel Criteria { get; set; } = new();
/// <summary>
/// Gets the background color based on status.
/// </summary>
public string StatusColor => Status switch
{
"Error" => "#FF6347",
"Ended" => "#90EE90",
"Running" => "#87CEEB",
_ => "#EEEEEE"
};
/// <summary>
/// Returns true if the search has downloadable results.
/// </summary>
public bool HasResults => Status == "Ended";
/// <summary>
/// Returns true if the search is read-only (already submitted).
/// </summary>
public bool IsReadOnly => Status != "New";
}
@@ -0,0 +1,10 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for processor status updates.
/// </summary>
public record StatusUpdate
{
public string Message { get; init; } = string.Empty;
public DateTime? Timestamp { get; init; }
}
@@ -0,0 +1,38 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// Client-side view model for authenticated user information.
/// Mirrors the server-side UserInfo model returned by /api/auth/login and /api/auth/me.
/// </summary>
public class UserInfoViewModel
{
/// <summary>
/// User's login username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// User's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// User's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// User's display name (computed on server, provided here for convenience).
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// User's organization title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// User's email address.
/// </summary>
public string EmailAddress { get; set; } = string.Empty;
}
@@ -0,0 +1,276 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// Represents a valid combination of search filter criteria.
/// Each combination defines which filter panels should be visible for a given search type.
/// </summary>
public class ValidCombination
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public bool Timespan { get; init; }
public bool WorkOrder { get; init; }
public bool ItemNumber { get; init; }
public bool ProfitCenter { get; init; }
public bool WorkCenter { get; init; }
public bool ComponentLot { get; init; }
public bool Operator { get; init; }
public bool ItemOperationMis { get; init; }
public bool ExtractMis { get; init; }
/// <summary>
/// Checks if the given filter flags match this combination.
/// </summary>
public bool Matches(
bool timespan,
bool workOrder,
bool itemNumber,
bool profitCenter,
bool workCenter,
bool componentLot,
bool @operator,
bool itemOperationMis,
bool extractMis)
{
return Timespan == timespan &&
WorkOrder == workOrder &&
ItemNumber == itemNumber &&
ProfitCenter == profitCenter &&
WorkCenter == workCenter &&
ComponentLot == componentLot &&
Operator == @operator &&
ItemOperationMis == itemOperationMis &&
ExtractMis == extractMis;
}
/// <summary>
/// Gets all 16 valid search type combinations.
/// </summary>
public static IReadOnlyList<ValidCombination> GetAll() =>
[
new ValidCombination
{
Id = 10,
Name = "Work Order",
Timespan = false,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 20,
Name = "Component Lot",
Timespan = false,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = true,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 30,
Name = "Time Span + Profit Center",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 40,
Name = "Time Span + Work Center",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 50,
Name = "Time Span + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 60,
Name = "Time Span + Profit Center + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 70,
Name = "Time Span + Profit Center + Item/Operation/MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 80,
Name = "Time Span + Profit Center + Work Order + Item/Operation/MIS",
Timespan = true,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 90,
Name = "Time Span + Profit Center + Extract MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
{
Id = 100,
Name = "Time Span + Work Center + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 110,
Name = "Time Span + Work Center + Extract MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
{
Id = 120,
Name = "Time Span + Work Center + Item/Operation/MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 130,
Name = "Time Span + Work Center + Work Order + Item/Operation/MIS",
Timespan = true,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 140,
Name = "Time Span + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 150,
Name = "Time Span + Work Center + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 160,
Name = "Time Span + Profit Center + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
}
];
}
@@ -0,0 +1,83 @@
@page "/login"
@inject IAuthService AuthService
@inject NavigationManager NavigationManager
<PageTitle>Login - JDE Scoping Tool</PageTitle>
<RadzenCard class="rz-mx-auto rz-mt-6" Style="max-width: 400px;">
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">
Authentication Required
</RadzenText>
<EditForm Model="@_loginModel" OnValidSubmit="@HandleLoginAsync">
<DataAnnotationsValidator />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
@_errorMessage
</RadzenAlert>
}
<RadzenStack Gap="1rem">
<RadzenFormField Text="Username" Variant="Variant.Outlined">
<ChildContent>
<RadzenTextBox @bind-Value="_loginModel.Username" Name="Username" Disabled="@_isLoading" Style="width: 100%;" />
</ChildContent>
<Helper>
<ValidationMessage For="@(() => _loginModel.Username)" />
</Helper>
</RadzenFormField>
<RadzenFormField Text="Password" Variant="Variant.Outlined">
<ChildContent>
<RadzenPassword @bind-Value="_loginModel.Password" Name="Password" Disabled="@_isLoading" Style="width: 100%;" />
</ChildContent>
<Helper>
<ValidationMessage For="@(() => _loginModel.Password)" />
</Helper>
</RadzenFormField>
<RadzenButton ButtonType="ButtonType.Submit" Text="Login" ButtonStyle="ButtonStyle.Primary"
IsBusy="@_isLoading" BusyText="Logging in..." Style="width: 100%;" />
</RadzenStack>
</EditForm>
</RadzenCard>
@code {
private LoginModel _loginModel = new();
private string? _errorMessage;
private bool _isLoading;
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
private async Task HandleLoginAsync()
{
_isLoading = true;
_errorMessage = null;
try
{
var result = await AuthService.LoginAsync(_loginModel);
if (result.Success)
{
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl);
}
else
{
_errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials.";
}
}
catch (Exception ex)
{
_errorMessage = $"An error occurred: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
}
@@ -0,0 +1,41 @@
@page "/not-authorized"
@inject NavigationManager NavigationManager
<PageTitle>Not Authorized - JDE Scoping Tool</PageTitle>
<RadzenCard class="rz-mx-auto rz-mt-6" Style="max-width: 500px;">
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" Size="AlertSize.Large">
<RadzenText TextStyle="TextStyle.H5" class="rz-mb-2">
Access Denied
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-4">
You do not have permission to access this resource.
</RadzenText>
@if (!string.IsNullOrEmpty(ResourceUrl))
{
<RadzenText TextStyle="TextStyle.Caption" class="rz-mb-4">
Requested resource: @ResourceUrl
</RadzenText>
}
</RadzenAlert>
<RadzenStack Orientation="Orientation.Horizontal" Gap="1rem" JustifyContent="JustifyContent.Center" class="rz-mt-4">
<RadzenButton Text="Go to Home" ButtonStyle="ButtonStyle.Primary" Click="@GoHome" />
<RadzenButton Text="Login" ButtonStyle="ButtonStyle.Secondary" Click="@GoToLogin" />
</RadzenStack>
</RadzenCard>
@code {
[SupplyParameterFromQuery]
public string? ResourceUrl { get; set; }
private void GoHome()
{
NavigationManager.NavigateTo("/");
}
private void GoToLogin()
{
NavigationManager.NavigateTo("/login");
}
}
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -0,0 +1,101 @@
@page "/refresh-status"
@attribute [Authorize]
@inject IRefreshStatusService RefreshStatusService
<PageTitle>Cache Refresh Status - JDE Scoping Tool</PageTitle>
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">Cache Refresh Status</RadzenText>
<!-- Date Filter Panel -->
<RadzenCard class="rz-mb-4">
<RadzenRow Gap="1rem" AlignItems="AlignItems.End">
<RadzenColumn Size="4">
<RadzenFormField Text="Start Time" Style="width: 100%;">
<RadzenDatePicker @bind-Value="_minDt" DateFormat="MM/dd/yyyy" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="End Time" Style="width: 100%;">
<RadzenDatePicker @bind-Value="_maxDt" DateFormat="MM/dd/yyyy" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Filter" Icon="filter_list" ButtonStyle="ButtonStyle.Primary" Click="@LoadDataAsync" IsBusy="@_isLoading" />
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@if (_isLoading)
{
<LoadingIndicator Message="Loading refresh status..." />
}
else
{
<RadzenDataGrid Data="@_results" TItem="DataUpdateViewModel" AllowSorting="true" AllowPaging="true" PageSize="20"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" Style="text-align: center;">
<Columns>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StartDT" Title="Start" Width="160px">
<Template Context="item">
@item.StartDt.ToString("MM/dd/yyyy hh:mm tt")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="EndDT" Title="End" Width="160px">
<Template Context="item">
@(item.EndDt?.ToString("MM/dd/yyyy hh:mm tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="BranchRecords" Title="Branch" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ProfitCenterRecords" Title="Profit Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkCenterRecords" Title="Work Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="OrgHierarchyRecords" Title="Org Hierarchy" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StatusCodeRecords" Title="Status Code" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="UserRecords" Title="User" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ItemRecords" Title="Item" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="LotRecords" Title="Lot" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderRecords" Title="Work Order" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderStepRecords" Title="WO Step" Width="90px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderComponentRecords" Title="WO Component" Width="110px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WasSuccessful" Title="Was Successful?" Width="120px" TextAlign="TextAlign.Center">
<Template Context="item">
@if (item.WasSuccessful)
{
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="YES" />
}
else
{
<RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="NO" />
}
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<DataUpdateViewModel> _results = [];
private bool _isLoading = true;
// Default to last 7 days
private DateTime _minDt = DateTime.Today.AddDays(-7);
private DateTime _maxDt = DateTime.Today;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_isLoading = true;
try
{
_results = await RefreshStatusService.GetRefreshStatusAsync(_minDt, _maxDt);
// Sort by StartDT descending
_results = _results.OrderByDescending(r => r.StartDt).ToList();
}
finally
{
_isLoading = false;
}
}
}
@@ -0,0 +1,433 @@
@page "/search"
@page "/search/{Id:int}"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@inject IFileService FileService
@inject AuthStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject DialogService DialogService
@inject NotificationService NotificationService
@inject IJSRuntime JSRuntime
@implements IDisposable
<PageTitle>@(_search.Id == 0 ? "New Search" : "Search") - JDE Scoping Tool</PageTitle>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H4" class="rz-m-0">Search</RadzenText>
@if (!_search.IsReadOnly)
{
<RadzenButton Text="Submit" Icon="send" ButtonStyle="ButtonStyle.Primary" Click="@SubmitSearchAsync" IsBusy="@_isSubmitting" BusyText="Submitting..." />
}
</RadzenStack>
@if (_isLoading)
{
<LoadingIndicator Message="Loading search..." />
}
else
{
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
@if (_search.IsReadOnly)
{
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
</RadzenAlert>
}
<!-- Validation Summary -->
<ValidationSummary class="rz-mb-4" />
<!-- Search Details Panel -->
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="12">
<RadzenFormField Text="Search Type" Style="width: 100%;">
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="12">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
</RadzenFormField>
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="Submitted At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Started At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Completed At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="User" Style="width: 100%;">
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Status" Style="width: 100%;">
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
@if (_search.HasResults)
{
<RadzenFormField Text=" " Style="width: 100%;">
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
</RadzenFormField>
}
</RadzenColumn>
</RadzenRow>
</RadzenCard>
<!-- Filter Panels -->
@if (_showTimespan)
{
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkOrder)
{
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemNumber)
{
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showProfitCenter)
{
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkCenter)
{
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showComponentLot)
{
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showOperator)
{
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemOperationMis)
{
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showExtractMis)
{
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
</RadzenStack>
</RadzenCard>
}
</EditForm>
}
@code {
[Parameter]
public int? Id { get; set; }
[SupplyParameterFromQuery(Name = "copySearchId")]
public int? CopySearchId { get; set; }
private ClientSearchViewModel _search = new() { Criteria = new() };
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
private int? _selectedSearchType;
private bool _isLoading = true;
private bool _isSubmitting;
// Filter visibility flags
private bool _showTimespan;
private bool _showWorkOrder;
private bool _showItemNumber;
private bool _showProfitCenter;
private bool _showWorkCenter;
private bool _showComponentLot;
private bool _showOperator;
private bool _showItemOperationMis;
private bool _showExtractMis;
protected override async Task OnInitializedAsync()
{
await LoadSearchAsync();
await SetupSignalRAsync();
}
private async Task LoadSearchAsync()
{
_isLoading = true;
try
{
if (CopySearchId.HasValue)
{
var copied = await SearchService.CopySearchAsync(CopySearchId.Value);
if (copied != null)
{
_search = copied;
_search.Id = 0;
_search.Status = "New";
}
}
else if (Id.HasValue && Id.Value > 0)
{
var loaded = await SearchService.GetSearchAsync(Id.Value);
if (loaded != null)
{
_search = loaded;
}
}
else
{
// New search
_search = new ClientSearchViewModel
{
Status = "New",
UserName = await AuthStateProvider.GetUsernameAsync() ?? "",
Criteria = new SearchCriteriaViewModel()
};
}
// Detect search type from criteria
DetectSearchType();
}
finally
{
_isLoading = false;
}
}
private void DetectSearchType()
{
var criteria = _search.Criteria;
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
bool hasItemNumber = criteria.Items.Count > 0;
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
bool hasComponentLot = criteria.ComponentLots.Count > 0;
bool hasOperator = criteria.Operators.Count > 0;
bool hasPartOperation = criteria.PartOperations.Count > 0;
bool hasExtractMis = criteria.ExtractMisData;
foreach (var combo in _validCombinations)
{
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
{
_selectedSearchType = combo.Id;
UpdateFilterVisibility(combo);
break;
}
}
}
private void OnSearchTypeChanged()
{
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
if (combo != null)
{
UpdateFilterVisibility(combo);
}
}
private void UpdateFilterVisibility(ValidCombination combo)
{
_showTimespan = combo.Timespan;
_showWorkOrder = combo.WorkOrder;
_showItemNumber = combo.ItemNumber;
_showProfitCenter = combo.ProfitCenter;
_showWorkCenter = combo.WorkCenter;
_showComponentLot = combo.ComponentLot;
_showOperator = combo.Operator;
_showItemOperationMis = combo.ItemOperationMis;
_showExtractMis = combo.ExtractMis;
// Set ExtractMisData flag based on combo
_search.Criteria.ExtractMisData = combo.ExtractMis;
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
{
if (update.Id == _search.Id)
{
InvokeAsync(() =>
{
_search.Status = update.Status;
_search.SubmitDt = update.SubmitDt;
_search.StartDt = update.StartDt;
_search.EndDt = update.EndDt;
StateHasChanged();
});
}
}
private async Task HandleValidSubmit()
{
// DataAnnotationsValidator has already validated the model
// Now perform additional custom validation
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
return;
}
await SubmitSearchInternalAsync();
}
private async Task SubmitSearchAsync()
{
// Manual submit button handler - validate and submit
if (string.IsNullOrWhiteSpace(_search.Name))
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
return;
}
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
return;
}
await SubmitSearchInternalAsync();
}
private async Task SubmitSearchInternalAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to submit the search?", "Confirm Submit", new ConfirmOptions
{
OkButtonText = "Submit",
CancelButtonText = "Cancel"
});
if (confirmed != true)
{
return;
}
_isSubmitting = true;
try
{
var id = await SearchService.SaveSearchAsync(_search);
if (id.HasValue)
{
NavigationManager.NavigateTo($"/search/{id}");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Error", "Failed to submit search.");
}
}
finally
{
_isSubmitting = false;
}
}
private string? ValidateFilters()
{
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
return "At least one work order must be specified for the work order filter.";
if (_showItemNumber && _search.Criteria.Items.Count == 0)
return "At least one item number must be specified for the item number filter.";
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
return "At least one profit center must be specified for the profit center filter.";
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
return "At least one work center must be specified for the work center filter.";
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
return "At least one component lot must be specified for the component lot filter.";
if (_showOperator && _search.Criteria.Operators.Count == 0)
return "At least one operator must be specified for the operator filter.";
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
return null;
}
private void CopySearchAsync()
{
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
}
private async Task DownloadResultsAsync()
{
var results = await SearchService.DownloadResultsAsync(_search.Id);
if (results != null && results.Length > 0)
{
// Trigger download via JS interop
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", results);
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
}
else
{
NotificationService.Notify(NotificationSeverity.Warning, "Download", "No results available to download.");
}
}
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
}
}
@@ -0,0 +1,171 @@
@page "/search/queue"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@implements IDisposable
<PageTitle>Search Queue - JDE Scoping Tool</PageTitle>
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">Search Queue</RadzenText>
<!-- Processor Status Panel -->
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Processor Status</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="8">
<RadzenFormField Text="Status Message" Style="width: 100%;">
<RadzenTextBox Value="@_statusMessage" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Last Update Timestamp" Style="width: 100%;">
<RadzenTextBox Value="@_statusUpdateDt" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@if (_isLoading)
{
<LoadingIndicator Message="Loading queue..." />
}
else
{
<RadzenDataGrid @ref="_grid" Data="@_searches" TItem="ClientSearchViewModel" AllowSorting="true" AllowPaging="true" PageSize="20"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true">
<Columns>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="UserName" Title="Owner" Width="150px" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Name" Title="Name" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
<Template Context="search">
@(search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="StartDT" Title="Started" Width="180px">
<Template Context="search">
@(search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="EndDT" Title="Ended" Width="180px">
<Template Context="search">
@(search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Status" Title="Status" Width="100px">
<Template Context="search">
<RadzenBadge BadgeStyle="@GetBadgeStyle(search.Status)" Text="@search.Status" />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<ClientSearchViewModel> _searches = [];
private RadzenDataGrid<ClientSearchViewModel>? _grid;
private bool _isLoading = true;
private string _statusMessage = "";
private string _statusUpdateDt = "";
protected override async Task OnInitializedAsync()
{
await LoadQueueAsync();
await SetupSignalRAsync();
}
private async Task LoadQueueAsync()
{
_isLoading = true;
try
{
_searches = await SearchService.GetQueueAsync();
}
finally
{
_isLoading = false;
}
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
HubConnection.OnStatusUpdate += HandleStatusUpdate;
await HubConnection.StartAsync();
// Get cached status
var cachedStatus = await HubConnection.GetCachedStatusAsync();
if (cachedStatus != null)
{
HandleStatusUpdate(cachedStatus);
}
}
private void HandleSearchUpdate(SearchUpdate update)
{
InvokeAsync(() =>
{
if (update.Status == "Ended" || update.Status == "Error")
{
// Remove completed/errored searches from queue
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
_searches.Remove(existing);
}
}
else
{
// Update or add the search
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
existing.Status = update.Status;
existing.SubmitDt = update.SubmitDt;
existing.StartDt = update.StartDt;
existing.EndDt = update.EndDt;
}
else
{
_searches.Add(new ClientSearchViewModel
{
Id = update.Id,
Name = update.Name,
UserName = update.UserName,
Status = update.Status,
SubmitDt = update.SubmitDt,
StartDt = update.StartDt,
EndDt = update.EndDt
});
}
}
StateHasChanged();
});
}
private void HandleStatusUpdate(StatusUpdate update)
{
InvokeAsync(() =>
{
_statusMessage = update.Message;
_statusUpdateDt = update.Timestamp?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "";
StateHasChanged();
});
}
private static BadgeStyle GetBadgeStyle(string status) => status switch
{
"Error" => BadgeStyle.Danger,
"Ended" => BadgeStyle.Success,
"Running" => BadgeStyle.Info,
"Queued" => BadgeStyle.Warning,
_ => BadgeStyle.Light
};
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
HubConnection.OnStatusUpdate -= HandleStatusUpdate;
}
}
@@ -0,0 +1,140 @@
@page "/"
@page "/searches"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@inject NavigationManager NavigationManager
@implements IDisposable
<PageTitle>Searches - JDE Scoping Tool</PageTitle>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H4" class="rz-m-0">Searches</RadzenText>
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem">
<RadzenButton Text="Start New Search" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@CreateNewSearch" />
<RadzenButton Text="View Search Queue" Icon="queue" ButtonStyle="ButtonStyle.Light" Click="@ViewQueue" />
</RadzenStack>
</RadzenStack>
@if (_isLoading)
{
<LoadingIndicator Message="Loading searches..." />
}
else
{
<RadzenDataGrid @ref="_grid" Data="@_searches" TItem="ClientSearchViewModel" AllowSorting="true" AllowPaging="true" PageSize="10"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" RowDoubleClick="@OnRowDoubleClick">
<Columns>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Name" Title="Name" Width="250px" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
<Template Context="search">
@(search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Status" Title="Status" Width="100px">
<Template Context="search">
<RadzenBadge BadgeStyle="@GetBadgeStyle(search.Status)" Text="@search.Status" />
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="search">
<RadzenButton Text="View" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Info" Click="@(() => ViewSearch(search.Id))" />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<ClientSearchViewModel> _searches = [];
private RadzenDataGrid<ClientSearchViewModel>? _grid;
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
await LoadSearchesAsync();
await SetupSignalRAsync();
}
private async Task LoadSearchesAsync()
{
_isLoading = true;
try
{
_searches = await SearchService.GetUserSearchesAsync();
}
finally
{
_isLoading = false;
}
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
{
InvokeAsync(() =>
{
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
existing.Status = update.Status;
existing.SubmitDt = update.SubmitDt;
existing.StartDt = update.StartDt;
existing.EndDt = update.EndDt;
}
else
{
_searches.Insert(0, new ClientSearchViewModel
{
Id = update.Id,
Name = update.Name,
UserName = update.UserName,
Status = update.Status,
SubmitDt = update.SubmitDt,
StartDt = update.StartDt,
EndDt = update.EndDt
});
}
StateHasChanged();
});
}
private void CreateNewSearch()
{
NavigationManager.NavigateTo("/search");
}
private void ViewQueue()
{
NavigationManager.NavigateTo("/search/queue");
}
private void ViewSearch(int id)
{
NavigationManager.NavigateTo($"/search/{id}");
}
private void OnRowDoubleClick(DataGridRowMouseEventArgs<ClientSearchViewModel> args)
{
ViewSearch(args.Data.Id);
}
private static BadgeStyle GetBadgeStyle(string status) => status switch
{
"Error" => BadgeStyle.Danger,
"Ended" => BadgeStyle.Success,
"Running" => BadgeStyle.Info,
"Queued" => BadgeStyle.Warning,
_ => BadgeStyle.Light
};
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
}
}
+39
View File
@@ -0,0 +1,39 @@
using JdeScoping.Client;
using JdeScoping.Client.Auth;
using JdeScoping.Client.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Radzen;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure HttpClient with base address
// In Blazor WebAssembly, the browser automatically handles cookies for same-origin requests
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Radzen services
builder.Services.AddRadzenComponents();
// Authentication services (cookie-based)
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<IUserStorageService, UserStorageService>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<IAuthService, AuthService>();
// SignalR service
builder.Services.AddScoped<IHubConnectionService, HubConnectionService>();
// API client services
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ILookupService, LookupService>();
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IRefreshStatusService, RefreshStatusService>();
await builder.Build().RunAsync();
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5091",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,89 @@
using System.Net.Http.Json;
using JdeScoping.Client.Auth;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles authentication via API calls with cookie-based auth.
/// </summary>
public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
private readonly AuthStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
AuthStateProvider authStateProvider)
{
_httpClient = httpClient;
_authStateProvider = authStateProvider;
}
public async Task<AuthResult> LoginAsync(LoginModel model)
{
try
{
var response = await _httpClient.PostAsJsonAsync("api/auth/login", new
{
model.Username,
model.Password
});
if (response.IsSuccessStatusCode)
{
// API returns UserInfo and sets auth cookie
var userInfo = await response.Content.ReadFromJsonAsync<UserInfoViewModel>();
if (userInfo != null)
{
// Notify auth state provider of the login
await _authStateProvider.MarkUserAsAuthenticated(userInfo);
return new AuthResult
{
Success = true,
User = userInfo
};
}
return new AuthResult
{
Success = false,
ErrorMessage = "Invalid response from server"
};
}
var errorContent = await response.Content.ReadAsStringAsync();
return new AuthResult
{
Success = false,
ErrorMessage = string.IsNullOrEmpty(errorContent)
? "Login failed. Please check your credentials."
: errorContent
};
}
catch (Exception ex)
{
return new AuthResult
{
Success = false,
ErrorMessage = $"Login failed: {ex.Message}"
};
}
}
public async Task LogoutAsync()
{
try
{
// Call logout endpoint to clear server-side cookie
await _httpClient.PostAsync("api/auth/logout", null);
}
catch
{
// Even if logout API fails, clear local state
}
await _authStateProvider.LogoutAsync();
}
}
@@ -0,0 +1,115 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles file upload/download operations via the api/fileio endpoints.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class FileService : IFileService
{
private readonly HttpClient _httpClient;
private readonly IJSRuntime _jsRuntime;
public FileService(HttpClient httpClient, IJSRuntime jsRuntime)
{
_httpClient = httpClient;
_jsRuntime = jsRuntime;
}
public async Task DownloadTemplateAsync(string templateType, object? existingData = null)
{
try
{
// Map template type to API endpoint
var endpoint = templateType switch
{
"work-orders" or "workorders" => "api/fileio/workorders/download",
"items" or "part-numbers" => "api/fileio/items/download",
"component-lots" or "componentlots" => "api/fileio/componentlots/download",
"part-operations" or "partoperations" => "api/fileio/partoperations/download",
_ => throw new ArgumentException($"Unknown template type: {templateType}")
};
var fileName = templateType switch
{
"work-orders" or "workorders" => "work_order_template.xlsx",
"items" or "part-numbers" => "item_number_template.xlsx",
"component-lots" or "componentlots" => "component_lot_template.xlsx",
"part-operations" or "partoperations" => "item_operations_mis_template.xlsx",
_ => $"{templateType}_template.xlsx"
};
// POST with existing data to get the Excel file
var response = await _httpClient.PostAsJsonAsync(endpoint, existingData);
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
await _jsRuntime.InvokeVoidAsync("downloadFile", fileName, bytes);
}
else
{
Console.WriteLine($"Failed to download template: {response.StatusCode}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to download template: {ex.Message}");
}
}
public async Task DownloadPartNumberTemplateAsync(List<ItemViewModel>? existingItems = null)
{
await DownloadTemplateAsync("items", existingItems);
}
public async Task<UploadResult<T>> UploadAsync<T>(string uploadType, Stream fileStream, string fileName)
{
try
{
// Map upload type to API endpoint
var endpoint = uploadType switch
{
"work-orders" or "workorders" => "api/fileio/workorders/upload",
"items" or "part-numbers" => "api/fileio/items/upload",
"component-lots" or "componentlots" => "api/fileio/componentlots/upload",
"part-operations" or "partoperations" => "api/fileio/partoperations/upload",
_ => throw new ArgumentException($"Unknown upload type: {uploadType}")
};
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(fileStream);
content.Add(streamContent, "file", fileName);
var response = await _httpClient.PostAsync(endpoint, content);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<UploadResult<T>>();
return result ?? new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = "Invalid response from server"
};
}
return new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = $"Upload failed: {response.StatusCode}"
};
}
catch (Exception ex)
{
return new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = $"Upload failed: {ex.Message}"
};
}
}
}
@@ -0,0 +1,117 @@
using JdeScoping.Client.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
namespace JdeScoping.Client.Services;
/// <summary>
/// Manages SignalR connection with auto-reconnect.
/// Uses cookie-based authentication (browser automatically sends cookies with requests).
/// </summary>
public class HubConnectionService : IHubConnectionService, IAsyncDisposable
{
private readonly NavigationManager _navigationManager;
private HubConnection? _hubConnection;
public event Action<SearchUpdate>? OnSearchUpdate;
public event Action<StatusUpdate>? OnStatusUpdate;
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
public HubConnectionService(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
}
public async Task StartAsync()
{
if (_hubConnection != null)
{
return;
}
// In Blazor WebAssembly, the browser automatically sends cookies with requests
// to the same origin, so we don't need to configure any special auth options
_hubConnection = new HubConnectionBuilder()
.WithUrl(_navigationManager.ToAbsoluteUri("/hubs/status"))
.WithAutomaticReconnect([
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
])
.Build();
_hubConnection.On<SearchUpdate>("searchUpdate", update =>
{
OnSearchUpdate?.Invoke(update);
});
_hubConnection.On<StatusUpdate>("statusUpdate", update =>
{
OnStatusUpdate?.Invoke(update);
});
_hubConnection.Reconnecting += error =>
{
Console.WriteLine($"SignalR reconnecting: {error?.Message}");
return Task.CompletedTask;
};
_hubConnection.Reconnected += connectionId =>
{
Console.WriteLine($"SignalR reconnected: {connectionId}");
return Task.CompletedTask;
};
_hubConnection.Closed += error =>
{
Console.WriteLine($"SignalR closed: {error?.Message}");
return Task.CompletedTask;
};
try
{
await _hubConnection.StartAsync();
Console.WriteLine("SignalR connected");
}
catch (Exception ex)
{
Console.WriteLine($"SignalR connection failed: {ex.Message}");
}
}
public async Task StopAsync()
{
if (_hubConnection != null)
{
await _hubConnection.StopAsync();
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
}
public async Task<StatusUpdate?> GetCachedStatusAsync()
{
if (_hubConnection == null || _hubConnection.State != HubConnectionState.Connected)
{
return null;
}
try
{
return await _hubConnection.InvokeAsync<StatusUpdate>("GetCachedStatus");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get cached status: {ex.Message}");
return null;
}
}
public async ValueTask DisposeAsync()
{
await StopAsync();
}
}
@@ -0,0 +1,29 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for authentication operations.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Attempts to log in with the provided credentials.
/// </summary>
Task<AuthResult> LoginAsync(LoginModel model);
/// <summary>
/// Logs out the current user.
/// </summary>
Task LogoutAsync();
}
/// <summary>
/// Result of an authentication attempt.
/// </summary>
public record AuthResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public UserInfoViewModel? User { get; init; }
}
@@ -0,0 +1,35 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for file upload/download operations.
/// </summary>
public interface IFileService
{
/// <summary>
/// Downloads the work order template file.
/// </summary>
Task DownloadTemplateAsync(string templateType, object? existingData = null);
/// <summary>
/// Downloads the part number template file.
/// </summary>
Task DownloadPartNumberTemplateAsync(List<ItemViewModel>? existingItems = null);
/// <summary>
/// Uploads a file and returns parsed data.
/// </summary>
Task<UploadResult<T>> UploadAsync<T>(string uploadType, Stream fileStream, string fileName);
}
/// <summary>
/// Result of a file upload operation.
/// </summary>
public class UploadResult<T>
{
public bool WasSuccessful { get; set; }
public string? ErrorMessage { get; set; }
public List<T> Data { get; set; } = [];
}
@@ -0,0 +1,39 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for managing SignalR hub connection.
/// </summary>
public interface IHubConnectionService
{
/// <summary>
/// Event fired when a search update is received.
/// </summary>
event Action<SearchUpdate>? OnSearchUpdate;
/// <summary>
/// Event fired when a processor status update is received.
/// </summary>
event Action<StatusUpdate>? OnStatusUpdate;
/// <summary>
/// Starts the SignalR connection.
/// </summary>
Task StartAsync();
/// <summary>
/// Stops the SignalR connection.
/// </summary>
Task StopAsync();
/// <summary>
/// Gets the cached processor status from the server.
/// </summary>
Task<StatusUpdate?> GetCachedStatusAsync();
/// <summary>
/// Gets the current connection state.
/// </summary>
bool IsConnected { get; }
}
@@ -0,0 +1,30 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for lookup/autocomplete API operations.
/// </summary>
public interface ILookupService
{
/// <summary>
/// Finds items matching the search term.
/// </summary>
Task<List<ItemViewModel>> FindItemsAsync(string searchTerm);
/// <summary>
/// Finds profit centers matching the search term.
/// </summary>
Task<List<ProfitCenterViewModel>> FindProfitCentersAsync(string searchTerm);
/// <summary>
/// Finds work centers matching the search term.
/// </summary>
Task<List<WorkCenterViewModel>> FindWorkCentersAsync(string searchTerm);
/// <summary>
/// Finds operators matching the search term.
/// </summary>
Task<List<OperatorViewModel>> FindOperatorsAsync(string searchTerm);
}
@@ -0,0 +1,14 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for data refresh status API operations.
/// </summary>
public interface IRefreshStatusService
{
/// <summary>
/// Gets refresh status records within the specified date range.
/// </summary>
Task<List<DataUpdateViewModel>> GetRefreshStatusAsync(DateTime minDt, DateTime maxDt);
}
@@ -0,0 +1,39 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for search-related API operations.
/// </summary>
public interface ISearchService
{
/// <summary>
/// Gets all searches for the current user.
/// </summary>
Task<List<SearchViewModel>> GetUserSearchesAsync();
/// <summary>
/// Gets a specific search by ID.
/// </summary>
Task<SearchViewModel?> GetSearchAsync(int id);
/// <summary>
/// Copies an existing search to create a new one.
/// </summary>
Task<SearchViewModel?> CopySearchAsync(int id);
/// <summary>
/// Saves and submits a search.
/// </summary>
Task<int?> SaveSearchAsync(SearchViewModel search);
/// <summary>
/// Gets all searches in the queue.
/// </summary>
Task<List<SearchViewModel>> GetQueueAsync();
/// <summary>
/// Downloads the results for a completed search.
/// </summary>
Task<byte[]?> DownloadResultsAsync(int id);
}
@@ -0,0 +1,99 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles lookup/autocomplete API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class LookupService : ILookupService
{
private readonly HttpClient _httpClient;
public LookupService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<ItemViewModel>> FindItemsAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<ItemViewModel>>(
$"api/lookup/items?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find items: {ex.Message}");
return [];
}
}
public async Task<List<ProfitCenterViewModel>> FindProfitCentersAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<ProfitCenterViewModel>>(
$"api/lookup/profit-centers?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find profit centers: {ex.Message}");
return [];
}
}
public async Task<List<WorkCenterViewModel>> FindWorkCentersAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<WorkCenterViewModel>>(
$"api/lookup/work-centers?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find work centers: {ex.Message}");
return [];
}
}
public async Task<List<OperatorViewModel>> FindOperatorsAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<OperatorViewModel>>(
$"api/lookup/operators?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find operators: {ex.Message}");
return [];
}
}
}
@@ -0,0 +1,35 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles refresh status API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class RefreshStatusService : IRefreshStatusService
{
private readonly HttpClient _httpClient;
public RefreshStatusService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<DataUpdateViewModel>> GetRefreshStatusAsync(DateTime minDt, DateTime maxDt)
{
try
{
var minDtStr = minDt.ToString("yyyy-MM-dd");
var maxDtStr = maxDt.ToString("yyyy-MM-dd");
var result = await _httpClient.GetFromJsonAsync<List<DataUpdateViewModel>>(
$"api/refresh-status?minDT={minDtStr}&maxDT={maxDtStr}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get refresh status: {ex.Message}");
return [];
}
}
}
@@ -0,0 +1,129 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles search-related API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class SearchService : ISearchService
{
private readonly HttpClient _httpClient;
public SearchService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<SearchViewModel>> GetUserSearchesAsync()
{
try
{
var result = await _httpClient.GetFromJsonAsync<List<SearchViewModel>>("api/search");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get searches: {ex.Message}");
return [];
}
}
public async Task<SearchViewModel?> GetSearchAsync(int id)
{
try
{
return await _httpClient.GetFromJsonAsync<SearchViewModel>($"api/search/{id}");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get search {id}: {ex.Message}");
return null;
}
}
public async Task<SearchViewModel?> CopySearchAsync(int id)
{
try
{
return await _httpClient.GetFromJsonAsync<SearchViewModel>($"api/search/{id}/copy");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to copy search {id}: {ex.Message}");
return null;
}
}
public async Task<int?> SaveSearchAsync(SearchViewModel search)
{
try
{
var response = await _httpClient.PostAsJsonAsync("api/search", new
{
search.Name,
search.UserName,
Criteria = new
{
MinimumDT = search.Criteria.MinimumDt,
MaximumDT = search.Criteria.MaximumDt,
WorkOrders = search.Criteria.WorkOrders.Select(wo => new { wo.WorkOrderNumber }),
Items = search.Criteria.Items.Select(i => new { i.ItemNumber }),
ProfitCenters = search.Criteria.ProfitCenters.Select(pc => new { pc.Code }),
WorkCenters = search.Criteria.WorkCenters.Select(wc => new { wc.Code }),
ComponentLots = search.Criteria.ComponentLots.Select(cl => new { cl.LotNumber, cl.ItemNumber }),
Operators = search.Criteria.Operators.Select(op => new { op.AddressNumber, UserID = op.UserId }),
PartOperations = search.Criteria.PartOperations.Select(po => new
{
po.ItemNumber,
po.OperationNumber,
po.MisNumber,
po.MisRevision
}),
search.Criteria.ExtractMisData
}
});
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<int>();
}
Console.WriteLine($"Failed to save search: {response.StatusCode}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save search: {ex.Message}");
return null;
}
}
public async Task<List<SearchViewModel>> GetQueueAsync()
{
try
{
var result = await _httpClient.GetFromJsonAsync<List<SearchViewModel>>("api/search/queue");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get queue: {ex.Message}");
return [];
}
}
public async Task<byte[]?> DownloadResultsAsync(int id)
{
try
{
return await _httpClient.GetByteArrayAsync($"api/search/{id}/results");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to download results for {id}: {ex.Message}");
return null;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.JSInterop
@using Radzen
@using Radzen.Blazor
@using JdeScoping.Client
@using JdeScoping.Client.Auth
@using JdeScoping.Client.Components.FilterPanels
@using JdeScoping.Client.Components.Shared
@using JdeScoping.Client.Layout
@using JdeScoping.Client.Models
@using JdeScoping.Client.Pages
@using JdeScoping.Client.Services
@using JdeScoping.Core.ViewModels
@using ClientSearchViewModel = JdeScoping.Client.Models.SearchViewModel
@@ -0,0 +1,287 @@
/* JDE Scoping Tool - Application Styles */
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.loading-progress {
position: absolute;
display: block;
width: 8rem;
height: 8rem;
inset: 20vh 0 auto 0;
margin: 0 auto 0 auto;
}
.loading-progress circle {
fill: none;
stroke: #e0e0e0;
stroke-width: 0.6rem;
transform-origin: 50% 50%;
transform: rotate(-90deg);
}
.loading-progress circle:last-child {
stroke: #1b6ec2;
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
transition: stroke-dasharray 0.05s ease-in-out;
}
.loading-progress-text {
position: absolute;
text-align: center;
font-weight: bold;
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
code {
color: #c02d76;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
/* Custom Application Styles */
/* Status badges */
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge-success {
background-color: #28a745;
color: white;
}
.status-badge-danger {
background-color: #dc3545;
color: white;
}
.status-badge-warning {
background-color: #ffc107;
color: #212529;
}
.status-badge-info {
background-color: #17a2b8;
color: white;
}
/* Card styles */
.rz-card {
margin-bottom: 1rem;
}
/* Loading container */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
min-height: 200px;
}
/* Filter panel styles */
.filter-panel {
margin-bottom: 1rem;
}
.filter-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
/* Grid content max height */
.rz-data-grid .rz-data-grid-data {
max-height: 300px;
overflow-y: auto;
}
/* Radzen Layout adjustments */
.rz-layout {
height: 100vh;
}
/* Fixed Top Navbar */
.navbar-fixed-top {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1030;
background-color: #222;
color: white;
height: 56px;
padding: 0;
}
.navbar-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 15px;
max-width: 100%;
}
.navbar-left {
display: flex;
align-items: center;
gap: 2rem;
}
.navbar-brand {
color: #fff;
font-size: 1.25rem;
font-weight: bold;
text-decoration: none;
}
.navbar-brand:hover {
color: #fff;
text-decoration: none;
}
.navbar-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
color: #9d9d9d;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out;
}
.nav-link:hover {
color: #fff;
text-decoration: none;
}
.nav-link.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.navbar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar-user {
color: #9d9d9d;
}
/* Main body with top padding for fixed navbar */
.main-body {
padding-top: 56px;
}
.body-content {
padding: 20px 15px;
min-height: calc(100vh - 56px - 50px); /* viewport - navbar - footer */
}
/* Footer */
.rz-footer {
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
padding: 0.5rem 0;
}
/* Container fluid */
.container-fluid {
padding: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JDE Scoping Tool</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<!-- Radzen Blazor CSS -->
<link rel="stylesheet" href="_content/Radzen.Blazor/css/material-base.css" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="JdeScoping.Client.styles.css" rel="stylesheet" />
<script type="importmap"></script>
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">X</span>
</div>
<!-- JS Interop -->
<script src="js/interop.js"></script>
<!-- Radzen Blazor JS -->
<script src="_content/Radzen.Blazor/Radzen.Blazor.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>
@@ -0,0 +1,70 @@
// JDE Scoping Tool - JavaScript Interop Functions
// Global download function for file byte arrays (called from Blazor)
window.downloadFile = function (fileName, byteArray) {
const blob = new Blob([new Uint8Array(byteArray)]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
};
window.jdeScopingInterop = {
// Download file from a byte array stream
downloadFileFromStream: async function (fileName, contentStreamReference) {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
},
// Download file from a URL
downloadFileFromUrl: function (url, fileName) {
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.target = '_blank';
anchorElement.click();
anchorElement.remove();
},
// Save value to localStorage
setLocalStorage: function (key, value) {
localStorage.setItem(key, value);
},
// Get value from localStorage
getLocalStorage: function (key) {
return localStorage.getItem(key);
},
// Remove value from localStorage
removeLocalStorage: function (key) {
localStorage.removeItem(key);
},
// Save value to sessionStorage (clears when browser closes)
setSessionStorage: function (key, value) {
sessionStorage.setItem(key, value);
},
// Get value from sessionStorage
getSessionStorage: function (key) {
return sessionStorage.getItem(key);
},
// Remove value from sessionStorage
removeSessionStorage: function (key) {
sessionStorage.removeItem(key);
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More