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>
This commit is contained in:
parent
73abfac988
commit
7d8e40b9cf
15 changed files with 626 additions and 35 deletions
338
Client/Components/ConfigurationDialog.razor
Normal file
338
Client/Components/ConfigurationDialog.razor
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
@using ShiftScheduler.Shared
|
||||||
|
@using System.Text.Json
|
||||||
|
@inject HttpClient HttpClient
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<div class="modal @(IsVisible ? "show" : "")" style="display: @(IsVisible ? "block" : "none")" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Configuration</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p>Loading configuration...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-primary" @onclick="ExportConfiguration">Export Configuration</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="() => _showImportSection = !_showImportSection">
|
||||||
|
@(_showImportSection ? "Hide Import" : "Import Configuration")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_showImportSection)
|
||||||
|
{
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Import Configuration</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="configFile" class="form-label">Select configuration file:</label>
|
||||||
|
<input type="file" class="form-control" id="configFile" accept=".json" @ref="_fileInput" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @onclick="ImportConfiguration">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Transport Configuration -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Transport Configuration</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Start Station</label>
|
||||||
|
<input type="text" class="form-control" @bind="_config.Transport.StartStation" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">End Station</label>
|
||||||
|
<input type="text" class="form-control" @bind="_config.Transport.EndStation" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">API Base URL</label>
|
||||||
|
<input type="text" class="form-control" @bind="_config.Transport.ApiBaseUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Safety Buffer (minutes)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_config.Transport.SafetyBufferMinutes" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Min Break (minutes)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_config.Transport.MinBreakMinutes" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Max Early Arrival (minutes)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_config.Transport.MaxEarlyArrivalMinutes" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Max Late Arrival (minutes)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_config.Transport.MaxLateArrivalMinutes" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Cache Duration (days)</label>
|
||||||
|
<input type="number" class="form-control" @bind="_config.Transport.CacheDurationDays" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shift Configuration -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6>Shift Configuration</h6>
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="AddShift">Add Shift</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (int i = 0; i < _config.Shifts.Count; i++)
|
||||||
|
{
|
||||||
|
var index = i; // Capture for closure
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_config.Shifts[index].Name" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Icon</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_config.Shifts[index].Icon" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Morning Time</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_config.Shifts[index].MorningTime" placeholder="HH:mm-HH:mm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Afternoon Time</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_config.Shifts[index].AfternoonTime" placeholder="HH:mm-HH:mm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger ms-2" @onclick="() => RemoveShift(index)">
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
@_errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_successMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
@_successMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_isLoading">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (IsVisible)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal {
|
||||||
|
z-index: 1055;
|
||||||
|
}
|
||||||
|
.modal-backdrop {
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@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<ApplicationConfiguration>(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<string>("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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
@page "/"
|
@page "/"
|
||||||
|
@using ShiftScheduler.Client.Components
|
||||||
|
|
||||||
<div class="top-nav-buttons">
|
<div class="top-nav-buttons">
|
||||||
<button class="reset-btn" @onclick="ResetSchedule" disabled="@_isLoadingInitial">Reset Schedule</button>
|
<button class="reset-btn" @onclick="ResetSchedule" disabled="@_isLoadingInitial">Reset Schedule</button>
|
||||||
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@_isLoadingInitial">Export to ICS</button>
|
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@_isLoadingInitial">Export to ICS</button>
|
||||||
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@_isLoadingInitial">Export to PDF</button>
|
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@_isLoadingInitial">Export to PDF</button>
|
||||||
|
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@_isLoadingInitial">⚙️ Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Shift Scheduler - @CurrentMonthYear</h3>
|
<h3>Shift Scheduler - @CurrentMonthYear</h3>
|
||||||
|
|
@ -67,3 +69,7 @@ else
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<ConfigurationDialog IsVisible="_showConfigDialog"
|
||||||
|
OnClose="HideConfiguration"
|
||||||
|
OnConfigurationChanged="OnConfigurationChanged" />
|
||||||
|
|
|
||||||
|
|
@ -259,5 +259,26 @@ namespace ShiftScheduler.Client.Pages
|
||||||
|
|
||||||
return string.Join(" | ", times);
|
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<List<Shift>>("api/shift/shifts") ?? new();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,24 @@
|
||||||
border-color: #FF69B4;
|
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 - separate from export buttons */
|
||||||
.reset-section {
|
.reset-section {
|
||||||
margin: 30px 0 20px 0;
|
margin: 30px 0 20px 0;
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,18 @@ window.downloadFile = (filename, contentType, base64Data) => {
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
73
Server/Controllers/ConfigurationController.cs
Normal file
73
Server/Controllers/ConfigurationController.cs
Normal file
|
|
@ -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<IActionResult> 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<IActionResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,41 +8,39 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ShiftController : ControllerBase
|
public class ShiftController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ShiftService _shiftService;
|
|
||||||
private readonly IcsExportService _icsService;
|
private readonly IcsExportService _icsService;
|
||||||
private readonly PdfExportService _pdfExportService;
|
private readonly PdfExportService _pdfExportService;
|
||||||
private readonly ITransportService _transportService;
|
private readonly ITransportService _transportService;
|
||||||
private readonly TransportConfiguration _transportConfig;
|
private readonly IConfigurationService _configurationService;
|
||||||
|
|
||||||
public ShiftController(
|
public ShiftController(
|
||||||
ShiftService shiftService,
|
|
||||||
IcsExportService icsService,
|
IcsExportService icsService,
|
||||||
PdfExportService pdfExportService,
|
PdfExportService pdfExportService,
|
||||||
ITransportService transportService,
|
ITransportService transportService,
|
||||||
TransportConfiguration transportConfig)
|
IConfigurationService configurationService)
|
||||||
{
|
{
|
||||||
_shiftService = shiftService;
|
|
||||||
_icsService = icsService;
|
_icsService = icsService;
|
||||||
_pdfExportService = pdfExportService;
|
_pdfExportService = pdfExportService;
|
||||||
_transportService = transportService;
|
_transportService = transportService;
|
||||||
_transportConfig = transportConfig;
|
_configurationService = configurationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("shifts")]
|
[HttpGet("shifts")]
|
||||||
public IActionResult GetShifts()
|
public IActionResult GetShifts()
|
||||||
{
|
{
|
||||||
return Ok(_shiftService.GetShifts());
|
return Ok(_configurationService.GetShifts());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("shift_transport")]
|
[HttpPost("shift_transport")]
|
||||||
public async Task<IActionResult> GetShiftTransport([FromBody] ShiftTransportRequest request)
|
public async Task<IActionResult> 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)
|
if (shift == null)
|
||||||
{
|
{
|
||||||
return NotFound($"Shift '{request.ShiftName}' not found");
|
return NotFound($"Shift '{request.ShiftName}' not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var transportConfig = _configurationService.GetTransportConfiguration();
|
||||||
TransportConnection? morningTransport = null;
|
TransportConnection? morningTransport = null;
|
||||||
TransportConnection? afternoonTransport = null;
|
TransportConnection? afternoonTransport = null;
|
||||||
|
|
||||||
|
|
@ -71,7 +69,7 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
if (morningEndTime.HasValue && afternoonStartTime.HasValue)
|
if (morningEndTime.HasValue && afternoonStartTime.HasValue)
|
||||||
{
|
{
|
||||||
var breakDurationMinutes = (afternoonStartTime.Value - morningEndTime.Value).TotalMinutes;
|
var breakDurationMinutes = (afternoonStartTime.Value - morningEndTime.Value).TotalMinutes;
|
||||||
shouldLoadAfternoonTransport = breakDurationMinutes >= _transportConfig.MinBreakMinutes;
|
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@ var builder = WebApplication.CreateBuilder(args);
|
||||||
var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new();
|
var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new();
|
||||||
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
|
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
|
||||||
|
|
||||||
|
// Create application configuration
|
||||||
|
var appConfiguration = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
Transport = transportConfig,
|
||||||
|
Shifts = shifts
|
||||||
|
};
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
builder.Services.AddSingleton(shifts);
|
builder.Services.AddSingleton<IConfigurationService>(new ConfigurationService(appConfiguration));
|
||||||
builder.Services.AddSingleton(transportConfig);
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpClient<TransportApiService>();
|
builder.Services.AddHttpClient<TransportApiService>();
|
||||||
builder.Services.AddSingleton<ShiftService>();
|
|
||||||
builder.Services.AddSingleton<IcsExportService>();
|
builder.Services.AddSingleton<IcsExportService>();
|
||||||
builder.Services.AddSingleton<PdfExportService>();
|
builder.Services.AddSingleton<PdfExportService>();
|
||||||
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
||||||
|
|
|
||||||
111
Services/ConfigurationService.cs
Normal file
111
Services/ConfigurationService.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
using ShiftScheduler.Shared;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ShiftScheduler.Services
|
||||||
|
{
|
||||||
|
public interface IConfigurationService
|
||||||
|
{
|
||||||
|
ApplicationConfiguration GetConfiguration();
|
||||||
|
void UpdateConfiguration(ApplicationConfiguration configuration);
|
||||||
|
Task<string> ExportConfigurationAsync();
|
||||||
|
Task ImportConfigurationAsync(string jsonContent);
|
||||||
|
List<Shift> 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<string> 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<ApplicationConfiguration>(jsonContent);
|
||||||
|
if (configuration != null)
|
||||||
|
{
|
||||||
|
UpdateConfiguration(configuration);
|
||||||
|
}
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Shift> GetShifts()
|
||||||
|
{
|
||||||
|
return GetConfiguration().Shifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransportConfiguration GetTransportConfiguration()
|
||||||
|
{
|
||||||
|
return GetConfiguration().Transport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
|
|
||||||
using ShiftScheduler.Shared;
|
|
||||||
|
|
||||||
namespace ShiftScheduler.Services
|
|
||||||
{
|
|
||||||
public class ShiftService
|
|
||||||
{
|
|
||||||
private readonly List<Shift> _shifts;
|
|
||||||
|
|
||||||
public ShiftService(List<Shift> shifts)
|
|
||||||
{
|
|
||||||
_shifts = shifts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Shift> GetShifts() => _shifts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,12 @@ using ShiftScheduler.Shared;
|
||||||
|
|
||||||
namespace ShiftScheduler.Services
|
namespace ShiftScheduler.Services
|
||||||
{
|
{
|
||||||
public class TransportApiService(HttpClient httpClient, TransportConfiguration config) : ITransportApiService
|
public class TransportApiService(HttpClient httpClient, IConfigurationService configurationService) : ITransportApiService
|
||||||
{
|
{
|
||||||
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
||||||
{
|
{
|
||||||
|
var config = configurationService.GetTransportConfiguration();
|
||||||
|
|
||||||
// To allow connections that arrive after shift starts, we search from earlier time
|
// To allow connections that arrive after shift starts, we search from earlier time
|
||||||
// and request more connections to cover the full range
|
// and request more connections to cover the full range
|
||||||
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
|
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,16 @@ using ShiftScheduler.Shared;
|
||||||
|
|
||||||
namespace ShiftScheduler.Services
|
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<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
||||||
{
|
{
|
||||||
|
var config = configurationService.GetTransportConfiguration();
|
||||||
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
|
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
|
||||||
var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm");
|
var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm");
|
||||||
|
|
||||||
// Generate cache key based on request parameters
|
// Generate cache key based on request parameters
|
||||||
var cacheKey = GenerateCacheKey(searchDate, searchTime);
|
var cacheKey = GenerateCacheKey(config, searchDate, searchTime);
|
||||||
|
|
||||||
// Try to get from cache first
|
// Try to get from cache first
|
||||||
if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null)
|
if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null)
|
||||||
|
|
@ -35,7 +36,7 @@ namespace ShiftScheduler.Services
|
||||||
return connection;
|
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}";
|
return $"transport_{config.StartStation}_{config.EndStation}_{searchDate}_{searchTime}";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
Shared/Configuration.cs
Normal file
8
Shared/Configuration.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace ShiftScheduler.Shared
|
||||||
|
{
|
||||||
|
public class ApplicationConfiguration
|
||||||
|
{
|
||||||
|
public TransportConfiguration Transport { get; set; } = new();
|
||||||
|
public List<Shift> Shifts { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ namespace ShiftScheduler.Services.Tests;
|
||||||
public class TransportApiServiceTests
|
public class TransportApiServiceTests
|
||||||
{
|
{
|
||||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||||
|
private readonly Mock<IConfigurationService> _configurationServiceMock;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly TransportApiService _transportApiService;
|
private readonly TransportApiService _transportApiService;
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ public class TransportApiServiceTests
|
||||||
{
|
{
|
||||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||||
_httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
_httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||||
|
_configurationServiceMock = new Mock<IConfigurationService>();
|
||||||
|
|
||||||
var config = new TransportConfiguration
|
var config = new TransportConfiguration
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +33,11 @@ public class TransportApiServiceTests
|
||||||
CacheDurationDays = 1
|
CacheDurationDays = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
_transportApiService = new TransportApiService(_httpClient, config);
|
_configurationServiceMock
|
||||||
|
.Setup(x => x.GetTransportConfiguration())
|
||||||
|
.Returns(config);
|
||||||
|
|
||||||
|
_transportApiService = new TransportApiService(_httpClient, _configurationServiceMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ namespace ShiftScheduler.Services.Tests;
|
||||||
public class TransportServiceTests
|
public class TransportServiceTests
|
||||||
{
|
{
|
||||||
private readonly Mock<ITransportApiService> _transportServiceMock;
|
private readonly Mock<ITransportApiService> _transportServiceMock;
|
||||||
|
private readonly Mock<IConfigurationService> _configurationServiceMock;
|
||||||
private readonly IMemoryCache _memoryCache;
|
private readonly IMemoryCache _memoryCache;
|
||||||
private readonly TransportService _transportService;
|
private readonly TransportService _transportService;
|
||||||
private readonly TransportConfiguration _config;
|
private readonly TransportConfiguration _config;
|
||||||
|
|
@ -16,6 +17,7 @@ public class TransportServiceTests
|
||||||
public TransportServiceTests()
|
public TransportServiceTests()
|
||||||
{
|
{
|
||||||
_transportServiceMock = new Mock<ITransportApiService>();
|
_transportServiceMock = new Mock<ITransportApiService>();
|
||||||
|
_configurationServiceMock = new Mock<IConfigurationService>();
|
||||||
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
|
||||||
_config = new TransportConfiguration
|
_config = new TransportConfiguration
|
||||||
|
|
@ -30,7 +32,11 @@ public class TransportServiceTests
|
||||||
CacheDurationDays = 1
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue