LibraryManagement / BlazorWebAssembly /Pages /UserInsights.razor
Yuyuqt
add: authorized pages, firebase
d8a7fc4
Raw
History Blame Contribute Delete
14.6 kB
@page "/user-insights"
@using LibraryManagement.Shared.Models
@using BlazorWebAssembly.Services
@inject LibraryApiClient ApiClient
@inject NavigationManager Navigation
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Librarian")]
<div class="max-w-7xl mx-auto py-12 px-6 animate-fade">
<!-- Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-6">
<div>
<h1 class="text-5xl font-black tracking-tight text-main mb-4">User Insights</h1>
<p class="text-lg text-muted max-w-xl">
Analytics and data-driven visibility into library membership and reading patterns.
</p>
</div>
<div class="flex gap-4">
<div class="bg-card border border-border px-6 py-3 rounded-2xl flex flex-col shadow-sm">
<span class="text-[10px] font-black text-muted uppercase tracking-[0.2em]">Total Members</span>
<span class="text-2xl font-black text-main">@(_totalMembers)</span>
</div>
<div class="bg-primary/10 border border-primary/20 px-6 py-3 rounded-2xl flex flex-col text-primary shadow-sm">
<span class="text-[10px] font-black text-primary uppercase tracking-[0.2em]">Active Subs</span>
<span class="text-2xl font-black text-primary">@(_activeSubscriptions)</span>
</div>
</div>
</div>
@if (_isLoading)
{
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
@for (int i = 0; i < 3; i++)
{
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm animate-pulse h-96"></div>
}
</div>
}
else
{
<!-- Metrics Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
<!-- Most Active Readers -->
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Top Readers</h3>
<div class="w-8 h-8 rounded-full bg-blue-500/10 text-blue-500 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
</div>
</div>
<div class="space-y-6 flex-grow">
@if (!_activeReaders.Any())
{
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
}
@foreach (var (user, index) in _activeReaders.Take(5).Select((u, i) => (u, i)))
{
var maxRaw = _activeReaders.Max(u => u.RawValue);
var pct = maxRaw > 0 ? (user.RawValue / maxRaw) * 100 : 0;
<div class="flex items-center gap-4">
<span class="w-8 h-8 rounded-lg bg-body flex items-center justify-center font-black text-muted text-xs">@(index + 1)</span>
<div class="flex-grow">
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
</div>
<div class="w-16 bg-body h-1.5 rounded-full overflow-hidden">
<div class="bg-blue-500 h-full rounded-full" style="width: @pct%"></div>
</div>
</div>
}
</div>
</div>
<!-- Loyalty Leaders -->
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Point Leaders</h3>
<div class="w-8 h-8 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"></path></svg>
</div>
</div>
<div class="space-y-6 flex-grow">
@if (!_loyalty.Any())
{
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
}
@foreach (var (user, index) in _loyalty.Take(5).Select((u, i) => (u, i)))
{
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500 font-black text-xs">
@(user.Name.Length >= 2 ? user.Name.Substring(0, 2).ToUpper() : "?")
</div>
<div class="flex-grow">
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
</div>
<span class="text-[10px] font-black text-amber-500 bg-amber-500/10 px-2 py-1 rounded-md">#@(index + 1)</span>
</div>
}
</div>
</div>
<!-- Top Subscribers -->
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Top Subscribers</h3>
<div class="w-8 h-8 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path></svg>
</div>
</div>
<div class="space-y-6 flex-grow">
@if (!_subscribers.Any())
{
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
}
@foreach (var (user, index) in _subscribers.Take(5).Select((u, i) => (u, i)))
{
<div class="flex items-center gap-4">
<span class="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-600 flex items-center justify-center font-black text-xs border border-emerald-500/20">@(index + 1)</span>
<div class="flex-grow">
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
</div>
</div>
}
</div>
</div>
</div>
<div class="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
<div class="p-6 border-b border-border bg-body">
<h3 class="text-lg font-bold text-main">Full Member Insights (All Users)</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-body border-b border-border">
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Joined Date</th>
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-right">Lifetime Points</th>
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-right">Books Read</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@foreach (var user in _allUsers.OrderByDescending(u => u.CreatedAt))
{
var points = _loyalty.FirstOrDefault(l => l.UserId == user.Id)?.RawValue ?? 0;
var books = _activeReaders.FirstOrDefault(r => r.UserId == user.Id)?.RawValue ?? 0;
<tr class="hover:bg-body transition-colors">
<td class="px-6 py-5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs border border-primary/20">
@(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U")
</div>
<p class="text-sm font-bold text-main">@user.FullName</p>
</div>
</td>
<td class="px-6 py-5 text-sm font-medium text-muted">@user.CreatedAt.ToString("MMM dd, yyyy")</td>
<td class="px-6 py-5 text-right font-black text-amber-500">@points.ToString("N0")</td>
<td class="px-6 py-5 text-right font-black text-blue-500">@books.ToString("N0")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@code {
private bool _isLoading = true;
private List<InsightItem> _subscribers = new();
private List<InsightItem> _loyalty = new();
private List<InsightItem> _activeReaders = new();
private List<UserDto> _allUsers = new();
private int _totalMembers = 0;
private int _activeSubscriptions = 0;
protected override async Task OnInitializedAsync()
{
await LoadInsightsAsync();
}
private async Task LoadInsightsAsync()
{
_isLoading = true;
try
{
var users = await ApiClient.GetUsersAsync();
_allUsers = users.ToList();
_totalMembers = _allUsers.Count;
var borrowings = await ApiClient.GetAllBorrowingsAsync();
var subscriptions = await ApiClient.GetAllSubscriptionsAsync();
var memberships = await ApiClient.GetMembershipsAsync();
var loyaltyHistory = await ApiClient.GetAllMembersPointsHistoryAsync();
_activeSubscriptions = subscriptions.Count(s => s.IsActive);
// 1. Top Subscribers (Total Spent)
var membershipPriceMap = memberships.ToDictionary(m => m.Id, m => m.Price);
_subscribers = subscriptions
.GroupBy(s => s.UserId)
.Select(g => {
var user = _allUsers.FirstOrDefault(u => u.Id == g.Key);
var totalSpent = g.Sum(s => membershipPriceMap.TryGetValue(s.MembershipId, out var price) ? price : 0);
return new InsightItem {
UserId = g.Key,
Name = user?.FullName ?? "Unknown User",
RawValue = (double)totalSpent,
ValueLabel = $"MMK {totalSpent:N0}"
};
})
.OrderByDescending(i => i.RawValue)
.ToList();
// 2. Top Loyalty (Earned Points only)
_loyalty = loyaltyHistory
.Select(lh => {
var user = _allUsers.FirstOrDefault(u => u.Id == lh.UserId);
var totalEarned = lh.History.Where(h => h.PointDelta > 0).Sum(h => h.PointDelta);
return new InsightItem {
UserId = lh.UserId,
Name = user?.FullName ?? lh.UserName,
RawValue = totalEarned,
ValueLabel = $"{totalEarned:N0} pts"
};
})
.OrderByDescending(i => i.RawValue)
.ToList();
// 3. Most Active (Approved Returns only)
_activeReaders = borrowings
.Where(b => b.Status == "Returned")
.GroupBy(b => b.UserId)
.Select(g => {
var user = _allUsers.FirstOrDefault(u => u.Id == g.Key);
var count = g.Count();
return new InsightItem {
UserId = g.Key,
Name = user?.FullName ?? "Unknown User",
RawValue = count,
ValueLabel = $"{count} books"
};
})
.OrderByDescending(i => i.RawValue)
.ToList();
}
catch (Exception ex)
{
Console.WriteLine($"Error loading insights: {ex.Message}");
}
finally
{
_isLoading = false;
}
}
private class InsightItem
{
public Guid UserId { get; set; }
public string Name { get; set; } = "";
public double RawValue { get; set; }
public string ValueLabel { get; set; } = "";
}
}