Complete configuration UI implementation with working dialog, API endpoints, and file import/export

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-08-25 19:32:44 +00:00
parent 2697a6e421
commit 98fc96af90
9 changed files with 600 additions and 2 deletions

View 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();
}
}

View file

@ -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" />

View file

@ -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();
}
} }
} }

View file

@ -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;

View file

@ -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);
});
};

View 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;
}
}

View file

@ -7,9 +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.AddSingleton<List<Shift>>(provider => provider.GetRequiredService<IConfigurationService>().GetShifts());
builder.Services.AddSingleton<TransportConfiguration>(provider => provider.GetRequiredService<IConfigurationService>().GetTransportConfiguration());
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportApiService>(); builder.Services.AddHttpClient<TransportApiService>();
builder.Services.AddSingleton<ShiftService>(); builder.Services.AddSingleton<ShiftService>();

View 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;
}
}
}

8
Shared/Configuration.cs Normal file
View file

@ -0,0 +1,8 @@
namespace ShiftScheduler.Shared
{
public class ApplicationConfiguration
{
public TransportConfiguration Transport { get; set; } = new();
public List<Shift> Shifts { get; set; } = new();
}
}