Files
GmRelayBot/src/GmRelay.Web/Components/Pages/GroupDetails.razor
T
Toutsu a8f2b10956
Deploy Telegram Bot / build-and-push (push) Successful in 3m36s
Deploy Telegram Bot / deploy (push) Successful in 11s
feat: send personal player notifications
2026-04-27 10:11:11 +03:00

470 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/group/{GroupId:guid}"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Сессии группы — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Сессии группы</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📅 Предстоящие игры</h2>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (sessions == null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%;"></div>
</div>
}
else if (sessions.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🎯</div>
<div class="empty-state-title">Нет предстоящих сессий</div>
<p class="empty-state-text">Для этой группы пока не запланировано игровых сессий.</p>
</div>
</div>
}
else
{
<div class="batch-bulk-grid animate-slide-up">
@foreach (var batch in batchModels)
{
<div class="batch-bulk-card">
<div class="batch-bulk-header">
<div>
<h3>@batch.Title</h3>
<p>@FormatBatchSummary(batch)</p>
</div>
<span class="status-badge status-info">Batch</span>
</div>
<EditForm Model="@batch" OnValidSubmit="@(() => UpdateBatchDetails(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Общее название</label>
<InputText @bind-Value="batch.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Общая ссылка</label>
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления игрокам</label>
<select @bind="batch.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
</button>
</EditForm>
<div class="batch-bulk-divider"></div>
<EditForm Model="@batch" OnValidSubmit="@(() => RescheduleBatch(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Первая дата пачки (МСК, UTC+3)</label>
<input type="datetime-local" @bind="batch.FirstScheduledAtLocal" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Шаг между играми, дней</label>
<InputNumber @bind-Value="batch.IntervalDays" class="gm-form-control" min="1" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-outline" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Переносим..." : "📅 Перенести пачку")
</button>
</EditForm>
<div class="batch-clone-row">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
<option value="month">Следующий месяц</option>
</select>
<button type="button" class="btn-gm btn-gm-success" disabled="@IsBatchBusy(batch)" @onclick="() => CloneBatch(batch)">
@(IsBatchBusy(batch) ? "⏳ Клонируем..." : "🧬 Клонировать")
</button>
</div>
</div>
}
</div>
@* Desktop table *@
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
<table class="gm-table">
<thead>
<tr>
<th>Название</th>
<th>Время (МСК)</th>
<th>Места</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
@foreach (var session in sessions)
{
<tr>
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
<td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td>
<td>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td>
<td>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer"
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
Подключиться ↗
</a>
</td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
✏️ Изменить
</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@* Mobile cards *@
<div class="session-card-mobile stagger-children">
@foreach (var session in sessions)
{
<div class="session-card">
<div class="session-card-header">
<span class="session-card-title">@session.Title</span>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</div>
<div class="session-card-body">
<div class="session-card-row">
<span>🕐 Время</span>
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
</div>
<div class="session-card-row">
<span>👥 Места</span>
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
</div>
<div class="session-card-row">
<span>🔗 Ссылка</span>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
</div>
</div>
<div class="session-card-actions">
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить
</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div>
</div>
}
</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private List<BatchBulkEditModel> batchModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private long telegramId;
private string? errorMessage;
private string? successMessage;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
await LoadSessions();
}
private async Task LoadSessions()
{
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels();
}
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
successMessage = null;
promotingSessionId = sessionId;
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
promotingSessionId = null;
}
}
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (!ValidateBatchDetails(batch))
{
errorMessage = "Название и ссылка для batch не должны быть пустыми.";
return;
}
processingBatchId = batch.BatchId;
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task RescheduleBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (batch.IntervalDays <= 0)
{
errorMessage = "Шаг между играми должен быть больше 0 дней.";
return;
}
processingBatchId = batch.BatchId;
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось перенести пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task CloneBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
processingBatchId = batch.BatchId;
try
{
var interval = batch.CloneInterval == "month"
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось клонировать пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private void RebuildBatchModels()
{
batchModels = sessions?
.GroupBy(session => session.BatchId)
.Select(group =>
{
var orderedSessions = group.OrderBy(session => session.ScheduledAt).ToList();
var firstSession = orderedSessions[0];
var lastSession = orderedSessions[^1];
return new BatchBulkEditModel
{
BatchId = group.Key,
Title = firstSession.Title,
JoinLink = firstSession.JoinLink,
NotificationMode = firstSession.NotificationMode,
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
batch.JoinLink = batch.JoinLink.Trim();
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
}
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
{
return 7;
}
var days = (orderedSessions[1].ScheduledAt - orderedSessions[0].ScheduledAt).TotalDays;
return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero));
}
private static bool CanPromote(WebSession session) =>
session.WaitlistedPlayerCount > 0 &&
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
private static string FormatSeats(WebSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
return session.WaitlistedPlayerCount > 0
? $"{seats} · ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено",
SessionStatus.Cancelled => "Отменено",
_ => status
};
private sealed class BatchBulkEditModel
{
public Guid BatchId { get; init; }
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 7;
public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week";
}
}