feat: add web batch bulk operations
Deploy Telegram Bot / build-and-push (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 11s

This commit is contained in:
2026-04-27 09:31:51 +03:00
parent 5f3516e703
commit 621ef553e7
12 changed files with 931 additions and 7 deletions
@@ -27,6 +27,13 @@
</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;">
@@ -48,6 +55,65 @@
}
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>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить title/link")
</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">
@@ -139,9 +205,12 @@
@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()
{
@@ -152,22 +221,31 @@
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);
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
@@ -183,6 +261,148 @@
}
}
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);
successMessage = "Общие title/link обновлены для всей пачки.";
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,
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);
@@ -198,6 +418,12 @@
: 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",
@@ -215,4 +441,16 @@
SessionStatus.Cancelled => "Отменено",
_ => status
};
private sealed class BatchBulkEditModel
{
public Guid BatchId { get; init; }
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
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";
}
}