feat: show participant list, kick player, auto-promote waitlist
This commit is contained in:
@@ -229,8 +229,13 @@
|
||||
<tbody>
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
var isExpanded = expandedSessions.Contains(session.Id);
|
||||
<tr>
|
||||
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
||||
<td style="color: var(--text-primary); font-weight: 500;">
|
||||
<button type="button" class="btn-gm btn-gm-link" @onclick="() => ToggleParticipants(session.Id)">
|
||||
@(isExpanded ? "▼" : "▶") @session.Title
|
||||
</button>
|
||||
</td>
|
||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||
<td>@FormatSeats(session)</td>
|
||||
<td>
|
||||
@@ -255,6 +260,49 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (isExpanded)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" style="padding: 0; border: none;">
|
||||
<div class="participant-panel">
|
||||
@if (loadingParticipantsSessionId == session.Id)
|
||||
{
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
}
|
||||
else if (participantsCache.TryGetValue(session.Id, out var participants))
|
||||
{
|
||||
@if (participants.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Нет участников</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="participant-list">
|
||||
@foreach (var p in participants)
|
||||
{
|
||||
<div class="participant-row">
|
||||
<div class="participant-info">
|
||||
<span class="participant-name">@p.DisplayName</span>
|
||||
<span class="participant-username">@FormatParticipantUsername(p)</span>
|
||||
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
|
||||
</div>
|
||||
@if (!p.IsGm)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
|
||||
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -264,9 +312,12 @@
|
||||
<div class="session-card-mobile stagger-children">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
var isExpanded = expandedSessions.Contains(session.Id);
|
||||
<div class="session-card">
|
||||
<div class="session-card-header">
|
||||
<span class="session-card-title">@session.Title</span>
|
||||
<button type="button" class="btn-gm btn-gm-link" style="text-align: left; padding: 0;" @onclick="() => ToggleParticipants(session.Id)">
|
||||
@(isExpanded ? "▼" : "▶") @session.Title
|
||||
</button>
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
</div>
|
||||
<div class="session-card-body">
|
||||
@@ -294,6 +345,45 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (isExpanded)
|
||||
{
|
||||
<div class="participant-panel" style="margin-top: 0.75rem;">
|
||||
@if (loadingParticipantsSessionId == session.Id)
|
||||
{
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
}
|
||||
else if (participantsCache.TryGetValue(session.Id, out var participants))
|
||||
{
|
||||
@if (participants.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Нет участников</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="participant-list">
|
||||
@foreach (var p in participants)
|
||||
{
|
||||
<div class="participant-row">
|
||||
<div class="participant-info">
|
||||
<span class="participant-name">@p.DisplayName</span>
|
||||
<span class="participant-username">@FormatParticipantUsername(p)</span>
|
||||
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
|
||||
</div>
|
||||
@if (!p.IsGm)
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
|
||||
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -316,6 +406,10 @@
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private CoGmEditModel coGmModel = new();
|
||||
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
|
||||
private HashSet<Guid> expandedSessions = new();
|
||||
private Guid? kickingParticipantId;
|
||||
private Guid? loadingParticipantsSessionId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -447,6 +541,105 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleParticipants(Guid sessionId)
|
||||
{
|
||||
if (expandedSessions.Contains(sessionId))
|
||||
{
|
||||
expandedSessions.Remove(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
expandedSessions.Add(sessionId);
|
||||
|
||||
if (!participantsCache.ContainsKey(sessionId))
|
||||
{
|
||||
loadingParticipantsSessionId = sessionId;
|
||||
try
|
||||
{
|
||||
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
|
||||
participantsCache[sessionId] = participants ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Не удалось загрузить участников: " + ex.Message;
|
||||
expandedSessions.Remove(sessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingParticipantsSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task KickParticipant(Guid sessionId, Guid participantId)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
kickingParticipantId = participantId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
|
||||
participantsCache.Remove(sessionId);
|
||||
successMessage = "Игрок исключён.";
|
||||
await LoadSessions();
|
||||
if (expandedSessions.Contains(sessionId))
|
||||
{
|
||||
await ToggleParticipants(sessionId);
|
||||
}
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
kickingParticipantId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatParticipantUsername(WebParticipant p)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
|
||||
? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "@" + p.TelegramUsername;
|
||||
return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}";
|
||||
}
|
||||
|
||||
private static string FormatParticipantRsvp(string rsvp) => rsvp switch
|
||||
{
|
||||
RsvpStatus.Pending => "⏳ не ответил",
|
||||
RsvpStatus.Confirmed => "✅ подтвердил",
|
||||
RsvpStatus.Declined => "❌ отказался",
|
||||
_ => rsvp
|
||||
};
|
||||
|
||||
private static string GetParticipantStatusClass(WebParticipant p)
|
||||
{
|
||||
if (p.IsGm) return "status-success";
|
||||
return p.RegistrationStatus switch
|
||||
{
|
||||
"Active" => "status-info",
|
||||
"Waitlisted" => "status-warning",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
}
|
||||
|
||||
private static string TranslateParticipantStatus(WebParticipant p)
|
||||
{
|
||||
if (p.IsGm) return "ГМ";
|
||||
return p.RegistrationStatus switch
|
||||
{
|
||||
"Active" => "Основной состав",
|
||||
"Waitlisted" => "Ожидание",
|
||||
_ => p.RegistrationStatus
|
||||
};
|
||||
}
|
||||
|
||||
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
Reference in New Issue
Block a user