diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2d67b79..ec16447 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(git checkout:*)", "Bash(git add:*)", "Bash(git commit:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "WebSearch" ] } } diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 9e9562f..5b1c6c4 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -343,7 +343,7 @@ NavMenu.razor: | ✓ | D4 | Expenses | Expense Pages | 1 Razor | | ✓ | E1 | Days | DayState Fluxor | 4 State-Dateien | | ✓ | E2 | Days | Days List Page | 1 Razor | -| ☐ | E3 | Days | Day Details Page | 1 Razor | +| ✓ | E3 | Days | Day Details Page | 1 Razor | | ☐ | E4 | Days | PersonExpense Management | Components in DayDetails | | ☐ | F1 | Dashboard | Dashboard Page | 1 Razor | | ☐ | F2 | Dashboard | Evaluation Components | 2 Shared Components | diff --git a/src/Koogle.Web/Components/Pages/Days/AddParticipantDialog.razor b/src/Koogle.Web/Components/Pages/Days/AddParticipantDialog.razor new file mode 100644 index 0000000..fe46be6 --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Days/AddParticipantDialog.razor @@ -0,0 +1,62 @@ +@using Koogle.Application.DTOs +@using Koogle.Domain.Enums + + + + Teilnehmer hinzufügen + + + @if (AvailablePersons.Count == 0) + { + Keine weiteren Personen verfügbar + } + else + { + Wählen Sie eine Person aus: + + @foreach (var person in AvailablePersons.OrderByDescending(p => p.PersonStatus == PersonStatus.Member).ThenBy(p => p.Name)) + { + + + + @person.Name[0] + + @person.Name + @if (person.PersonStatus == PersonStatus.Guest) + { + Gast + } + + + } + + } + + + Abbrechen + + Hinzufügen + + + + +@code { + [CascadingParameter] + private IMudDialogInstance? MudDialog { get; set; } + + [Parameter] + public IReadOnlyList AvailablePersons { get; set; } = []; + + [Parameter] + public Guid DayId { get; set; } + + private PersonDto? _selectedPerson; + + private void Cancel() => MudDialog?.Cancel(); + + private void Submit() + { + if (_selectedPerson is null) return; + MudDialog?.Close(DialogResult.Ok(_selectedPerson.Id)); + } +} diff --git a/src/Koogle.Web/Components/Pages/Days/DayDetails.razor b/src/Koogle.Web/Components/Pages/Days/DayDetails.razor new file mode 100644 index 0000000..c1cd9db --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Days/DayDetails.razor @@ -0,0 +1,407 @@ +@page "/days/{DayId:guid}" +@attribute [Authorize(Policy = "ClubViewer")] + +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +@using Fluxor +@using Koogle.Application.DTOs +@using Koogle.Domain.Enums +@using Koogle.Web.Store.DayState +@using Microsoft.AspNetCore.Authorization + +@inject IState DayState +@inject IDispatcher Dispatcher +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject NavigationManager NavigationManager + +Spieltag Details + +@if (DayState.Value.IsLoading && DayState.Value.SelectedDay is null) +{ + +} +else if (DayState.Value.Error is not null) +{ + + @DayState.Value.Error + +} +else if (Day is null) +{ + Spieltag nicht gefunden +} +else +{ + + + + + + + @Day.PostDate.ToString("dddd, dd. MMMM yyyy") + + @GetStatusLabel(Day.Status) + + + + + @if (Day.Status != DayStatus.Closed) + { + + @GetNextStatusLabel(Day.Status) + + } + @if (Day.Status == DayStatus.New) + { + + Löschen + + } + + + + + + + + + + + + + Teilnehmer (@Day.ParticipantCount) + + @if (Day.Status != DayStatus.Closed) + { + + Hinzufügen + + } + + + @if (Day.Participants.Count == 0) + { + Keine Teilnehmer + } + else + { + + @foreach (var participant in Day.Participants.OrderBy(p => p.PersonName)) + { + + + + + @participant.PersonName[0] + + @participant.PersonName + @if (participant.PersonStatus == PersonStatus.Guest) + { + Gast + } + + @if (Day.Status != DayStatus.Closed) + { + + } + + + } + + } + + + + + + + + + Details + + + + + + Status + + + @GetStatusLabel(Day.Status) + + + + + Datum + @Day.PostDate.ToString("dd.MM.yyyy") + + + Teilnehmer + @Day.ParticipantCount + + + Erstellt + @Day.CreatedAt.ToString("dd.MM.yyyy HH:mm") + + @if (Day.ModifiedAt.HasValue) + { + + Geändert + @Day.ModifiedAt.Value.ToString("dd.MM.yyyy HH:mm") + + } + + + + + + Status-Workflow + + + Neu + + + + Gestartet + + + + Abgeschlossen + + + + + + + + + + + Strafen / Kosten + + + Die Verwaltung von Strafen und Kosten wird in Phase E4 implementiert. + + + + +} + +@code { + [Parameter] + public Guid DayId { get; set; } + + private DayDto? Day => DayState.Value.SelectedDay; + + protected override void OnInitialized() + { + base.OnInitialized(); + Dispatcher.Dispatch(new LoadDayDetailsAction(DayId)); + Dispatcher.Dispatch(new LoadAvailablePersonsAction()); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + if (Day?.Id != DayId) + { + Dispatcher.Dispatch(new LoadDayDetailsAction(DayId)); + } + } + + private void ClearError() + { + Dispatcher.Dispatch(new ClearDayErrorAction()); + } + + private void NavigateBack() + { + NavigationManager.NavigateTo("/days"); + } + + private static string GetStatusLabel(DayStatus status) => status switch + { + DayStatus.New => "Neu", + DayStatus.Started => "Gestartet", + DayStatus.Postponed => "Verschoben", + DayStatus.Closed => "Abgeschlossen", + _ => status.ToString() + }; + + private static Color GetStatusColor(DayStatus status) => status switch + { + DayStatus.New => Color.Info, + DayStatus.Started => Color.Warning, + DayStatus.Postponed => Color.Secondary, + DayStatus.Closed => Color.Success, + _ => Color.Default + }; + + private static string GetNextStatusLabel(DayStatus status) => status switch + { + DayStatus.New => "Starten", + DayStatus.Started => "Abschließen", + DayStatus.Postponed => "Fortsetzen", + _ => "" + }; + + private static Color GetNextStatusColor(DayStatus status) => status switch + { + DayStatus.New => Color.Warning, + DayStatus.Started => Color.Success, + DayStatus.Postponed => Color.Warning, + _ => Color.Default + }; + + private static string GetNextStatusIcon(DayStatus status) => status switch + { + DayStatus.New => Icons.Material.Filled.PlayArrow, + DayStatus.Started => Icons.Material.Filled.Done, + DayStatus.Postponed => Icons.Material.Filled.PlayArrow, + _ => Icons.Material.Filled.Help + }; + + private Color GetWorkflowStepColor(DayStatus targetStatus) + { + if (Day is null) return Color.Default; + + var statusOrder = new[] { DayStatus.New, DayStatus.Started, DayStatus.Closed }; + var currentIndex = Array.IndexOf(statusOrder, Day.Status); + var targetIndex = Array.IndexOf(statusOrder, targetStatus); + + if (targetIndex < currentIndex) return Color.Success; // Completed + if (targetIndex == currentIndex) return GetStatusColor(Day.Status); // Current + return Color.Default; // Future + } + + private string GetWorkflowStepIcon(DayStatus targetStatus) + { + if (Day is null) return Icons.Material.Filled.Circle; + + var statusOrder = new[] { DayStatus.New, DayStatus.Started, DayStatus.Closed }; + var currentIndex = Array.IndexOf(statusOrder, Day.Status); + var targetIndex = Array.IndexOf(statusOrder, targetStatus); + + if (targetIndex < currentIndex) return Icons.Material.Filled.Check; // Completed + if (targetIndex == currentIndex) return targetStatus switch + { + DayStatus.Started => Icons.Material.Filled.PlayArrow, + DayStatus.Closed => Icons.Material.Filled.Done, + _ => Icons.Material.Filled.Circle + }; + return Icons.Material.Filled.Circle; // Future + } + + private void AdvanceStatus() + { + if (Day is null) return; + Dispatcher.Dispatch(new AdvanceDayStatusAction(Day.Id)); + Snackbar.Add("Status wird aktualisiert...", Severity.Info); + } + + private async Task ConfirmDelete() + { + if (Day is null) return; + + var parameters = new DialogParameters + { + { "ContentText", $"Möchten Sie den Spieltag vom {Day.PostDate:dd.MM.yyyy} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." }, + { "ButtonText", "Löschen" }, + { "Color", Color.Error } + }; + + var dialog = await DialogService.ShowAsync("Spieltag löschen", parameters); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + Dispatcher.Dispatch(new DeleteDayAction(Day.Id)); + Snackbar.Add("Spieltag wird gelöscht...", Severity.Info); + NavigationManager.NavigateTo("/days"); + } + } + + private async Task OpenAddParticipantDialog() + { + if (Day is null) return; + + var existingIds = Day.Participants.Select(p => p.PersonId).ToHashSet(); + var availablePersons = DayState.Value.AvailablePersons + .Where(p => !existingIds.Contains(p.Id)) + .ToList(); + + if (availablePersons.Count == 0) + { + Snackbar.Add("Alle Personen sind bereits Teilnehmer", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + { "AvailablePersons", availablePersons }, + { "DayId", Day.Id } + }; + + var dialog = await DialogService.ShowAsync("Teilnehmer hinzufügen", parameters); + var result = await dialog.Result; + + if (result != null && !result.Canceled && result.Data is Guid personId) + { + var dto = new AddDayParticipantDto + { + DayId = Day.Id, + PersonId = personId + }; + Dispatcher.Dispatch(new AddDayParticipantAction(dto)); + Snackbar.Add("Teilnehmer wird hinzugefügt...", Severity.Info); + } + } + + private async Task RemoveParticipant(DayParticipantDto participant) + { + if (Day is null) return; + + var parameters = new DialogParameters + { + { "ContentText", $"Möchten Sie \"{participant.PersonName}\" wirklich von diesem Spieltag entfernen?" }, + { "ButtonText", "Entfernen" }, + { "Color", Color.Warning } + }; + + var dialog = await DialogService.ShowAsync("Teilnehmer entfernen", parameters); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var dto = new RemoveDayParticipantDto + { + DayId = Day.Id, + PersonId = participant.PersonId + }; + Dispatcher.Dispatch(new RemoveDayParticipantAction(dto)); + Snackbar.Add("Teilnehmer wird entfernt...", Severity.Info); + } + } +}