470 lines
20 KiB
Plaintext
470 lines
20 KiB
Plaintext
@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";
|
||
}
|
||
}
|