@page "/days/{DayId:guid}" @attribute [Authorize(Policy = "ClubViewer")] @inherits Fluxor.Blazor.Web.Components.FluxorComponent @implements IAsyncDisposable @using Fluxor @using Koogle.Application.DTOs @using Koogle.Domain.Enums @using Koogle.Web.Store.DayState @using Koogle.Web.Store.GameState @using Koogle.Web.Store.GifState @using Koogle.Web.Store.PersonState @using Koogle.Web.Store.TimerState @using Koogle.Web.Components.Game @using Koogle.Web.Hubs @using Koogle.Web.Services @using Microsoft.AspNetCore.Authorization @inject IState DayState @inject IState GameState @inject IState GifState @inject IState PersonState @inject IState TimerState @inject IDispatcher Dispatcher @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager NavigationManager @inject GameHubService HubService 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.Started && !GameState.Value.IsGameActive) { Neues Spiel } @if (GameState.Value.IsGameActive) { Spiel beenden } @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)) { var isSelected = _selectedParticipantId == participant.PersonId; @participant.PersonName[0] @participant.PersonName @if (participant.PersonStatus == PersonStatus.Guest) { Gast } @if (Day.Status != DayStatus.Closed) { } } @if (_selectedParticipantId.HasValue) { @SelectedParticipant?.PersonName ausgewählt } } Strafen / Kosten (@FilteredExpenses.Count@(_selectedParticipantId.HasValue && Expenses.Count != FilteredExpenses.Count ? $"/{Expenses.Count}" : "")) @if (Day.Status != DayStatus.Closed) { @if (OneClickExpenses.Count > 0 && _selectedParticipantId.HasValue) { @foreach (var expense in OneClickExpenses.OrderBy(e => e.Name)) { @expense.Name @expense.Price.ToString("C") } } Strafe hinzufügen } @if (DayState.Value.IsLoadingExpenses) { } @if (Day.Participants.Count == 0) { Füge zuerst Teilnehmer hinzu, um Strafen zuweisen zu können. } else if (FilteredExpenses.Count == 0) { @if (_selectedParticipantId.HasValue) { Keine Strafen für @SelectedParticipant?.PersonName } else { Keine Strafen zugewiesen } } else { Gesamt @TotalExpenseAmount.ToString("C") Offen @OpenExpenseAmount.ToString("C") Bezahlt @PaidExpenseAmount.ToString("C") @if (!_selectedParticipantId.HasValue) { Person } Strafe Uhrzeit Preis Status Aktionen @if (!_selectedParticipantId.HasValue) { @context.PersonName[0] @context.PersonName } @context.Name @context.CreatedAt.ToString("HH:mm") @context.Price.ToString("C") @(context.PersonExpenseStatus == PersonExpenseStatus.Open ? "Offen" : "Bezahlt") @if (context.PersonExpenseStatus == PersonExpenseStatus.Open) { } else { } @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 @if (TimerState.Value.IsRunning) {
}
} @code { [Parameter] public Guid DayId { get; set; } private DayDto? Day => DayState.Value.SelectedDay; private IReadOnlyList Expenses => DayState.Value.SelectedDayExpenses; private Guid? _selectedParticipantId; private int _activeTabIndex = 0; private DayParticipantDto? SelectedParticipant => Day?.Participants.FirstOrDefault(p => p.PersonId == _selectedParticipantId); private IReadOnlyList OneClickExpenses => DayState.Value.AvailableExpenses.Where(e => e.IsOneClick && !e.IsVariable).ToList(); private IReadOnlyList FilteredExpenses => (_selectedParticipantId.HasValue ? Expenses.Where(e => e.PersonId == _selectedParticipantId.Value) : Expenses).OrderByDescending(e => e.CreatedAt).ToList(); private decimal TotalExpenseAmount => FilteredExpenses.Sum(e => e.Price); private decimal OpenExpenseAmount => FilteredExpenses.Where(e => e.PersonExpenseStatus == PersonExpenseStatus.Open).Sum(e => e.Price); private decimal PaidExpenseAmount => FilteredExpenses.Where(e => e.PersonExpenseStatus == PersonExpenseStatus.Done).Sum(e => e.Price); protected override void OnInitialized() { base.OnInitialized(); Dispatcher.Dispatch(new LoadDayDetailsAction(DayId)); Dispatcher.Dispatch(new LoadAvailablePersonsAction()); Dispatcher.Dispatch(new LoadDayExpensesAction(DayId)); Dispatcher.Dispatch(new LoadAvailableExpensesAction()); Dispatcher.Dispatch(new LoadActiveGameAction(DayId)); } protected override void OnParametersSet() { base.OnParametersSet(); if (Day?.Id != DayId) { Dispatcher.Dispatch(new LoadDayDetailsAction(DayId)); Dispatcher.Dispatch(new LoadDayExpensesAction(DayId)); Dispatcher.Dispatch(new LoadActiveGameAction(DayId)); } } private void ClearError() { Dispatcher.Dispatch(new ClearDayErrorAction()); } private void ToggleParticipantSelection(Guid personId) { _selectedParticipantId = _selectedParticipantId == personId ? null : personId; } private void ClearParticipantSelection() { _selectedParticipantId = null; } 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 => "Spieltag starten", DayStatus.Started => "Spieltag abschliessen", DayStatus.Postponed => "Spieltag 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 async Task AdvanceStatus() { if (Day is null) return; if (Day.Status == DayStatus.Started ) { var res = await DialogService.ShowMessageBox("Tag abschließen?", "Spieltag wirklich beenden?", yesText: "Ja", noText: "Nein"); if (res.HasValue && !res.Value) { 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öchtest du 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 List personIds) { foreach (var personId in personIds) { var dto = new AddDayParticipantDto { DayId = Day.Id, PersonId = personId }; Dispatcher.Dispatch(new AddDayParticipantAction(dto)); } Snackbar.Add($"{personIds.Count} Teilnehmer werden hinzugefügt...", Severity.Info); } } private async Task RemoveParticipant(DayParticipantDto participant) { if (Day is null) return; var parameters = new DialogParameters { { "ContentText", $"Möchtest du \"{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); } } // PersonExpense Methods private async Task OpenAddExpenseDialog() { if (Day is null) return; var availableExpenses = DayState.Value.AvailableExpenses; if (availableExpenses.Count == 0) { Snackbar.Add("Keine Strafen-Vorlagen definiert. Bitte erstelle zuerst Vorlagen unter \"Strafen\".", Severity.Warning); return; } var parameters = new DialogParameters { { "AvailableExpenses", availableExpenses }, { "Participants", Day.Participants }, { "DayId", Day.Id }, { "PreselectedPersonId", _selectedParticipantId } }; var dialog = await DialogService.ShowAsync("Strafe hinzufügen", parameters); var result = await dialog.Result; if (result != null && !result.Canceled && result.Data is not null) { if (result.Data is CreateInversePersonExpenseDto inverseDto) { Dispatcher.Dispatch(new CreateInversePersonExpenseAction(inverseDto)); Snackbar.Add("Inverse Strafe wird erstellt...", Severity.Info); } else if (result.Data is CreatePersonExpenseDto createDto) { Dispatcher.Dispatch(new CreatePersonExpenseAction(createDto)); Snackbar.Add("Strafe wird hinzugefügt...", Severity.Info); } } } private void QuickAssignExpense(ExpenseDto expense) { if (Day is null || !_selectedParticipantId.HasValue) return; if (expense.IsInverse) { var dto = new CreateInversePersonExpenseDto { ExcludedPersonId = _selectedParticipantId.Value, ExpenseId = expense.Id, DayId = Day.Id, GameId = null }; Dispatcher.Dispatch(new CreateInversePersonExpenseAction(dto)); Snackbar.Add($"{expense.Name} für alle außer {SelectedParticipant?.PersonName}...", Severity.Info); } else { var dto = new CreatePersonExpenseDto { PersonId = _selectedParticipantId.Value, ExpenseId = expense.Id, DayId = Day.Id, GameId = null, Price = null }; Dispatcher.Dispatch(new CreatePersonExpenseAction(dto)); Snackbar.Add($"{expense.Name} für {SelectedParticipant?.PersonName}...", Severity.Info); } } private void MarkAsPaid(PersonExpenseDto expense) { var dto = new UpdatePersonExpenseStatusDto { Id = expense.Id, Status = PersonExpenseStatus.Done }; Dispatcher.Dispatch(new UpdatePersonExpenseStatusAction(dto)); Snackbar.Add("Status wird aktualisiert...", Severity.Info); } private void MarkAsOpen(PersonExpenseDto expense) { var dto = new UpdatePersonExpenseStatusDto { Id = expense.Id, Status = PersonExpenseStatus.Open }; Dispatcher.Dispatch(new UpdatePersonExpenseStatusAction(dto)); Snackbar.Add("Status wird aktualisiert...", Severity.Info); } private async Task ConfirmDeleteExpense(PersonExpenseDto expense) { if (Day is null) return; var parameters = new DialogParameters { { "ContentText", $"Möchtest du die Strafe \"{expense.Name}\" für \"{expense.PersonName}\" wirklich löschen?" }, { "ButtonText", "Löschen" }, { "Color", Color.Error } }; var dialog = await DialogService.ShowAsync("Strafe löschen", parameters); var result = await dialog.Result; if (result != null && !result.Canceled) { Dispatcher.Dispatch(new DeletePersonExpenseAction(expense.Id, Day.Id)); Snackbar.Add("Strafe wird gelöscht...", Severity.Info); } } // Game-related methods private async Task OpenGameSetupDialog() { if (Day is null) return; var parameters = new DialogParameters { { "DayId", Day.Id } }; var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseOnEscapeKey = true }; var dialog = await DialogService.ShowAsync("Neues Spiel starten", parameters, options); var result = await dialog.Result; if (result != null && !result.Canceled) { // Game started - switch to Eingabe tab _activeTabIndex = 1; Snackbar.Add("Spiel gestartet!", Severity.Success); } } private async Task EndGame() { var res = await DialogService.ShowMessageBox("Spiel beenden", "Spiel wirklich beenden?", yesText:"Ja", noText:"Nein"); if (res.HasValue && res.Value) { Dispatcher.Dispatch(new EndGameAction(GameStatus.Completed)); _activeTabIndex = 0; // Switch back to Details tab Snackbar.Add("Spiel wird beendet...", Severity.Info); } } private string? GetCurrentPlayerName() { var playerId = GameState.Value.Participants.CurrentPlayerId; if (playerId is null) return null; return GetPlayerName(playerId.Value); } private string GetPlayerName(Guid personId) { // First try to get from PersonState var person = PersonState.Value.Persons.FirstOrDefault(p => p.Id == personId); if (person != null) return person.Name; // Fallback to day participants var participant = Day?.Participants.FirstOrDefault(p => p.PersonId == personId); return participant?.PersonName ?? "Unbekannt"; } private async Task ShowPlayerSelector() { if (!GameState.Value.IsGameActive) return; var parameters = new DialogParameters { { "PlayerIds", GameState.Value.Participants.PlayerIds }, { "CurrentPlayerId", GameState.Value.Participants.CurrentPlayerId }, { "PlayerNameResolver", (Func)GetPlayerName }, { "GameModel", GameState.Value.GameModel }, { "FilterByGameLogic", true } }; var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true }; var dialog = await DialogService.ShowAsync("Spieler auswählen", parameters, options); var result = await dialog.Result; if (result != null && !result.Canceled && result.Data is Guid selectedPlayerId) { Dispatcher.Dispatch(new SetCurrentPlayerAction(selectedPlayerId)); Snackbar.Add($"Spieler gewechselt zu {GetPlayerName(selectedPlayerId)}", Severity.Success); } } private const int TimerDurationSeconds = 3; private Task HandleThrowCompleted(ThrowResult result) { // Switch to Tafel tab and start timer _activeTabIndex = 2; Dispatcher.Dispatch(new StartTimerAction(TimerDurationSeconds)); return Task.CompletedTask; } private void HandleTimerAborted() { // User clicked timer button - switch back to Eingabe tab _activeTabIndex = 1; } protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (firstRender) { TimerState.StateChanged += OnTimerStateChanged; GameState.StateChanged += OnGameStateChanged; // Subscribe to SignalR events HubService.OnGameStateUpdated += HandleHubGameStateUpdated; HubService.OnThrowRecorded += HandleHubThrowRecorded; HubService.OnGameStarted += HandleHubGameStarted; HubService.OnGameEnded += HandleHubGameEnded; HubService.OnGifTriggered += HandleHubGifTriggered; // Start SignalR connection and join day group await HubService.StartAsync(); await HubService.JoinDayAsync(DayId); // If there's an active game, join its group too if (GameState.Value.ActiveGameId.HasValue) { await HubService.JoinGameAsync(GameState.Value.ActiveGameId.Value); _previousGameId = GameState.Value.ActiveGameId.Value; } } } private void OnTimerStateChanged(object? sender, EventArgs e) { // When timer stops (completed naturally), switch back to Eingabe tab if (!TimerState.Value.IsRunning && _activeTabIndex == 2) { _activeTabIndex = 1; InvokeAsync(StateHasChanged); } } private Guid? _previousGameId; private async void OnGameStateChanged(object? sender, EventArgs e) { var currentGameId = GameState.Value.ActiveGameId; // Game started - join game group if (currentGameId.HasValue && _previousGameId != currentGameId) { if (_previousGameId.HasValue) { await HubService.LeaveGameAsync(_previousGameId.Value); } await HubService.JoinGameAsync(currentGameId.Value); _previousGameId = currentGameId; } // Game ended - leave game group else if (!currentGameId.HasValue && _previousGameId.HasValue) { await HubService.LeaveGameAsync(_previousGameId.Value); _previousGameId = null; } } // SignalR event handlers private void HandleHubGameStateUpdated(GameStateSerializationDto state) { // Dispatch action to update local state from remote Dispatcher.Dispatch(new RemoteGameStateUpdatedAction(state)); InvokeAsync(StateHasChanged); } private void HandleHubThrowRecorded(Guid gameId, Guid playerId, int pinsKnocked) { // Throw was recorded by another client - refresh state Snackbar.Add($"Wurf empfangen: {pinsKnocked} Pins", Severity.Info); InvokeAsync(StateHasChanged); } private void HandleHubGameStarted(Guid gameId, string gameTypeName) { // New game started - reload active game Dispatcher.Dispatch(new LoadActiveGameAction(DayId)); Snackbar.Add($"Neues Spiel gestartet: {gameTypeName}", Severity.Success); InvokeAsync(StateHasChanged); } private void HandleHubGameEnded(GameResultDto result) { // Game ended - reload completed games Dispatcher.Dispatch(new LoadCompletedGamesAction(DayId)); var message = result.WinnerName != null ? $"Spiel beendet! Gewinner: {result.WinnerName}" : "Spiel beendet!"; Snackbar.Add(message, Severity.Success); _activeTabIndex = 0; InvokeAsync(StateHasChanged); } private void HandleHubGifTriggered(GifPlaybackDto gif, ThrowEventType eventType) { // GIF triggered from another client - dispatch action to show it Dispatcher.Dispatch(new GifPlaybackStartedAction(gif, eventType)); InvokeAsync(StateHasChanged); } public new async ValueTask DisposeAsync() { TimerState.StateChanged -= OnTimerStateChanged; GameState.StateChanged -= OnGameStateChanged; // Unsubscribe from SignalR events HubService.OnGameStateUpdated -= HandleHubGameStateUpdated; HubService.OnThrowRecorded -= HandleHubThrowRecorded; HubService.OnGameStarted -= HandleHubGameStarted; HubService.OnGameEnded -= HandleHubGameEnded; HubService.OnGifTriggered -= HandleHubGifTriggered; // Leave groups and stop hub connection if (_previousGameId.HasValue) { await HubService.LeaveGameAsync(_previousGameId.Value); } await HubService.LeaveDayAsync(DayId); await HubService.DisposeAsync(); await base.DisposeAsync(); } }