File size: 14,594 Bytes
6f32ae8
 
 
 
 
 
 
 
d8a7fc4
6f32ae8
 
 
 
 
 
 
 
 
d8a7fc4
6f32ae8
d8a7fc4
6f32ae8
d8a7fc4
 
 
6f32ae8
 
 
 
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
 
d8a7fc4
 
 
 
 
 
6f32ae8
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
 
d8a7fc4
 
 
 
 
 
 
6f32ae8
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
 
 
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
 
 
d8a7fc4
 
 
 
 
 
 
 
 
 
6f32ae8
 
d8a7fc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f32ae8
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
@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; } = "";
    }
}