Spaces:
Sleeping
Sleeping
| @page "/members" | |
| @using BlazorWebAssembly.Services | |
| @using LibraryManagement.Shared.Models | |
| @using BlazorWebAssembly.Models | |
| @using Microsoft.AspNetCore.Authorization | |
| @attribute [Authorize(Roles = "Librarian")] | |
| @inject LibraryApiClient ApiClient | |
| @inject IJSRuntime JS | |
| @inject NavigationManager Navigation | |
| <div class="max-w-7xl mx-auto py-10 px-6 animate-fade"> | |
| <!-- Top Tabs Navigation --> | |
| <div class="flex items-center gap-8 border-b border-border mb-10"> | |
| <button @onclick="() => SetTab(LibrarianTab.Directory)" class="pb-4 px-2 text-sm font-black uppercase tracking-widest transition-all relative @(_activeTab == LibrarianTab.Directory ? "text-primary" : "text-muted hover:text-main")"> | |
| Member Directory | |
| @if (_activeTab == LibrarianTab.Directory) { <div class="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-t-full"></div> } | |
| </button> | |
| <button @onclick="() => SetTab(LibrarianTab.Approvals)" class="pb-4 px-2 text-sm font-black uppercase tracking-widest transition-all relative @(_activeTab == LibrarianTab.Approvals ? "text-primary" : "text-muted hover:text-main")"> | |
| Membership Approvals | |
| @if (_pendingSubscriptions?.Any() == true) | |
| { | |
| <span class="ml-2 bg-red-500 text-white text-[10px] px-2 py-0.5 rounded-full">@_pendingSubscriptions.Count()</span> | |
| } | |
| @if (_activeTab == LibrarianTab.Approvals) { <div class="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-t-full"></div> } | |
| </button> | |
| </div> | |
| @if (_activeTab == LibrarianTab.Directory) | |
| { | |
| @if (_currentView == ViewMode.List) | |
| { | |
| <div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> | |
| <div> | |
| <h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Members Directory</h1> | |
| <p class="text-muted text-lg"> | |
| Manage library members and administrator accounts. | |
| <span class="ml-2 text-xs text-muted font-bold">(@(_allUsers?.Count() ?? 0) total)</span> | |
| </p> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| <button @onclick="ShowCreate" class="bg-primary text-white px-6 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all whitespace-nowrap flex items-center"> | |
| <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> | |
| Add Member | |
| </button> | |
| </div> | |
| </div> | |
| @if (_isLoading) | |
| { | |
| <div class="space-y-4"> | |
| @for (int i = 0; i < 5; i++) | |
| { | |
| <div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div> | |
| } | |
| </div> | |
| } | |
| else if (_pagedResult != null) | |
| { | |
| <div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm"> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-left border-collapse min-w-max whitespace-nowrap"> | |
| <thead class="bg-body border-b border-border"> | |
| <tr> | |
| <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">Contact</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Role</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th> | |
| <th class="px-6 py-4 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-border"> | |
| @foreach (var user in _pagedResult.Items) | |
| { | |
| <tr class="hover:bg-body transition-colors group"> | |
| <td class="px-6 py-4"> | |
| <div class="flex items-center gap-4"> | |
| <div class="h-10 w-10 flex-shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-sm"> | |
| @(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U") | |
| </div> | |
| <div> | |
| <button @onclick="() => ShowDetails(user.Id)" class="text-sm font-bold text-main hover:text-primary transition-colors text-left">@user.FullName</button> | |
| @if (!string.IsNullOrEmpty(user.StudentId)) | |
| { | |
| <div class="text-xs text-muted font-medium mt-0.5">ID: @user.StudentId</div> | |
| } | |
| </div> | |
| </div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <div class="text-sm text-main font-medium">@user.Email</div> | |
| <div class="text-xs text-muted">@(string.IsNullOrEmpty(user.PhoneNumber) ? "No phone" : user.PhoneNumber)</div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase @(user.Role == "Librarian" ? "bg-amber-500/10 text-amber-500" : "bg-blue-500/10 text-blue-500")"> | |
| @user.Role | |
| </span> | |
| </td> | |
| <td class="px-6 py-4"> | |
| @if (user.BanStatus == true) | |
| { | |
| <span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-red-500/10 text-red-500"> | |
| Suspended | |
| </span> | |
| } | |
| else if (user.IsActive) | |
| { | |
| <span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-green-500/10 text-green-500"> | |
| Active | |
| </span> | |
| } | |
| else | |
| { | |
| <span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-body text-muted border border-border"> | |
| Inactive | |
| </span> | |
| } | |
| </td> | |
| <td class="px-6 py-4 text-right"> | |
| <div class="flex items-center justify-end gap-2"> | |
| <button @onclick="() => ShowDetails(user.Id)" class="p-2 rounded-lg border border-border text-muted hover:bg-primary hover:text-white hover:border-primary transition-all"> | |
| <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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg> | |
| </button> | |
| <button @onclick="() => ShowEdit(user.Id)" class="p-2 rounded-lg border border-border text-muted hover:bg-primary hover:text-white hover:border-primary transition-all"> | |
| <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg> | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| } | |
| </tbody> | |
| </table> | |
| </div> | |
| @if (_pagedResult.TotalPages > 1) | |
| { | |
| <div class="px-6 py-4 border-t border-border flex items-center justify-between bg-body"> | |
| <p class="text-xs text-muted"> | |
| Showing <span class="font-bold text-main">@((_pagedResult.CurrentPage - 1) * _pagedResult.PageSize + 1)</span> | |
| to | |
| <span class="font-bold text-main">@(Math.Min(_pagedResult.CurrentPage * _pagedResult.PageSize, _pagedResult.TotalCount))</span> | |
| of <span class="font-bold text-main">@_pagedResult.TotalCount</span> members | |
| </p> | |
| <div class="flex items-center gap-2"> | |
| <button @onclick="() => ChangePage(_pagedResult.CurrentPage - 1)" disabled="@(!_pagedResult.HasPreviousPage)" | |
| class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all"> | |
| <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="M15 19l-7-7 7-7" /></svg> | |
| </button> | |
| @for (int p = 1; p <= _pagedResult.TotalPages; p++) | |
| { | |
| var pageNumber = p; | |
| if (p == _pagedResult.CurrentPage) | |
| { | |
| <span class="w-8 h-8 flex items-center justify-center rounded-lg bg-main text-body text-xs font-bold">@p</span> | |
| } | |
| else if (p == 1 || p == _pagedResult.TotalPages || (p >= _pagedResult.CurrentPage - 1 && p <= _pagedResult.CurrentPage + 1)) | |
| { | |
| <button @onclick="() => ChangePage(pageNumber)" | |
| class="w-8 h-8 flex items-center justify-center rounded-lg border border-border text-xs font-bold text-muted hover:text-primary hover:border-primary transition-colors">@p</button> | |
| } | |
| else if (p == _pagedResult.CurrentPage - 2 || p == _pagedResult.CurrentPage + 2) | |
| { | |
| <span class="w-8 h-8 flex items-center justify-center text-xs text-muted">...</span> | |
| } | |
| } | |
| <button @onclick="() => ChangePage(_pagedResult.CurrentPage + 1)" disabled="@(!_pagedResult.HasNextPage)" | |
| class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all"> | |
| <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="M9 5l7 7-7 7" /></svg> | |
| </button> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| } | |
| } | |
| else if (_currentView == ViewMode.Details && _selectedUser != null) | |
| { | |
| <div class="animate-fade"> | |
| <button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group"> | |
| <svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg> | |
| Back to Directory | |
| </button> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-12"> | |
| <div class="lg:col-span-1"> | |
| <div class="aspect-square w-full rounded-3xl bg-card border border-border flex flex-col items-center justify-center text-main p-6 shadow-sm relative overflow-hidden"> | |
| <div class="absolute inset-0 bg-primary/5"></div> | |
| <div class="relative z-10 flex flex-col items-center"> | |
| <div class="w-32 h-32 rounded-full bg-main text-body flex items-center justify-center text-5xl font-black mb-6 shadow-xl"> | |
| @(_selectedUser.User.FullName?.Substring(0, Math.Min(2, _selectedUser.User.FullName.Length)).ToUpper() ?? "U") | |
| </div> | |
| <h2 class="text-2xl font-bold text-center mb-2">@_selectedUser.User.FullName</h2> | |
| <div class="inline-flex rounded-full px-4 py-1.5 text-[10px] font-black uppercase tracking-widest @(_selectedUser.User.Role == "Librarian" ? "bg-amber-500/10 text-amber-500 border border-amber-500/20" : "bg-blue-500/10 text-blue-500 border border-blue-500/20")"> | |
| @_selectedUser.User.Role | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="lg:col-span-2 space-y-8"> | |
| <div> | |
| <h1 class="text-4xl font-extrabold text-main tracking-tight mb-4">Member Profile</h1> | |
| <div class="flex items-center gap-3"> | |
| @if (_selectedUser.User.BanStatus == true) | |
| { | |
| <span class="inline-flex items-center rounded-full bg-red-500/10 border border-red-500/20 px-4 py-1.5 text-[10px] font-black text-red-500 tracking-widest uppercase">Suspended</span> | |
| } | |
| else if (_selectedUser.User.IsActive) | |
| { | |
| <span class="inline-flex items-center rounded-full bg-green-500/10 border border-green-500/20 px-4 py-1.5 text-[10px] font-black text-green-500 tracking-widest uppercase">Active</span> | |
| } | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-8 border-y border-border py-8"> | |
| <div> | |
| <h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Email Address</h4> | |
| <p class="text-sm font-semibold text-main">@_selectedUser.User.Email</p> | |
| </div> | |
| <div> | |
| <h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Phone Number</h4> | |
| <p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.PhoneNumber) ? "-" : _selectedUser.User.PhoneNumber)</p> | |
| </div> | |
| <div> | |
| <h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Student ID</h4> | |
| <p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.StudentId) ? "-" : _selectedUser.User.StudentId)</p> | |
| </div> | |
| <div> | |
| <h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Joined Date</h4> | |
| <p class="text-sm font-semibold text-main">@_selectedUser.User.CreatedAt.ToString("MMMM dd, yyyy")</p> | |
| </div> | |
| <div class="sm:col-span-2"> | |
| <h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Physical Address</h4> | |
| <p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.Address) ? "-" : _selectedUser.User.Address)</p> | |
| </div> | |
| </div> | |
| <div class="flex flex-wrap items-center gap-4 pt-2"> | |
| <button @onclick="() => ShowEdit(_selectedUser.User.Id)" class="bg-primary text-white py-3 px-8 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Edit Details</button> | |
| <button @onclick="UpdateRole" class="bg-body border border-border text-main py-3 px-8 rounded-xl font-bold hover:bg-card transition-colors"> | |
| Make @(_selectedUser.User.Role == "Librarian" ? "Member" : "Librarian") | |
| </button> | |
| <button @onclick="DeleteUser" class="p-3 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded-xl transition-colors ml-auto border border-transparent hover:border-red-500/20"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg> | |
| </button> | |
| </div> | |
| <!-- Wallet Management Section --> | |
| <div class="mt-12 pt-12 border-t border-border"> | |
| <div class="flex items-center justify-between mb-8"> | |
| <h3 class="text-2xl font-bold text-main">Library Wallet</h3> | |
| <div class="bg-primary/5 border border-primary/20 px-6 py-3 rounded-2xl"> | |
| <span class="text-[10px] font-black text-primary uppercase tracking-widest block mb-1">Current Balance</span> | |
| <span class="text-2xl font-black text-main">@_selectedUser.User.Balance.ToString("N0") MMK</span> | |
| </div> | |
| </div> | |
| <div class="bg-card border border-border rounded-3xl p-8 shadow-sm"> | |
| <h4 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-6">Add Credits (Cash Deposit)</h4> | |
| <div class="flex flex-col sm:flex-row gap-4"> | |
| <div class="flex-1 relative"> | |
| <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> | |
| <span class="text-muted text-xs font-black">MMK</span> | |
| </div> | |
| <input type="number" @bind="_topUpAmount" class="w-full bg-body border border-border rounded-xl pl-16 pr-4 py-4 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all font-bold" placeholder="0.00" /> | |
| </div> | |
| <button @onclick="TopUpWallet" class="bg-primary text-white px-10 py-4 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all whitespace-nowrap" disabled="@(_topUpAmount <= 0)"> | |
| Confirm Deposit | |
| </button> | |
| </div> | |
| <p class="mt-4 text-xs text-muted font-medium italic">Credits will be added instantly for automated subscription purchases.</p> | |
| </div> | |
| </div> | |
| <!-- Subscription Management --> | |
| <div class="mt-12 pt-12 border-t border-border"> | |
| <h3 class="text-2xl font-bold text-main mb-8">Manage Subscription</h3> | |
| @if (_selectedUser.CurrentSubscription != null && _selectedUser.CurrentSubscription.IsActive) | |
| { | |
| <div class="bg-green-500/5 border border-green-500/20 rounded-2xl p-6 mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> | |
| <div> | |
| <span class="text-[10px] text-green-600 font-black uppercase tracking-widest mb-1 block">Active Plan</span> | |
| <h4 class="text-2xl font-black text-green-700">@_selectedUser.CurrentSubscription.MembershipType</h4> | |
| </div> | |
| <div class="sm:text-right"> | |
| <div class="text-[10px] text-green-600/70 font-black uppercase tracking-widest mb-1">Expires On</div> | |
| <div class="font-bold text-green-700">@_selectedUser.CurrentSubscription.ExpiryDate.ToString("MMMM dd, yyyy")</div> | |
| </div> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="bg-card border border-dashed border-border rounded-2xl p-6 mb-8 text-center"> | |
| <p class="text-muted font-medium">No active subscription for this member.</p> | |
| </div> | |
| } | |
| <h4 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-6">Assign New Plan</h4> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| @foreach (var plan in _selectedUser.AvailableMemberships) | |
| { | |
| var isCurrent = _selectedUser.CurrentSubscription?.IsActive == true && _selectedUser.CurrentSubscription.MembershipId == plan.Id; | |
| <div class="bg-card rounded-2xl border p-6 flex flex-col transition-all relative @(isCurrent ? "border-primary shadow-lg shadow-primary/10" : "border-border hover:border-primary/50")"> | |
| @if (isCurrent) | |
| { | |
| <div class="absolute -top-3 right-6 bg-primary text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-sm">Current</div> | |
| } | |
| <h5 class="font-bold text-main text-xl mb-1">@plan.Type</h5> | |
| <p class="text-muted font-medium text-sm mb-6 flex-grow">@plan.Price.ToString("N0") MMK / @plan.DurationMonths mo</p> | |
| @if (!isCurrent) | |
| { | |
| <button @onclick="() => AssignSubscription(plan.Id, plan.Type)" class="w-full bg-body border border-border text-main py-2.5 rounded-xl text-sm font-bold hover:bg-primary hover:text-white hover:border-primary transition-colors">Assign Plan</button> | |
| } | |
| else | |
| { | |
| <button disabled class="w-full bg-body border border-border text-muted py-2.5 rounded-xl text-sm font-bold opacity-50 cursor-not-allowed">Current Plan</button> | |
| } | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit) | |
| { | |
| <div class="animate-fade"> | |
| <button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group"> | |
| <svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg> | |
| Back to Directory | |
| </button> | |
| <div class="max-w-3xl mx-auto"> | |
| <div class="mb-10 text-center"> | |
| <h1 class="text-4xl font-extrabold tracking-tight text-main mb-3">@(_currentView == ViewMode.Create ? "Register New Member" : "Edit Member Details")</h1> | |
| <p class="text-lg text-muted">@(_currentView == ViewMode.Create ? "Add a new reader to the library system." : $"Update profile details for {_editUser?.FullName}.")</p> | |
| </div> | |
| <div class="bg-card border border-border p-8 sm:p-10 rounded-3xl shadow-sm"> | |
| @if (_currentView == ViewMode.Create) | |
| { | |
| <EditForm Model="@_createRequest" OnValidSubmit="HandleCreate" class="space-y-6"> | |
| <DataAnnotationsValidator /> | |
| <div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> | |
| <div class="sm:col-span-2"> | |
| <label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label> | |
| <InputText @bind-Value="_createRequest.FullName" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="John Doe" /> | |
| <ValidationMessage For="@(() => _createRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Email Address <span class="text-red-500">*</span></label> | |
| <InputText @bind-Value="_createRequest.Email" type="email" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="you@domain.com" /> | |
| <ValidationMessage For="@(() => _createRequest.Email)" class="mt-1 text-xs text-red-500 font-medium" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Password <span class="text-red-500">*</span></label> | |
| <InputText @bind-Value="_createRequest.Password" type="password" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="••••••••" /> | |
| <ValidationMessage For="@(() => _createRequest.Password)" class="mt-1 text-xs text-red-500 font-medium" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Phone Number</label> | |
| <InputText @bind-Value="_createRequest.PhoneNumber" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="+123456789" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Student ID</label> | |
| <InputText @bind-Value="_createRequest.StudentId" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="Optional" /> | |
| </div> | |
| <div class="sm:col-span-2"> | |
| <label class="block text-sm font-bold text-main mb-2">Address</label> | |
| <InputTextArea @bind-Value="_createRequest.Address" rows="3" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="Full physical address" /> | |
| </div> | |
| </div> | |
| <div class="pt-8 border-t border-border flex justify-end gap-4 mt-8"> | |
| <button type="button" @onclick="BackToList" class="bg-body border border-border text-main px-8 py-3 rounded-xl font-bold hover:bg-card transition-colors">Cancel</button> | |
| <button type="submit" class="bg-primary text-white px-8 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Create Member</button> | |
| </div> | |
| </EditForm> | |
| } | |
| else if (_currentView == ViewMode.Edit) | |
| { | |
| <EditForm Model="@_updateRequest" OnValidSubmit="HandleUpdate" class="space-y-6"> | |
| <DataAnnotationsValidator /> | |
| <div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> | |
| <div class="sm:col-span-2"> | |
| <label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label> | |
| <InputText @bind-Value="_updateRequest.FullName" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" /> | |
| <ValidationMessage For="@(() => _updateRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Phone Number</label> | |
| <InputText @bind-Value="_updateRequest.PhoneNumber" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" /> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-bold text-main mb-2">Student ID</label> | |
| <InputText @bind-Value="_updateRequest.StudentId" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" /> | |
| </div> | |
| <div class="sm:col-span-2"> | |
| <label class="block text-sm font-bold text-main mb-2">Address</label> | |
| <InputTextArea @bind-Value="_updateRequest.Address" rows="3" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" /> | |
| </div> | |
| </div> | |
| <div class="pt-8 border-t border-border flex justify-end gap-4 mt-8"> | |
| <button type="button" @onclick="BackToList" class="bg-body border border-border text-main px-8 py-3 rounded-xl font-bold hover:bg-card transition-colors">Cancel</button> | |
| <button type="submit" class="bg-primary text-white px-8 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Update Member</button> | |
| </div> | |
| </EditForm> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| } | |
| else if (_activeTab == LibrarianTab.Approvals) | |
| { | |
| <div class="animate-fade"> | |
| <h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Membership Approvals</h1> | |
| <p class="text-muted text-lg mb-8">Review and approve pending "Pay Cash at Library" subscription requests.</p> | |
| @if (_isLoading) | |
| { | |
| <div class="space-y-4"> | |
| @for (int i = 0; i < 3; i++) | |
| { | |
| <div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div> | |
| } | |
| </div> | |
| } | |
| else if (_pendingSubscriptions == null || !_pendingSubscriptions.Any()) | |
| { | |
| <div class="bg-card rounded-3xl border border-dashed border-border p-20 text-center"> | |
| <div class="w-20 h-20 bg-primary/5 rounded-full flex items-center justify-center mx-auto mb-6 text-primary"> | |
| <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| </div> | |
| <h3 class="text-xl font-bold text-main mb-2">All Caught Up!</h3> | |
| <p class="text-muted">No pending membership requests to approve.</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="grid grid-cols-1 gap-6"> | |
| @foreach (var sub in _pendingSubscriptions) | |
| { | |
| <div class="bg-card border border-border rounded-3xl p-6 sm:p-8 flex flex-col md:flex-row md:items-center justify-between gap-6 shadow-sm hover:shadow-md transition-shadow"> | |
| <div class="flex items-center gap-6"> | |
| <div class="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center text-primary font-black text-xl"> | |
| @(sub.UserName?.Substring(0, Math.Min(2, sub.UserName.Length)).ToUpper() ?? "U") | |
| </div> | |
| <div> | |
| <h4 class="text-xl font-bold text-main mb-1">@sub.UserName</h4> | |
| <p class="text-sm text-muted mb-2">@sub.UserEmail</p> | |
| <div class="inline-flex items-center rounded-full bg-blue-500/10 px-3 py-1 text-[10px] font-black text-blue-500 tracking-widest uppercase border border-blue-500/20"> | |
| @sub.MembershipType | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-4 ml-auto sm:ml-0"> | |
| <button @onclick="() => HandleApproval(sub.Id, false)" class="bg-body border border-border text-red-500 hover:bg-red-500 hover:text-white hover:border-red-500 px-6 py-3 rounded-xl font-bold transition-all"> | |
| Reject | |
| </button> | |
| <button @onclick="() => HandleApproval(sub.Id, true)" class="bg-primary text-white px-8 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all"> | |
| Approve | |
| </button> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| @code { | |
| private enum ViewMode { List, Details, Create, Edit } | |
| private enum LibrarianTab { Directory, Approvals } | |
| private ViewMode _currentView = ViewMode.List; | |
| private LibrarianTab _activeTab = LibrarianTab.Directory; | |
| private IEnumerable<UserDto>? _allUsers; | |
| private PagedResult<UserDto>? _pagedResult; | |
| private IEnumerable<SubscriptionDto>? _pendingSubscriptions; | |
| private bool _isLoading = true; | |
| private const int PageSize = 10; | |
| private MemberDetailsViewModel? _selectedUser; | |
| private UserCreateRequest _createRequest = new(); | |
| private UserUpdateRequest _updateRequest = new(); | |
| private UserDto? _editUser; | |
| private decimal _topUpAmount; | |
| protected override async Task OnInitializedAsync() | |
| { | |
| await LoadData(); | |
| } | |
| private async Task LoadData() | |
| { | |
| _isLoading = true; | |
| try | |
| { | |
| _allUsers = await ApiClient.GetUsersAsync(); | |
| _pendingSubscriptions = await ApiClient.GetPendingSubscriptionsAsync(); | |
| ApplyPagination(1); | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error loading data: " + ex.Message); | |
| } | |
| finally | |
| { | |
| _isLoading = false; | |
| } | |
| } | |
| private async Task SetTab(LibrarianTab tab) | |
| { | |
| _activeTab = tab; | |
| _currentView = ViewMode.List; | |
| await LoadData(); | |
| } | |
| private async Task LoadUsers() | |
| { | |
| _isLoading = true; | |
| try | |
| { | |
| _allUsers = await ApiClient.GetUsersAsync(); | |
| ApplyPagination(1); | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error loading users: " + ex.Message); | |
| } | |
| finally | |
| { | |
| _isLoading = false; | |
| } | |
| } | |
| private void ApplyPagination(int page) | |
| { | |
| if (_allUsers == null) return; | |
| var totalCount = _allUsers.Count(); | |
| var totalPages = (int)Math.Ceiling(totalCount / (double)PageSize); | |
| page = Math.Max(1, Math.Min(page, Math.Max(1, totalPages))); | |
| _pagedResult = new PagedResult<UserDto> | |
| { | |
| Items = _allUsers.Skip((page - 1) * PageSize).Take(PageSize), | |
| CurrentPage = page, | |
| TotalPages = totalPages, | |
| TotalCount = totalCount, | |
| PageSize = PageSize | |
| }; | |
| } | |
| private void ChangePage(int page) | |
| { | |
| ApplyPagination(page); | |
| } | |
| private async Task ShowDetails(Guid userId) | |
| { | |
| _isLoading = true; | |
| try | |
| { | |
| var user = await ApiClient.GetUserAsync(userId); | |
| if (user == null) return; | |
| var memberships = await ApiClient.GetMembershipsAsync(); | |
| var subscription = await ApiClient.GetUserSubscriptionAsync(userId); | |
| _selectedUser = new MemberDetailsViewModel | |
| { | |
| User = user, | |
| AvailableMemberships = memberships.ToList(), | |
| CurrentSubscription = subscription | |
| }; | |
| _currentView = ViewMode.Details; | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error loading member details: " + ex.Message); | |
| } | |
| finally | |
| { | |
| _isLoading = false; | |
| } | |
| } | |
| private void ShowCreate() | |
| { | |
| _createRequest = new(); | |
| _currentView = ViewMode.Create; | |
| } | |
| private async Task ShowEdit(Guid userId) | |
| { | |
| _isLoading = true; | |
| try | |
| { | |
| _editUser = _allUsers?.FirstOrDefault(u => u.Id == userId); | |
| if (_editUser == null) | |
| { | |
| _editUser = await ApiClient.GetUserAsync(userId); | |
| } | |
| if (_editUser != null) | |
| { | |
| _updateRequest = new UserUpdateRequest | |
| { | |
| FullName = _editUser.FullName, | |
| PhoneNumber = _editUser.PhoneNumber, | |
| StudentId = _editUser.StudentId, | |
| Address = _editUser.Address | |
| }; | |
| _currentView = ViewMode.Edit; | |
| } | |
| } | |
| finally | |
| { | |
| _isLoading = false; | |
| } | |
| } | |
| private void BackToList() | |
| { | |
| _currentView = ViewMode.List; | |
| _selectedUser = null; | |
| _editUser = null; | |
| } | |
| private async Task HandleCreate() | |
| { | |
| try | |
| { | |
| var result = await ApiClient.CreateUserAsync(_createRequest); | |
| if (result != null) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Member created successfully!"); | |
| await LoadUsers(); | |
| BackToList(); | |
| } | |
| else | |
| { | |
| await JS.InvokeVoidAsync("alert", "Failed to create member."); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task HandleUpdate() | |
| { | |
| if (_editUser == null) return; | |
| try | |
| { | |
| var result = await ApiClient.UpdateUserAsync(_editUser.Id, _updateRequest); | |
| if (result != null) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Member updated successfully!"); | |
| await LoadUsers(); | |
| BackToList(); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task DeleteUser() | |
| { | |
| if (_selectedUser == null) return; | |
| bool confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to completely remove this member? This action cannot be undone."); | |
| if (!confirmed) return; | |
| try | |
| { | |
| var success = await ApiClient.DeleteUserAsync(_selectedUser.User.Id); | |
| if (success) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Member removed."); | |
| await LoadUsers(); | |
| BackToList(); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task UpdateRole() | |
| { | |
| if (_selectedUser == null) return; | |
| var newRole = _selectedUser.User.Role == "Librarian" ? "Member" : "Librarian"; | |
| try | |
| { | |
| var success = await ApiClient.UpdateUserRoleAsync(_selectedUser.User.Id, newRole); | |
| if (success) | |
| { | |
| await JS.InvokeVoidAsync("alert", $"Role updated to {newRole}."); | |
| await ShowDetails(_selectedUser.User.Id); // Refresh details | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task AssignSubscription(int membershipId, string planName) | |
| { | |
| if (_selectedUser == null) return; | |
| bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Assign {planName} plan to this member?"); | |
| if (!confirmed) return; | |
| try | |
| { | |
| var request = new AdminSubscribeRequest { UserId = _selectedUser.User.Id, MembershipId = membershipId }; | |
| var result = await ApiClient.AdminSubscribeAsync(request); | |
| if (result != null) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Subscription assigned successfully."); | |
| await ShowDetails(_selectedUser.User.Id); // Refresh details | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task TopUpWallet() | |
| { | |
| if (_selectedUser == null || _topUpAmount <= 0) return; | |
| bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Add {_topUpAmount:N0} MMK to {_selectedUser.User.FullName}'s wallet?"); | |
| if (!confirmed) return; | |
| try | |
| { | |
| var result = await ApiClient.TopUpWalletAsync(new TopUpRequest | |
| { | |
| UserId = _selectedUser.User.Id, | |
| Amount = _topUpAmount, | |
| Description = "Librarian Cash Top-up" | |
| }); | |
| if (result.Success) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Wallet topped up successfully!"); | |
| _topUpAmount = 0; | |
| await ShowDetails(_selectedUser.User.Id); // Refresh | |
| } | |
| else | |
| { | |
| await JS.InvokeVoidAsync("alert", "Failed: " + result.Message); | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| private async Task HandleApproval(Guid subscriptionId, bool approve) | |
| { | |
| try | |
| { | |
| var success = await ApiClient.ApproveSubscriptionAsync(new ApproveSubscriptionRequest | |
| { | |
| SubscriptionId = subscriptionId, | |
| Approve = approve | |
| }); | |
| if (success) | |
| { | |
| await JS.InvokeVoidAsync("alert", approve ? "Subscription approved!" : "Subscription rejected."); | |
| await LoadData(); // Refresh list | |
| } | |
| } | |
| catch (Exception ex) | |
| { | |
| await JS.InvokeVoidAsync("alert", "Error: " + ex.Message); | |
| } | |
| } | |
| } | |