4d3362d93f
PR Checks / test-and-build (pull_request) Failing after 3m14s
- Add @using System.Security.Claims - Fix quotation marks in @onclick lambdas (Razor parser error CS1026)
236 lines
10 KiB
Plaintext
236 lines
10 KiB
Plaintext
@page "/group/{GroupId:guid}/stats"
|
|
@using GmRelay.Web.Services
|
|
@using GmRelay.Shared.Domain
|
|
@using Microsoft.AspNetCore.Authorization
|
|
@using Microsoft.AspNetCore.Components.Authorization
|
|
@using System.Security.Claims
|
|
@attribute [Authorize]
|
|
@inject ISessionStore SessionStore
|
|
@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><a href="/group/@GroupId">Сессии группы</a></li>
|
|
<li class="active">Статистика</li>
|
|
</ul>
|
|
|
|
<div class="page-header animate-fade-in">
|
|
<h2>📊 Статистика посещаемости</h2>
|
|
<p class="page-subtitle">Надёжность состава и качество расписания</p>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
|
⚠️ @errorMessage
|
|
</div>
|
|
}
|
|
|
|
@if (stats is null)
|
|
{
|
|
<div class="loading-spinner">⏳ Загружаем статистику…</div>
|
|
}
|
|
else if (stats.Count == 0)
|
|
{
|
|
<div class="empty-state">
|
|
<div class="empty-icon">📈</div>
|
|
<h3>Пока нет данных</h3>
|
|
<p>После первых сессий здесь появится аналитика.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
|
<div class="stats-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
|
|
<div class="stat-card">
|
|
<div class="stat-value">@stats.Count</div>
|
|
<div class="stat-label">Игроков</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">@TotalSessions</div>
|
|
<div class="stat-label">Сессий</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">@AvgAttendanceRate%</div>
|
|
<div class="stat-label">Средняя посещаемость</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">@topPlayer?.DisplayName</div>
|
|
<div class="stat-label">Самый стабильный</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="gm-table" style="width: 100%;">
|
|
<thead>
|
|
<tr>
|
|
<th @onclick="@(() => SortBy("player"))" style="cursor:pointer;" class="sortable">Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("total"))" style="cursor:pointer; text-align:center;" class="sortable">Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("confirmed"))" style="cursor:pointer; text-align:center;" class="sortable">✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("declined"))" style="cursor:pointer; text-align:center;" class="sortable">❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("noresponse"))" style="cursor:pointer; text-align:center;" class="sortable">💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("waitlist"))" style="cursor:pointer; text-align:center;" class="sortable">⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("rate"))" style="cursor:pointer; text-align:center;" class="sortable">% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
<th @onclick="@(() => SortBy("cancelled"))" style="cursor:pointer; text-align:center;" class="sortable">🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "")</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var s in sortedStats)
|
|
{
|
|
<tr>
|
|
<td>
|
|
<div class="player-info">
|
|
<span class="player-name">@s.DisplayName</span>
|
|
@if (!string.IsNullOrEmpty(s.TelegramUsername))
|
|
{
|
|
<span class="player-username">@@@s.TelegramUsername</span>
|
|
}
|
|
</div>
|
|
</td>
|
|
<td style="text-align:center;">@s.TotalSessions</td>
|
|
<td style="text-align:center;">@s.ConfirmedCount</td>
|
|
<td style="text-align:center;">@s.DeclinedCount</td>
|
|
<td style="text-align:center;">@s.NoResponseCount</td>
|
|
<td style="text-align:center;">@s.WaitlistedCount</td>
|
|
<td style="text-align:center;">
|
|
<span class="rate-badge @AttendanceBadgeClass(s.AttendanceRate)">
|
|
@s.AttendanceRate%
|
|
</span>
|
|
</td>
|
|
<td style="text-align:center;">@s.CancellationAffectedCount</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<style>
|
|
.stat-card {
|
|
background: var(--card-bg-secondary, rgba(255,255,255,0.05));
|
|
border-radius: 0.75rem;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
.stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--accent-color, #7cb97a);
|
|
}
|
|
.stat-label {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted, #94a3b8);
|
|
margin-top: 0.25rem;
|
|
}
|
|
.player-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.player-name {
|
|
font-weight: 500;
|
|
}
|
|
.player-username {
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted, #94a3b8);
|
|
}
|
|
.rate-badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.5rem;
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
.rate-excellent { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
.rate-good { background: rgba(234,179,8,0.15); color: #eab308; }
|
|
.rate-poor { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
</style>
|
|
|
|
@code {
|
|
[Parameter] public Guid GroupId { get; set; }
|
|
private List<PlayerAttendanceStats>? stats;
|
|
private List<PlayerAttendanceStats> sortedStats = new();
|
|
private string? errorMessage;
|
|
private string sortColumn = "confirmed";
|
|
private bool sortDesc = true;
|
|
private int TotalSessions => stats?.Count > 0 ? (int)(stats.Max(s => s.TotalSessions)) : 0;
|
|
private int AvgAttendanceRate => stats?.Count > 0 ? (int)(stats.Average(s => s.AttendanceRate)) : 0;
|
|
private PlayerAttendanceStats? topPlayer => stats?.OrderByDescending(s => s.AttendanceRate).ThenByDescending(s => s.ConfirmedCount).FirstOrDefault();
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
var user = authState.User;
|
|
if (!user.Identity?.IsAuthenticated ?? true)
|
|
{
|
|
Navigation.NavigateTo("/login");
|
|
return;
|
|
}
|
|
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
|
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (!long.TryParse(telegramIdClaim, out var telegramId))
|
|
{
|
|
Navigation.NavigateTo("/login");
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
|
|
{
|
|
Navigation.NavigateTo("/access-denied");
|
|
return;
|
|
}
|
|
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
|
|
UpdateSortedStats();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorMessage = $"Ошибка загрузки статистики: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void SortBy(string column)
|
|
{
|
|
if (sortColumn == column)
|
|
sortDesc = !sortDesc;
|
|
else
|
|
{
|
|
sortColumn = column;
|
|
sortDesc = true;
|
|
}
|
|
UpdateSortedStats();
|
|
}
|
|
|
|
private void UpdateSortedStats()
|
|
{
|
|
if (stats is null) { sortedStats = new(); return; }
|
|
IOrderedEnumerable<PlayerAttendanceStats> ordered = sortColumn switch
|
|
{
|
|
"player" => sortDesc ? stats.OrderByDescending(s => s.DisplayName) : stats.OrderBy(s => s.DisplayName),
|
|
"total" => sortDesc ? stats.OrderByDescending(s => s.TotalSessions) : stats.OrderBy(s => s.TotalSessions),
|
|
"confirmed" => sortDesc ? stats.OrderByDescending(s => s.ConfirmedCount) : stats.OrderBy(s => s.ConfirmedCount),
|
|
"declined" => sortDesc ? stats.OrderByDescending(s => s.DeclinedCount) : stats.OrderBy(s => s.DeclinedCount),
|
|
"noresponse" => sortDesc ? stats.OrderByDescending(s => s.NoResponseCount) : stats.OrderBy(s => s.NoResponseCount),
|
|
"waitlist" => sortDesc ? stats.OrderByDescending(s => s.WaitlistedCount) : stats.OrderBy(s => s.WaitlistedCount),
|
|
"rate" => sortDesc ? stats.OrderByDescending(s => s.AttendanceRate) : stats.OrderBy(s => s.AttendanceRate),
|
|
"cancelled" => sortDesc ? stats.OrderByDescending(s => s.CancellationAffectedCount) : stats.OrderBy(s => s.CancellationAffectedCount),
|
|
_ => stats.OrderByDescending(s => s.ConfirmedCount)
|
|
};
|
|
sortedStats = ordered.ToList();
|
|
}
|
|
|
|
private string SortIndicator(string column) => sortColumn == column ? (sortDesc ? "▼" : "▲") : "";
|
|
|
|
private string AttendanceBadgeClass(decimal rate) => rate switch
|
|
{
|
|
>= 75m => "rate-excellent",
|
|
>= 50m => "rate-good",
|
|
_ => "rate-poor"
|
|
};
|
|
}
|