diff --git a/Client/Pages/Index.razor b/Client/Pages/Index.razor index cd3d8a3..9ad264a 100644 --- a/Client/Pages/Index.razor +++ b/Client/Pages/Index.razor @@ -2,10 +2,16 @@ @using ShiftScheduler.Client.Components
- - - - +
+ + +
+
+ + + + +

Shift Scheduler - @CurrentMonthYear

diff --git a/Client/Pages/Index.razor.cs b/Client/Pages/Index.razor.cs index fc11afb..4a55be2 100644 --- a/Client/Pages/Index.razor.cs +++ b/Client/Pages/Index.razor.cs @@ -18,6 +18,7 @@ namespace ShiftScheduler.Client.Pages private Dictionary SelectedShiftsWithTransport { get; set; } = new(); private int EditMonth { get; set; } private int EditYear { get; set; } + private bool _isCurrentMonth = false; private bool _isLoadingTransport = false; private bool _isLoadingInitial = false; private string _errorMessage = string.Empty; @@ -31,9 +32,7 @@ namespace ShiftScheduler.Client.Pages (EditMonth, EditYear) = GetNextMonthAndYear(); - DaysInMonth = Enumerable.Range(1, DateTime.DaysInMonth(EditYear, EditMonth)) - .Select(d => new DateTime(EditYear, EditMonth, d)) - .ToList(); + UpdateDaysInMonth(); Shifts = await HttpClient.GetFromJsonAsync>("api/shift/shifts") ?? new(); @@ -43,6 +42,13 @@ namespace ShiftScheduler.Client.Pages StateHasChanged(); } + private void UpdateDaysInMonth() + { + DaysInMonth = Enumerable.Range(1, DateTime.DaysInMonth(EditYear, EditMonth)) + .Select(d => new DateTime(EditYear, EditMonth, d)) + .ToList(); + } + private (int nextMonth, int year) GetNextMonthAndYear() { var today = DateTime.Today; @@ -51,6 +57,26 @@ namespace ShiftScheduler.Client.Pages return (nextMonth, year); } + private async Task SelectCurrentMonth() + { + var today = DateTime.Today; + EditMonth = today.Month; + EditYear = today.Year; + _isCurrentMonth = true; + UpdateDaysInMonth(); + await LoadScheduleFromStorage(); + StateHasChanged(); + } + + private async Task SelectNextMonth() + { + (EditMonth, EditYear) = GetNextMonthAndYear(); + _isCurrentMonth = false; + UpdateDaysInMonth(); + await LoadScheduleFromStorage(); + StateHasChanged(); + } + private async Task SelectShift(DateTime day, string shiftName) { SelectedSchedule[day] = shiftName; @@ -167,15 +193,14 @@ namespace ShiftScheduler.Client.Pages { try { - var storageKey = $"ShiftSchedule_{EditYear}_{EditMonth:D2}"; + var request = new + { + Year = EditYear, + Month = EditMonth, + Schedule = SelectedSchedule + }; - var serializableSchedule = SelectedSchedule.ToDictionary( - kvp => kvp.Key.ToString("yyyy-MM-dd"), - kvp => kvp.Value - ); - - var json = JsonSerializer.Serialize(serializableSchedule); - await JSRuntime.InvokeVoidAsync("localStorage.setItem", storageKey, json); + await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request); } catch (Exception) { @@ -187,22 +212,19 @@ namespace ShiftScheduler.Client.Pages { try { - var storageKey = $"ShiftSchedule_{EditYear}_{EditMonth:D2}"; - var json = await JSRuntime.InvokeAsync("localStorage.getItem", storageKey); + var response = await HttpClient.GetAsync($"api/shift/load_schedule/{EditYear}/{EditMonth}"); - if (!string.IsNullOrEmpty(json)) + if (response.IsSuccessStatusCode) { - var serializableSchedule = JsonSerializer.Deserialize>(json); - if (serializableSchedule != null) - { - SelectedSchedule = serializableSchedule.ToDictionary( - kvp => DateTime.Parse(kvp.Key), - kvp => kvp.Value - ); - - // Reload transport data for all selected shifts - await ReloadTransportDataForSchedule(); - } + SelectedSchedule = await response.Content.ReadFromJsonAsync>() ?? new(); + + // Reload transport data for all selected shifts + await ReloadTransportDataForSchedule(); + } + else + { + SelectedSchedule = new Dictionary(); + SelectedShiftsWithTransport = new Dictionary(); } } catch (Exception) @@ -259,8 +281,7 @@ namespace ShiftScheduler.Client.Pages try { - var storageKey = $"ShiftSchedule_{EditYear}_{EditMonth:D2}"; - await JSRuntime.InvokeVoidAsync("localStorage.removeItem", storageKey); + await HttpClient.DeleteAsync($"api/shift/delete_schedule/{EditYear}/{EditMonth}"); } catch (Exception) { diff --git a/Client/ShiftScheduler.Client.csproj b/Client/ShiftScheduler.Client.csproj index db3945f..5b25a0c 100644 --- a/Client/ShiftScheduler.Client.csproj +++ b/Client/ShiftScheduler.Client.csproj @@ -1,15 +1,15 @@ - net8.0 + net9.0 enable enable - - - + + + diff --git a/Client/wwwroot/css/app.css b/Client/wwwroot/css/app.css index 8034dcc..da047fa 100644 --- a/Client/wwwroot/css/app.css +++ b/Client/wwwroot/css/app.css @@ -99,3 +99,226 @@ a, .btn-link { .loading-progress-text:after { content: var(--blazor-load-percentage-text, "Loading"); } + +/* Shift Scheduler Specific Styles */ +.top-nav-buttons { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 1rem; +} + +.month-selection { + display: flex; + gap: 0.5rem; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.month-btn { + padding: 0.5rem 1rem; + border: 2px solid #1b6ec2; + background-color: #fff; + color: #1b6ec2; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.month-btn:hover { + background-color: #f8f9fa; +} + +.month-btn.selected { + background-color: #1b6ec2; + color: #fff; +} + +.month-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.day-cell { + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1rem; + background-color: #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.day-header { + font-weight: bold; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #dee2e6; +} + +.shifts { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.shift-button { + padding: 0.25rem 0.5rem; + border: 2px solid transparent; + background-color: #f8f9fa; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 1.2rem; +} + +.shift-button:hover { + background-color: #e9ecef; + border-color: #dee2e6; +} + +.shift-button.selected { + background-color: #1b6ec2; + color: #fff; + border-color: #1861ac; +} + +.shift-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.tooltip-wrapper { + position: relative; + display: inline-block; +} + +.tooltip { + visibility: hidden; + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + white-space: nowrap; + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease; +} + +.tooltip-wrapper:hover .tooltip { + visibility: visible; + opacity: 1; +} + +.shift-times, .transport-info { + font-size: 0.8rem; + color: #6c757d; + margin-top: 0.25rem; +} + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.loading-content { + text-align: center; +} + +.loading-spinner { + font-size: 2rem; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.alert { + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; + position: relative; +} + +.alert-danger { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb; +} + +.btn-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + opacity: 0.5; +} + +.btn-close:hover { + opacity: 1; +} + +.reset-btn { + background-color: #dc3545; + color: #fff; + border: 1px solid #dc3545; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + cursor: pointer; +} + +.reset-btn:hover { + background-color: #c82333; + border-color: #bd2130; +} + +.export-btn, .config-btn { + background-color: #28a745; + color: #fff; + border: 1px solid #28a745; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + cursor: pointer; +} + +.export-btn:hover, .config-btn:hover { + background-color: #218838; + border-color: #1e7e34; +} diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index 7d750f5..a051151 100644 --- a/Server/Controllers/ShiftController.cs +++ b/Server/Controllers/ShiftController.cs @@ -143,6 +143,48 @@ namespace ShiftScheduler.Server.Controllers var pdf = _pdfExportService.GenerateMonthlySchedulePdf(shiftsWithTransport); return File(pdf, "application/pdf", "schedule.pdf"); } + + [HttpPost("save_schedule")] + public async Task SaveSchedule([FromBody] SaveScheduleRequest request) + { + try + { + await _configurationService.SaveScheduleAsync(request.Year, request.Month, request.Schedule); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, $"Error saving schedule: {ex.Message}"); + } + } + + [HttpGet("load_schedule/{year}/{month}")] + public async Task LoadSchedule(int year, int month) + { + try + { + var schedule = await _configurationService.LoadScheduleAsync(year, month); + return Ok(schedule); + } + catch (Exception ex) + { + return StatusCode(500, $"Error loading schedule: {ex.Message}"); + } + } + + [HttpDelete("delete_schedule/{year}/{month}")] + public async Task DeleteSchedule(int year, int month) + { + try + { + await _configurationService.DeleteScheduleAsync(year, month); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, $"Error deleting schedule: {ex.Message}"); + } + } } public class ShiftTransportRequest @@ -150,4 +192,11 @@ namespace ShiftScheduler.Server.Controllers public string ShiftName { get; set; } = string.Empty; public DateTime Date { get; set; } } + + public class SaveScheduleRequest + { + public int Year { get; set; } + public int Month { get; set; } + public Dictionary Schedule { get; set; } = new(); + } } diff --git a/Server/ShiftScheduler.Server.csproj b/Server/ShiftScheduler.Server.csproj index fffbd50..9dd91d8 100644 --- a/Server/ShiftScheduler.Server.csproj +++ b/Server/ShiftScheduler.Server.csproj @@ -1,14 +1,14 @@ - net8.0 + net9.0 enable enable - - + + diff --git a/Services/ConfigurationService.cs b/Services/ConfigurationService.cs index 7aac9d8..03901b5 100644 --- a/Services/ConfigurationService.cs +++ b/Services/ConfigurationService.cs @@ -11,6 +11,11 @@ namespace ShiftScheduler.Services Task ImportConfigurationAsync(string jsonContent); List GetShifts(); TransportConfiguration GetTransportConfiguration(); + + // Schedule persistence methods + Task SaveScheduleAsync(int year, int month, Dictionary schedule); + Task> LoadScheduleAsync(int year, int month); + Task DeleteScheduleAsync(int year, int month); } public class ConfigurationService : IConfigurationService @@ -128,6 +133,75 @@ namespace ShiftScheduler.Services return GetConfiguration().Transport; } + // Schedule persistence methods + public async Task SaveScheduleAsync(int year, int month, Dictionary schedule) + { + try + { + var scheduleFileName = $"schedule-{year:D4}-{month:D2}.json"; + var scheduleFilePath = Path.Combine(_configDirectory, scheduleFileName); + + var serializableSchedule = schedule.ToDictionary( + kvp => kvp.Key.ToString("yyyy-MM-dd"), + kvp => kvp.Value + ); + + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(serializableSchedule, jsonOptions); + await File.WriteAllTextAsync(scheduleFilePath, json); + } + catch + { + // Log error if needed, but don't throw to avoid breaking the application + } + } + + public async Task> LoadScheduleAsync(int year, int month) + { + try + { + var scheduleFileName = $"schedule-{year:D4}-{month:D2}.json"; + var scheduleFilePath = Path.Combine(_configDirectory, scheduleFileName); + + if (!File.Exists(scheduleFilePath)) + return new Dictionary(); + + var json = await File.ReadAllTextAsync(scheduleFilePath); + var serializableSchedule = JsonSerializer.Deserialize>(json); + + if (serializableSchedule == null) + return new Dictionary(); + + return serializableSchedule.ToDictionary( + kvp => DateTime.Parse(kvp.Key), + kvp => kvp.Value + ); + } + catch + { + return new Dictionary(); + } + } + + public async Task DeleteScheduleAsync(int year, int month) + { + try + { + var scheduleFileName = $"schedule-{year:D4}-{month:D2}.json"; + var scheduleFilePath = Path.Combine(_configDirectory, scheduleFileName); + + if (File.Exists(scheduleFilePath)) + { + File.Delete(scheduleFilePath); + } + await Task.CompletedTask; + } + catch + { + // Log error if needed, but don't throw + } + } + private ApplicationConfiguration LoadConfigurationFromFiles(ApplicationConfiguration fallbackConfiguration) { try diff --git a/Services/ShiftScheduler.Services.csproj b/Services/ShiftScheduler.Services.csproj index fbb390b..fb248fc 100644 --- a/Services/ShiftScheduler.Services.csproj +++ b/Services/ShiftScheduler.Services.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable @@ -13,7 +13,7 @@ - + diff --git a/Shared/ShiftScheduler.Shared.csproj b/Shared/ShiftScheduler.Shared.csproj index 9a938be..46f7f7d 100644 --- a/Shared/ShiftScheduler.Shared.csproj +++ b/Shared/ShiftScheduler.Shared.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable diff --git a/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj index 732093c..feb6af5 100644 --- a/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj +++ b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net9.0 enable enable false