@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 Сессии группы — GM-Relay
@if (groupManagement is not null) {

Управление группой

@groupManagement.Group.Name · @FormatRole(CurrentUserRole)

@FormatRole(CurrentUserRole)
@foreach (var manager in groupManagement.Managers) { @FormatManager(manager) @if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue) { } }
@if (groupManagement.CurrentUserIsOwner) {
}
} @if (!string.IsNullOrEmpty(errorMessage)) {
⚠️ @errorMessage
} @if (!string.IsNullOrEmpty(successMessage)) {
✅ @successMessage
} @if (campaignTemplates is not null) {

Применить шаблон

@campaignTemplateModels.Count доступных для этой группы

⚙️ Управлять шаблонами
@if (campaignTemplateModels.Count == 0) {
Шаблонов пока нет

Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.

} else {
@foreach (var template in campaignTemplateModels) {

@template.Name

@FormatTemplateSummary(template)

}
}
} @if (sessions == null) {
} else if (sessions.Count == 0) {
🎯
Нет предстоящих сессий

Для этой группы пока не запланировано игровых сессий.

} else {
@foreach (var batch in batchModels) {

@batch.Title

@FormatBatchSummary(batch)

Batch
}
@* Desktop table *@
@foreach (var session in sessions) { }
Название Время (МСК) Места Статус Ссылка Действие
@session.Title @session.ScheduledAt.FormatMoscow() @FormatSeats(session) @TranslateStatus(session.Status) Подключиться ↗
✏️ Изменить @if (CanPromote(session)) { }
@* Mobile cards *@
@foreach (var session in sessions) {
@session.Title @TranslateStatus(session.Status)
🕐 Время @session.ScheduledAt.FormatMoscow()
👥 Места @FormatSeats(session)
🔗 Ссылка Подключиться ↗
✏️ Изменить @if (CanPromote(session)) { }
}
}
@code { [Parameter] public Guid GroupId { get; set; } private List? sessions; private List? campaignTemplates; private WebGroupManagement? groupManagement; private List batchModels = []; private List campaignTemplateModels = []; private Guid? promotingSessionId; private Guid? processingBatchId; private Guid? processingTemplateId; private long? removingCoGmId; private bool isAddingCoGm; private long telegramId; private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); 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() { groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId); if (groupManagement is null) { Navigation.NavigateTo("/access-denied"); return; } sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); if (sessions is null) { Navigation.NavigateTo("/access-denied"); return; } campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId); if (campaignTemplates is null) { Navigation.NavigateTo("/access-denied"); return; } RebuildBatchModels(); RebuildCampaignTemplateModels(); } private async Task AddCoGm() { errorMessage = null; successMessage = null; if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0) { errorMessage = "Telegram ID co-GM должен быть положительным числом."; return; } isAddingCoGm = true; try { await SessionService.AddCoGmForOwnerAsync( GroupId, telegramId, coGmModel.TelegramId.Value, coGmModel.DisplayName, coGmModel.TelegramUsername); coGmModel = new(); successMessage = "Co-GM добавлен."; await LoadSessions(); } catch (SessionAccessDeniedException) { Navigation.NavigateTo("/access-denied"); } catch (Exception ex) { errorMessage = "Не удалось добавить co-GM: " + ex.Message; } finally { isAddingCoGm = false; } } private async Task RemoveCoGm(long coGmTelegramId) { errorMessage = null; successMessage = null; removingCoGmId = coGmTelegramId; try { await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId); successMessage = "Co-GM удалён."; await LoadSessions(); } catch (SessionAccessDeniedException) { Navigation.NavigateTo("/access-denied"); } catch (Exception ex) { errorMessage = "Не удалось удалить co-GM: " + ex.Message; } finally { removingCoGmId = null; } } 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 async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template) { errorMessage = null; successMessage = null; processingTemplateId = template.Id; try { var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; if (utcTime <= DateTime.UtcNow) { errorMessage = "Первая дата batch должна быть в будущем."; return; } var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime); successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр."; await LoadSessions(); } catch (SessionAccessDeniedException) { Navigation.NavigateTo("/access-denied"); } catch (Exception ex) { errorMessage = "Не удалось создать batch из шаблона: " + ex.Message; } finally { processingTemplateId = 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 void RebuildCampaignTemplateModels() { var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow(); campaignTemplateModels = campaignTemplates? .OrderByDescending(template => template.UpdatedAt) .ThenBy(template => template.Name) .Select(template => new CampaignTemplateUsageModel { Id = template.Id, Name = template.Name, Title = template.Title, JoinLink = template.JoinLink, SessionCount = template.SessionCount, IntervalDays = template.IntervalDays, MaxPlayers = template.MaxPlayers, NotificationMode = template.NotificationMode, FirstScheduledAtLocal = defaultStart }) .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 bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id; private string CurrentUserRole => groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role ?? GroupManagerRoleExtensions.CoGmValue; private static string FormatRole(string role) => GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName(); private static string FormatManager(WebGroupManager manager) { var username = string.IsNullOrWhiteSpace(manager.TelegramUsername) ? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture) : "@" + manager.TelegramUsername; return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}"; } private static int InferIntervalDays(IReadOnlyList 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 FormatTemplateSummary(CampaignTemplateUsageModel template) { var seats = template.MaxPlayers.HasValue ? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест" : "без лимита"; return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}"; } private static string FormatNotificationMode(string notificationMode) => SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch { SessionNotificationMode.GroupOnly => "только группа", _ => "группа и личка" }; 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"; } private sealed class CampaignTemplateUsageModel { public Guid Id { get; init; } public string Name { get; init; } = ""; public string Title { get; init; } = ""; public string JoinLink { get; init; } = ""; public int SessionCount { get; init; } public int IntervalDays { get; init; } public int? MaxPlayers { get; init; } public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue; public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now; } private sealed class CoGmEditModel { public long? TelegramId { get; set; } public string DisplayName { get; set; } = ""; public string? TelegramUsername { get; set; } } }