898 lines
37 KiB
Plaintext
898 lines
37 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 (groupManagement is not null)
|
||
{
|
||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||
<div class="batch-bulk-header">
|
||
<div>
|
||
<h3>Управление группой</h3>
|
||
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
|
||
</div>
|
||
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||
@foreach (var manager in groupManagement.Managers)
|
||
{
|
||
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||
@FormatManager(manager)
|
||
</span>
|
||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
||
{
|
||
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
|
||
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
|
||
</button>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
@if (groupManagement.CurrentUserIsOwner)
|
||
{
|
||
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
|
||
<div class="batch-bulk-fields">
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Telegram ID co-GM</label>
|
||
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Имя</label>
|
||
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Username</label>
|
||
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
|
||
@(isAddingCoGm ? "⏳ Добавляем..." : "➕ Добавить co-GM")
|
||
</button>
|
||
</EditForm>
|
||
}
|
||
</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 (campaignTemplates is not null)
|
||
{
|
||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||
<div class="batch-bulk-header">
|
||
<div>
|
||
<h3>Шаблоны кампаний</h3>
|
||
<p>@campaignTemplateModels.Count сохранённых</p>
|
||
</div>
|
||
<span class="status-badge status-info">Template</span>
|
||
</div>
|
||
|
||
<EditForm Model="@campaignTemplateModel" OnValidSubmit="CreateCampaignTemplate">
|
||
<div class="campaign-template-fields">
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Название шаблона</label>
|
||
<InputText @bind-Value="campaignTemplateModel.Name" class="gm-form-control" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Название кампании</label>
|
||
<InputText @bind-Value="campaignTemplateModel.Title" class="gm-form-control" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Ссылка</label>
|
||
<InputText @bind-Value="campaignTemplateModel.JoinLink" class="gm-form-control" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Игр</label>
|
||
<InputNumber @bind-Value="campaignTemplateModel.SessionCount" class="gm-form-control" min="1" max="52" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Интервал, дней</label>
|
||
<InputNumber @bind-Value="campaignTemplateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Мест</label>
|
||
<InputNumber @bind-Value="campaignTemplateModel.MaxPlayers" class="gm-form-control" min="1" />
|
||
</div>
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Уведомления</label>
|
||
<select @bind="campaignTemplateModel.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="@isCreatingTemplate">
|
||
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
|
||
</button>
|
||
</EditForm>
|
||
|
||
@if (campaignTemplateModels.Count > 0)
|
||
{
|
||
<div class="campaign-template-list">
|
||
@foreach (var template in campaignTemplateModels)
|
||
{
|
||
<div class="campaign-template-row">
|
||
<div class="campaign-template-info">
|
||
<h3>@template.Name</h3>
|
||
<p>@FormatTemplateSummary(template)</p>
|
||
</div>
|
||
<div class="campaign-template-actions">
|
||
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
|
||
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
|
||
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
|
||
</button>
|
||
<button type="button" class="btn-gm btn-gm-danger" disabled="@IsTemplateBusy(template)" @onclick="() => DeleteCampaignTemplate(template)">
|
||
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</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<WebCampaignTemplate>? campaignTemplates;
|
||
private WebGroupManagement? groupManagement;
|
||
private List<BatchBulkEditModel> batchModels = [];
|
||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||
private Guid? promotingSessionId;
|
||
private Guid? processingBatchId;
|
||
private Guid? processingTemplateId;
|
||
private Guid? deletingTemplateId;
|
||
private long? removingCoGmId;
|
||
private bool isAddingCoGm;
|
||
private bool isCreatingTemplate;
|
||
private long telegramId;
|
||
private string? errorMessage;
|
||
private string? successMessage;
|
||
private CoGmEditModel coGmModel = new();
|
||
private CampaignTemplateEditModel campaignTemplateModel = 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 CreateCampaignTemplate()
|
||
{
|
||
errorMessage = null;
|
||
successMessage = null;
|
||
|
||
if (!ValidateCampaignTemplate(campaignTemplateModel))
|
||
{
|
||
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
|
||
return;
|
||
}
|
||
|
||
isCreatingTemplate = true;
|
||
|
||
try
|
||
{
|
||
await SessionService.CreateCampaignTemplateForGmAsync(
|
||
GroupId,
|
||
telegramId,
|
||
new CreateCampaignTemplateRequest(
|
||
campaignTemplateModel.Name,
|
||
campaignTemplateModel.Title,
|
||
campaignTemplateModel.JoinLink,
|
||
campaignTemplateModel.SessionCount,
|
||
campaignTemplateModel.IntervalDays,
|
||
campaignTemplateModel.MaxPlayers,
|
||
SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode)));
|
||
|
||
campaignTemplateModel = new();
|
||
successMessage = "Шаблон кампании сохранён.";
|
||
await LoadSessions();
|
||
}
|
||
catch (SessionAccessDeniedException)
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
isCreatingTemplate = false;
|
||
}
|
||
}
|
||
|
||
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 async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template)
|
||
{
|
||
errorMessage = null;
|
||
successMessage = null;
|
||
deletingTemplateId = template.Id;
|
||
|
||
try
|
||
{
|
||
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
|
||
successMessage = "Шаблон кампании удалён.";
|
||
await LoadSessions();
|
||
}
|
||
catch (SessionAccessDeniedException)
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
deletingTemplateId = 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 static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
|
||
{
|
||
template.Name = template.Name.Trim();
|
||
template.Title = template.Title.Trim();
|
||
template.JoinLink = template.JoinLink.Trim();
|
||
|
||
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
return template.Name.Length > 0 &&
|
||
template.Title.Length > 0 &&
|
||
template.JoinLink.Length > 0 &&
|
||
template.SessionCount is >= 1 and <= 52 &&
|
||
template.IntervalDays is >= 1 and <= 365;
|
||
}
|
||
|
||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||
|
||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) =>
|
||
processingTemplateId == template.Id || deletingTemplateId == 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<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 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 CampaignTemplateEditModel
|
||
{
|
||
public string Name { get; set; } = "";
|
||
public string Title { get; set; } = "";
|
||
public string JoinLink { get; set; } = "";
|
||
public int SessionCount { get; set; } = 6;
|
||
public int IntervalDays { get; set; } = 7;
|
||
public int? MaxPlayers { get; set; }
|
||
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
||
}
|
||
|
||
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; }
|
||
}
|
||
}
|