From 7d8e40b9cffb3b12c390f8d0b5c5e31195d2b3ff Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:17:09 +0200 Subject: [PATCH] Add Configuration UI for Transport and Shift Settings with Import/Export (#27) * Initial plan * Complete configuration UI implementation with working dialog, API endpoints, and file import/export Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> * Refactor to inject IConfigurationService directly instead of using factory singletons Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> * Remove redundant ShiftService and use IConfigurationService directly Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> --- Client/Components/ConfigurationDialog.razor | 338 ++++++++++++++++++ Client/Pages/Index.razor | 6 + Client/Pages/Index.razor.cs | 21 ++ Client/Pages/Index.razor.css | 18 + Client/wwwroot/js/fileDownload.js | 15 + Server/Controllers/ConfigurationController.cs | 73 ++++ Server/Controllers/ShiftController.cs | 16 +- Server/Program.cs | 11 +- Services/ConfigurationService.cs | 111 ++++++ Services/ShiftService.cs | 17 - Services/TransportApiService.cs | 4 +- Services/TransportService.cs | 7 +- Shared/Configuration.cs | 8 + .../TransportApiServiceTests.cs | 8 +- .../TransportServiceTests.cs | 8 +- 15 files changed, 626 insertions(+), 35 deletions(-) create mode 100644 Client/Components/ConfigurationDialog.razor create mode 100644 Server/Controllers/ConfigurationController.cs create mode 100644 Services/ConfigurationService.cs delete mode 100644 Services/ShiftService.cs create mode 100644 Shared/Configuration.cs diff --git a/Client/Components/ConfigurationDialog.razor b/Client/Components/ConfigurationDialog.razor new file mode 100644 index 0000000..ca3279d --- /dev/null +++ b/Client/Components/ConfigurationDialog.razor @@ -0,0 +1,338 @@ +@using ShiftScheduler.Shared +@using System.Text.Json +@inject HttpClient HttpClient +@inject IJSRuntime JSRuntime + + + +@if (IsVisible) +{ + +} + + + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + [Parameter] public EventCallback OnConfigurationChanged { get; set; } + + private ApplicationConfiguration _config = new(); + private bool _isLoading = false; + private bool _isSaving = false; + private bool _showImportSection = false; + private string _errorMessage = string.Empty; + private string _successMessage = string.Empty; + private ElementReference _fileInput; + + protected override async Task OnParametersSetAsync() + { + if (IsVisible && (_config.Shifts.Count == 0 || _config.Transport.StartStation == string.Empty)) + { + await LoadConfiguration(); + } + } + + private async Task LoadConfiguration() + { + _isLoading = true; + _errorMessage = string.Empty; + StateHasChanged(); + + try + { + var response = await HttpClient.GetAsync("api/configuration"); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + _config = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new ApplicationConfiguration(); + } + else + { + _errorMessage = "Failed to load configuration"; + } + } + catch (Exception ex) + { + _errorMessage = $"Error loading configuration: {ex.Message}"; + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private async Task Save() + { + _isSaving = true; + _errorMessage = string.Empty; + _successMessage = string.Empty; + StateHasChanged(); + + try + { + var response = await HttpClient.PutAsJsonAsync("api/configuration", _config); + if (response.IsSuccessStatusCode) + { + _successMessage = "Configuration saved successfully!"; + await OnConfigurationChanged.InvokeAsync(); + await Task.Delay(1000); // Show success message briefly + await Close(); + } + else + { + _errorMessage = "Failed to save configuration"; + } + } + catch (Exception ex) + { + _errorMessage = $"Error saving configuration: {ex.Message}"; + } + finally + { + _isSaving = false; + StateHasChanged(); + } + } + + private async Task ExportConfiguration() + { + try + { + var response = await HttpClient.GetAsync("api/configuration/export"); + if (response.IsSuccessStatusCode) + { + var bytes = await response.Content.ReadAsByteArrayAsync(); + var base64 = Convert.ToBase64String(bytes); + await JSRuntime.InvokeVoidAsync("downloadFile", "ShiftScheduler-Configuration.json", "application/json", base64); + } + } + catch (Exception ex) + { + _errorMessage = $"Error exporting configuration: {ex.Message}"; + StateHasChanged(); + } + } + + private async Task ImportConfiguration() + { + try + { + var result = await JSRuntime.InvokeAsync("readFileAsText", _fileInput); + if (!string.IsNullOrEmpty(result)) + { + var request = new { JsonContent = result }; + var response = await HttpClient.PostAsJsonAsync("api/configuration/import", request); + if (response.IsSuccessStatusCode) + { + await LoadConfiguration(); + _successMessage = "Configuration imported successfully!"; + _showImportSection = false; + } + else + { + _errorMessage = "Failed to import configuration"; + } + } + } + catch (Exception ex) + { + _errorMessage = $"Error importing configuration: {ex.Message}"; + } + StateHasChanged(); + } + + private void AddShift() + { + _config.Shifts.Add(new Shift + { + Name = "New Shift", + Icon = "⭐", + MorningTime = "", + AfternoonTime = "" + }); + StateHasChanged(); + } + + private void RemoveShift(int index) + { + if (index >= 0 && index < _config.Shifts.Count) + { + _config.Shifts.RemoveAt(index); + StateHasChanged(); + } + } + + private async Task Close() + { + _errorMessage = string.Empty; + _successMessage = string.Empty; + _showImportSection = false; + await OnClose.InvokeAsync(); + } +} \ No newline at end of file diff --git a/Client/Pages/Index.razor b/Client/Pages/Index.razor index bd65808..17c7f58 100644 --- a/Client/Pages/Index.razor +++ b/Client/Pages/Index.razor @@ -1,9 +1,11 @@ @page "/" +@using ShiftScheduler.Client.Components
+

Shift Scheduler - @CurrentMonthYear

@@ -67,3 +69,7 @@ else } } + + diff --git a/Client/Pages/Index.razor.cs b/Client/Pages/Index.razor.cs index c1f9f75..d4c548a 100644 --- a/Client/Pages/Index.razor.cs +++ b/Client/Pages/Index.razor.cs @@ -259,5 +259,26 @@ namespace ShiftScheduler.Client.Pages return string.Join(" | ", times); } + + private bool _showConfigDialog = false; + + private void ShowConfiguration() + { + _showConfigDialog = true; + StateHasChanged(); + } + + private void HideConfiguration() + { + _showConfigDialog = false; + StateHasChanged(); + } + + private async Task OnConfigurationChanged() + { + // Reload shifts after configuration changes + Shifts = await HttpClient.GetFromJsonAsync>("api/shift/shifts") ?? new(); + StateHasChanged(); + } } } diff --git a/Client/Pages/Index.razor.css b/Client/Pages/Index.razor.css index 8e602ee..d2e07a9 100644 --- a/Client/Pages/Index.razor.css +++ b/Client/Pages/Index.razor.css @@ -127,6 +127,24 @@ border-color: #FF69B4; } +/* Configuration button */ +.btn.config-btn { + margin: 5px; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + background-color: #DDA0DD; /* Light purple/plum */ + color: #333; + border: 2px solid #DA70D6; /* Slightly darker purple border */ + border-radius: 5px; + transition: background-color 0.2s ease; +} + +.btn.config-btn:hover { + background-color: #DA70D6; + border-color: #BA55D3; +} + /* Reset section - separate from export buttons */ .reset-section { margin: 30px 0 20px 0; diff --git a/Client/wwwroot/js/fileDownload.js b/Client/wwwroot/js/fileDownload.js index a0c444b..4b398fa 100644 --- a/Client/wwwroot/js/fileDownload.js +++ b/Client/wwwroot/js/fileDownload.js @@ -15,4 +15,19 @@ window.downloadFile = (filename, contentType, base64Data) => { a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); +}; + +window.readFileAsText = (fileInput) => { + return new Promise((resolve, reject) => { + const file = fileInput.files[0]; + if (!file) { + resolve(''); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = (e) => reject(e); + reader.readAsText(file); + }); }; \ No newline at end of file diff --git a/Server/Controllers/ConfigurationController.cs b/Server/Controllers/ConfigurationController.cs new file mode 100644 index 0000000..93a3e7a --- /dev/null +++ b/Server/Controllers/ConfigurationController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using ShiftScheduler.Services; +using ShiftScheduler.Shared; +using System.Text; + +namespace ShiftScheduler.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ConfigurationController : ControllerBase + { + private readonly IConfigurationService _configurationService; + + public ConfigurationController(IConfigurationService configurationService) + { + _configurationService = configurationService; + } + + [HttpGet] + public IActionResult GetConfiguration() + { + return Ok(_configurationService.GetConfiguration()); + } + + [HttpPut] + public IActionResult UpdateConfiguration([FromBody] ApplicationConfiguration configuration) + { + try + { + _configurationService.UpdateConfiguration(configuration); + return Ok(); + } + catch (Exception ex) + { + return BadRequest($"Failed to update configuration: {ex.Message}"); + } + } + + [HttpGet("export")] + public async Task ExportConfiguration() + { + try + { + var json = await _configurationService.ExportConfigurationAsync(); + var bytes = Encoding.UTF8.GetBytes(json); + return File(bytes, "application/json", "ShiftScheduler-Configuration.json"); + } + catch (Exception ex) + { + return BadRequest($"Failed to export configuration: {ex.Message}"); + } + } + + [HttpPost("import")] + public async Task ImportConfiguration([FromBody] ImportConfigurationRequest request) + { + try + { + await _configurationService.ImportConfigurationAsync(request.JsonContent); + return Ok(); + } + catch (Exception ex) + { + return BadRequest($"Failed to import configuration: {ex.Message}"); + } + } + } + + public class ImportConfigurationRequest + { + public string JsonContent { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index f4617b2..9de6a11 100644 --- a/Server/Controllers/ShiftController.cs +++ b/Server/Controllers/ShiftController.cs @@ -8,41 +8,39 @@ namespace ShiftScheduler.Server.Controllers [Route("api/[controller]")] public class ShiftController : ControllerBase { - private readonly ShiftService _shiftService; private readonly IcsExportService _icsService; private readonly PdfExportService _pdfExportService; private readonly ITransportService _transportService; - private readonly TransportConfiguration _transportConfig; + private readonly IConfigurationService _configurationService; public ShiftController( - ShiftService shiftService, IcsExportService icsService, PdfExportService pdfExportService, ITransportService transportService, - TransportConfiguration transportConfig) + IConfigurationService configurationService) { - _shiftService = shiftService; _icsService = icsService; _pdfExportService = pdfExportService; _transportService = transportService; - _transportConfig = transportConfig; + _configurationService = configurationService; } [HttpGet("shifts")] public IActionResult GetShifts() { - return Ok(_shiftService.GetShifts()); + return Ok(_configurationService.GetShifts()); } [HttpPost("shift_transport")] public async Task GetShiftTransport([FromBody] ShiftTransportRequest request) { - var shift = _shiftService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName); + var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName); if (shift == null) { return NotFound($"Shift '{request.ShiftName}' not found"); } + var transportConfig = _configurationService.GetTransportConfiguration(); TransportConnection? morningTransport = null; TransportConnection? afternoonTransport = null; @@ -71,7 +69,7 @@ namespace ShiftScheduler.Server.Controllers if (morningEndTime.HasValue && afternoonStartTime.HasValue) { var breakDurationMinutes = (afternoonStartTime.Value - morningEndTime.Value).TotalMinutes; - shouldLoadAfternoonTransport = breakDurationMinutes >= _transportConfig.MinBreakMinutes; + shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes; } } diff --git a/Server/Program.cs b/Server/Program.cs index 7e52165..821312d 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -7,12 +7,17 @@ var builder = WebApplication.CreateBuilder(args); var shifts = builder.Configuration.GetSection("Shifts").Get>() ?? new(); var transportConfig = builder.Configuration.GetSection("Transport").Get() ?? new(); +// Create application configuration +var appConfiguration = new ApplicationConfiguration +{ + Transport = transportConfig, + Shifts = shifts +}; + // Register services -builder.Services.AddSingleton(shifts); -builder.Services.AddSingleton(transportConfig); +builder.Services.AddSingleton(new ConfigurationService(appConfiguration)); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Services/ConfigurationService.cs b/Services/ConfigurationService.cs new file mode 100644 index 0000000..7903f61 --- /dev/null +++ b/Services/ConfigurationService.cs @@ -0,0 +1,111 @@ +using ShiftScheduler.Shared; +using System.Text.Json; + +namespace ShiftScheduler.Services +{ + public interface IConfigurationService + { + ApplicationConfiguration GetConfiguration(); + void UpdateConfiguration(ApplicationConfiguration configuration); + Task ExportConfigurationAsync(); + Task ImportConfigurationAsync(string jsonContent); + List GetShifts(); + TransportConfiguration GetTransportConfiguration(); + } + + public class ConfigurationService : IConfigurationService + { + private ApplicationConfiguration _configuration; + private readonly object _lock = new object(); + + public ConfigurationService(ApplicationConfiguration initialConfiguration) + { + _configuration = initialConfiguration; + } + + public ApplicationConfiguration GetConfiguration() + { + lock (_lock) + { + return new ApplicationConfiguration + { + Transport = new TransportConfiguration + { + StartStation = _configuration.Transport.StartStation, + EndStation = _configuration.Transport.EndStation, + ApiBaseUrl = _configuration.Transport.ApiBaseUrl, + SafetyBufferMinutes = _configuration.Transport.SafetyBufferMinutes, + MinBreakMinutes = _configuration.Transport.MinBreakMinutes, + MaxEarlyArrivalMinutes = _configuration.Transport.MaxEarlyArrivalMinutes, + MaxLateArrivalMinutes = _configuration.Transport.MaxLateArrivalMinutes, + CacheDurationDays = _configuration.Transport.CacheDurationDays + }, + Shifts = _configuration.Shifts.Select(s => new Shift + { + Name = s.Name, + Icon = s.Icon, + MorningTime = s.MorningTime, + AfternoonTime = s.AfternoonTime + }).ToList() + }; + } + } + + public void UpdateConfiguration(ApplicationConfiguration configuration) + { + lock (_lock) + { + _configuration = new ApplicationConfiguration + { + Transport = new TransportConfiguration + { + StartStation = configuration.Transport.StartStation, + EndStation = configuration.Transport.EndStation, + ApiBaseUrl = configuration.Transport.ApiBaseUrl, + SafetyBufferMinutes = configuration.Transport.SafetyBufferMinutes, + MinBreakMinutes = configuration.Transport.MinBreakMinutes, + MaxEarlyArrivalMinutes = configuration.Transport.MaxEarlyArrivalMinutes, + MaxLateArrivalMinutes = configuration.Transport.MaxLateArrivalMinutes, + CacheDurationDays = configuration.Transport.CacheDurationDays + }, + Shifts = configuration.Shifts.Select(s => new Shift + { + Name = s.Name, + Icon = s.Icon, + MorningTime = s.MorningTime, + AfternoonTime = s.AfternoonTime + }).ToList() + }; + } + } + + public async Task ExportConfigurationAsync() + { + var config = GetConfiguration(); + return await Task.FromResult(JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true + })); + } + + public async Task ImportConfigurationAsync(string jsonContent) + { + var configuration = JsonSerializer.Deserialize(jsonContent); + if (configuration != null) + { + UpdateConfiguration(configuration); + } + await Task.CompletedTask; + } + + public List GetShifts() + { + return GetConfiguration().Shifts; + } + + public TransportConfiguration GetTransportConfiguration() + { + return GetConfiguration().Transport; + } + } +} \ No newline at end of file diff --git a/Services/ShiftService.cs b/Services/ShiftService.cs deleted file mode 100644 index a2cc40d..0000000 --- a/Services/ShiftService.cs +++ /dev/null @@ -1,17 +0,0 @@ - -using ShiftScheduler.Shared; - -namespace ShiftScheduler.Services -{ - public class ShiftService - { - private readonly List _shifts; - - public ShiftService(List shifts) - { - _shifts = shifts; - } - - public List GetShifts() => _shifts; - } -} diff --git a/Services/TransportApiService.cs b/Services/TransportApiService.cs index 6b511f2..15ffd72 100644 --- a/Services/TransportApiService.cs +++ b/Services/TransportApiService.cs @@ -3,10 +3,12 @@ using ShiftScheduler.Shared; namespace ShiftScheduler.Services { - public class TransportApiService(HttpClient httpClient, TransportConfiguration config) : ITransportApiService + public class TransportApiService(HttpClient httpClient, IConfigurationService configurationService) : ITransportApiService { public async Task GetConnectionAsync(DateTime shiftStartTime) { + var config = configurationService.GetTransportConfiguration(); + // To allow connections that arrive after shift starts, we search from earlier time // and request more connections to cover the full range var searchDate = shiftStartTime.ToString("yyyy-MM-dd"); diff --git a/Services/TransportService.cs b/Services/TransportService.cs index 94647ee..91dd0f0 100644 --- a/Services/TransportService.cs +++ b/Services/TransportService.cs @@ -3,15 +3,16 @@ using ShiftScheduler.Shared; namespace ShiftScheduler.Services { - public class TransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService + public class TransportService(ITransportApiService transportService, IConfigurationService configurationService, IMemoryCache cache) : ITransportService { public async Task GetConnectionAsync(DateTime shiftStartTime) { + var config = configurationService.GetTransportConfiguration(); var searchDate = shiftStartTime.ToString("yyyy-MM-dd"); var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm"); // Generate cache key based on request parameters - var cacheKey = GenerateCacheKey(searchDate, searchTime); + var cacheKey = GenerateCacheKey(config, searchDate, searchTime); // Try to get from cache first if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null) @@ -35,7 +36,7 @@ namespace ShiftScheduler.Services return connection; } - private string GenerateCacheKey(string searchDate, string searchTime) + private string GenerateCacheKey(TransportConfiguration config, string searchDate, string searchTime) { return $"transport_{config.StartStation}_{config.EndStation}_{searchDate}_{searchTime}"; } diff --git a/Shared/Configuration.cs b/Shared/Configuration.cs new file mode 100644 index 0000000..737d7c9 --- /dev/null +++ b/Shared/Configuration.cs @@ -0,0 +1,8 @@ +namespace ShiftScheduler.Shared +{ + public class ApplicationConfiguration + { + public TransportConfiguration Transport { get; set; } = new(); + public List Shifts { get; set; } = new(); + } +} \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs b/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs index 8065a29..cd95c7a 100644 --- a/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs +++ b/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs @@ -11,6 +11,7 @@ namespace ShiftScheduler.Services.Tests; public class TransportApiServiceTests { private readonly Mock _httpMessageHandlerMock; + private readonly Mock _configurationServiceMock; private readonly HttpClient _httpClient; private readonly TransportApiService _transportApiService; @@ -18,6 +19,7 @@ public class TransportApiServiceTests { _httpMessageHandlerMock = new Mock(); _httpClient = new HttpClient(_httpMessageHandlerMock.Object); + _configurationServiceMock = new Mock(); var config = new TransportConfiguration { @@ -31,7 +33,11 @@ public class TransportApiServiceTests CacheDurationDays = 1 }; - _transportApiService = new TransportApiService(_httpClient, config); + _configurationServiceMock + .Setup(x => x.GetTransportConfiguration()) + .Returns(config); + + _transportApiService = new TransportApiService(_httpClient, _configurationServiceMock.Object); } [Fact] diff --git a/ShiftScheduler.Services.Tests/TransportServiceTests.cs b/ShiftScheduler.Services.Tests/TransportServiceTests.cs index fe0f265..516b1bc 100644 --- a/ShiftScheduler.Services.Tests/TransportServiceTests.cs +++ b/ShiftScheduler.Services.Tests/TransportServiceTests.cs @@ -9,6 +9,7 @@ namespace ShiftScheduler.Services.Tests; public class TransportServiceTests { private readonly Mock _transportServiceMock; + private readonly Mock _configurationServiceMock; private readonly IMemoryCache _memoryCache; private readonly TransportService _transportService; private readonly TransportConfiguration _config; @@ -16,6 +17,7 @@ public class TransportServiceTests public TransportServiceTests() { _transportServiceMock = new Mock(); + _configurationServiceMock = new Mock(); _memoryCache = new MemoryCache(new MemoryCacheOptions()); _config = new TransportConfiguration @@ -30,7 +32,11 @@ public class TransportServiceTests CacheDurationDays = 1 }; - _transportService = new TransportService(_transportServiceMock.Object, _config, _memoryCache); + _configurationServiceMock + .Setup(x => x.GetTransportConfiguration()) + .Returns(_config); + + _transportService = new TransportService(_transportServiceMock.Object, _configurationServiceMock.Object, _memoryCache); } [Fact]