Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
6469525
1
Parent(s): 0cba109
fix: add Librarian approval for pay in cash, balance update loading time fix, membership approval page added
Browse files- Backend/Features/Subscriptions/SubscriptionController.cs +20 -1
- Backend/Features/Subscriptions/SubscriptionService.cs +85 -14
- BlazorWebAssembly/Pages/Members.razor +462 -335
- BlazorWebAssembly/Pages/Membership.razor +23 -24
- BlazorWebAssembly/Services/LibraryApiClient.cs +15 -0
- DbConnect/Entities/UserSubscription.cs +3 -1
- LibraryManagement.Shared/Models/LibraryDtos.cs +8 -0
Backend/Features/Subscriptions/SubscriptionController.cs
CHANGED
|
@@ -60,9 +60,11 @@ namespace Backend.Features.Subscriptions
|
|
| 60 |
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 61 |
|
| 62 |
var userId = Guid.Parse(userIdStr);
|
| 63 |
-
|
|
|
|
| 64 |
return Ok(subscription);
|
| 65 |
}
|
|
|
|
| 66 |
catch (Exception ex)
|
| 67 |
{
|
| 68 |
return BadRequest(new { message = ex.Message });
|
|
@@ -129,5 +131,22 @@ namespace Backend.Features.Subscriptions
|
|
| 129 |
return BadRequest(new { message = ex.Message });
|
| 130 |
}
|
| 131 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
}
|
|
|
|
|
|
| 60 |
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
|
| 61 |
|
| 62 |
var userId = Guid.Parse(userIdStr);
|
| 63 |
+
// For cash payments, we create a pending subscription
|
| 64 |
+
var subscription = await _subscriptionService.CreatePendingSubscriptionAsync(userId, request.MembershipId);
|
| 65 |
return Ok(subscription);
|
| 66 |
}
|
| 67 |
+
|
| 68 |
catch (Exception ex)
|
| 69 |
{
|
| 70 |
return BadRequest(new { message = ex.Message });
|
|
|
|
| 131 |
return BadRequest(new { message = ex.Message });
|
| 132 |
}
|
| 133 |
}
|
| 134 |
+
|
| 135 |
+
[HttpGet("subscriptions/pending")]
|
| 136 |
+
[Authorize(Roles = "Librarian")]
|
| 137 |
+
public async Task<ActionResult<IEnumerable<SubscriptionDto>>> GetPendingSubscriptions()
|
| 138 |
+
{
|
| 139 |
+
return Ok(await _subscriptionService.GetPendingSubscriptionsAsync());
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
[HttpPost("subscriptions/approve")]
|
| 143 |
+
[Authorize(Roles = "Librarian")]
|
| 144 |
+
public async Task<IActionResult> ApproveSubscription([FromBody] ApproveSubscriptionRequest request)
|
| 145 |
+
{
|
| 146 |
+
var success = await _subscriptionService.ApproveSubscriptionAsync(request.SubscriptionId, request.Approve);
|
| 147 |
+
if (!success) return NotFound(new { message = "Subscription not found." });
|
| 148 |
+
return Ok(new { message = request.Approve ? "Subscription approved." : "Subscription rejected." });
|
| 149 |
+
}
|
| 150 |
}
|
| 151 |
}
|
| 152 |
+
|
Backend/Features/Subscriptions/SubscriptionService.cs
CHANGED
|
@@ -14,13 +14,17 @@ namespace Backend.Features.Subscriptions
|
|
| 14 |
Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId);
|
| 15 |
Task<IEnumerable<SubscriptionDto>> GetUserAllSubscriptionsAsync(Guid userId);
|
| 16 |
Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
|
| 17 |
-
Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null);
|
| 18 |
Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
|
|
|
|
|
|
|
|
|
|
| 19 |
Task<SubscriptionUpgradePreviewDto> GetUpgradePreviewAsync(Guid userId, int newMembershipId);
|
| 20 |
Task<SubscriptionDto> SubscribeWithWalletAsync(Guid userId, int membershipId);
|
| 21 |
}
|
| 22 |
|
| 23 |
|
|
|
|
| 24 |
public class SubscriptionService : ISubscriptionService
|
| 25 |
{
|
| 26 |
private readonly AppDbContext _context;
|
|
@@ -113,7 +117,8 @@ namespace Backend.Features.Subscriptions
|
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
| 116 |
-
public async Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null)
|
|
|
|
| 117 |
{
|
| 118 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 119 |
if (membership == null) throw new Exception("Membership plan not found.");
|
|
@@ -161,11 +166,13 @@ namespace Backend.Features.Subscriptions
|
|
| 161 |
MembershipId = membershipId,
|
| 162 |
StartDate = startDate,
|
| 163 |
ExpiryDate = startDate.AddMonths(membership.DurationMonths),
|
| 164 |
-
IsActive =
|
| 165 |
ExternalRedemptionId = redemptionId,
|
| 166 |
-
Status =
|
|
|
|
| 167 |
};
|
| 168 |
|
|
|
|
| 169 |
_context.UserSubscriptions.Add(subscription);
|
| 170 |
await _context.SaveChangesAsync();
|
| 171 |
|
|
@@ -176,15 +183,19 @@ namespace Backend.Features.Subscriptions
|
|
| 176 |
decimal paidAmount = membership.Price - discount;
|
| 177 |
if (paidAmount < 0) paidAmount = 0;
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
return MapToDto(subscription);
|
| 190 |
}
|
|
@@ -263,9 +274,66 @@ namespace Backend.Features.Subscriptions
|
|
| 263 |
if (!deducted) throw new Exception("Failed to deduct from wallet.");
|
| 264 |
|
| 265 |
// Create subscription
|
| 266 |
-
return await SubscribeUserAsync(userId, membershipId);
|
| 267 |
}
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 271 |
{
|
|
@@ -291,8 +359,11 @@ namespace Backend.Features.Subscriptions
|
|
| 291 |
StartDate = subscription.StartDate,
|
| 292 |
ExpiryDate = subscription.ExpiryDate,
|
| 293 |
IsActive = subscription.IsActive,
|
|
|
|
|
|
|
| 294 |
ExternalRedemptionId = subscription.ExternalRedemptionId
|
| 295 |
};
|
|
|
|
| 296 |
}
|
| 297 |
}
|
| 298 |
}
|
|
|
|
| 14 |
Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId);
|
| 15 |
Task<IEnumerable<SubscriptionDto>> GetUserAllSubscriptionsAsync(Guid userId);
|
| 16 |
Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
|
| 17 |
+
Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null, string status = "Active", string paymentMethod = "Cash");
|
| 18 |
Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
|
| 19 |
+
Task<IEnumerable<SubscriptionDto>> GetPendingSubscriptionsAsync();
|
| 20 |
+
Task<SubscriptionDto> CreatePendingSubscriptionAsync(Guid userId, int membershipId);
|
| 21 |
+
Task<bool> ApproveSubscriptionAsync(Guid subscriptionId, bool approve);
|
| 22 |
Task<SubscriptionUpgradePreviewDto> GetUpgradePreviewAsync(Guid userId, int newMembershipId);
|
| 23 |
Task<SubscriptionDto> SubscribeWithWalletAsync(Guid userId, int membershipId);
|
| 24 |
}
|
| 25 |
|
| 26 |
|
| 27 |
+
|
| 28 |
public class SubscriptionService : ISubscriptionService
|
| 29 |
{
|
| 30 |
private readonly AppDbContext _context;
|
|
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
| 120 |
+
public async Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null, string status = "Active", string paymentMethod = "Cash")
|
| 121 |
+
|
| 122 |
{
|
| 123 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 124 |
if (membership == null) throw new Exception("Membership plan not found.");
|
|
|
|
| 166 |
MembershipId = membershipId,
|
| 167 |
StartDate = startDate,
|
| 168 |
ExpiryDate = startDate.AddMonths(membership.DurationMonths),
|
| 169 |
+
IsActive = status == "Active",
|
| 170 |
ExternalRedemptionId = redemptionId,
|
| 171 |
+
Status = status,
|
| 172 |
+
PaymentMethod = paymentMethod
|
| 173 |
};
|
| 174 |
|
| 175 |
+
|
| 176 |
_context.UserSubscriptions.Add(subscription);
|
| 177 |
await _context.SaveChangesAsync();
|
| 178 |
|
|
|
|
| 183 |
decimal paidAmount = membership.Price - discount;
|
| 184 |
if (paidAmount < 0) paidAmount = 0;
|
| 185 |
|
| 186 |
+
if (status == "Active")
|
| 187 |
+
{
|
| 188 |
+
await _loyaltyService.ProcessEventAsync(
|
| 189 |
+
externalUserId: userId.ToString(),
|
| 190 |
+
eventKey: "SUBSCRIBE",
|
| 191 |
+
eventValue: (double)paidAmount,
|
| 192 |
+
referenceId: $"SUB-{subscription.Id}",
|
| 193 |
+
description: $"Purchased Membership: {membership.Type} (Discount applied: {discount})",
|
| 194 |
+
email: subscription.User?.Email ?? "No Email",
|
| 195 |
+
mobile: subscription.User?.PhoneNumber ?? "0000000000"
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
|
| 200 |
return MapToDto(subscription);
|
| 201 |
}
|
|
|
|
| 274 |
if (!deducted) throw new Exception("Failed to deduct from wallet.");
|
| 275 |
|
| 276 |
// Create subscription
|
| 277 |
+
return await SubscribeUserAsync(userId, membershipId, status: "Active", paymentMethod: "Wallet");
|
| 278 |
}
|
| 279 |
|
| 280 |
+
public async Task<IEnumerable<SubscriptionDto>> GetPendingSubscriptionsAsync()
|
| 281 |
+
{
|
| 282 |
+
var pending = await _context.UserSubscriptions
|
| 283 |
+
.Include(s => s.Membership)
|
| 284 |
+
.Include(s => s.User)
|
| 285 |
+
.Where(s => s.Status == "PendingApproval")
|
| 286 |
+
.OrderByDescending(s => s.StartDate)
|
| 287 |
+
.ToListAsync();
|
| 288 |
+
|
| 289 |
+
return pending.Select(MapToDto);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
public async Task<SubscriptionDto> CreatePendingSubscriptionAsync(Guid userId, int membershipId)
|
| 293 |
+
{
|
| 294 |
+
// Similar logic to SubscribeUserAsync but set to PendingApproval
|
| 295 |
+
return await SubscribeUserAsync(userId, membershipId, status: "PendingApproval", paymentMethod: "Cash");
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
public async Task<bool> ApproveSubscriptionAsync(Guid subscriptionId, bool approve)
|
| 299 |
+
{
|
| 300 |
+
var subscription = await _context.UserSubscriptions
|
| 301 |
+
.Include(s => s.Membership)
|
| 302 |
+
.Include(s => s.User)
|
| 303 |
+
.FirstOrDefaultAsync(s => s.Id == subscriptionId);
|
| 304 |
+
|
| 305 |
+
if (subscription == null) return false;
|
| 306 |
+
|
| 307 |
+
if (approve)
|
| 308 |
+
{
|
| 309 |
+
subscription.Status = "Active";
|
| 310 |
+
subscription.IsActive = true;
|
| 311 |
+
|
| 312 |
+
// Award points now
|
| 313 |
+
decimal discount = 0; // Simplified for approval flow for now
|
| 314 |
+
decimal paidAmount = subscription.Membership.Price - discount;
|
| 315 |
+
|
| 316 |
+
await _loyaltyService.ProcessEventAsync(
|
| 317 |
+
externalUserId: subscription.UserId.ToString(),
|
| 318 |
+
eventKey: "SUBSCRIBE",
|
| 319 |
+
eventValue: (double)paidAmount,
|
| 320 |
+
referenceId: $"SUB-{subscription.Id}",
|
| 321 |
+
description: $"Membership Approved: {subscription.Membership.Type}",
|
| 322 |
+
email: subscription.User?.Email ?? "No Email",
|
| 323 |
+
mobile: subscription.User?.PhoneNumber ?? "0000000000"
|
| 324 |
+
);
|
| 325 |
+
}
|
| 326 |
+
else
|
| 327 |
+
{
|
| 328 |
+
subscription.Status = "Rejected";
|
| 329 |
+
subscription.IsActive = false;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
await _context.SaveChangesAsync();
|
| 333 |
+
return true;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
|
| 338 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 339 |
{
|
|
|
|
| 359 |
StartDate = subscription.StartDate,
|
| 360 |
ExpiryDate = subscription.ExpiryDate,
|
| 361 |
IsActive = subscription.IsActive,
|
| 362 |
+
Status = subscription.Status,
|
| 363 |
+
PaymentMethod = subscription.PaymentMethod,
|
| 364 |
ExternalRedemptionId = subscription.ExternalRedemptionId
|
| 365 |
};
|
| 366 |
+
|
| 367 |
}
|
| 368 |
}
|
| 369 |
}
|
BlazorWebAssembly/Pages/Members.razor
CHANGED
|
@@ -9,403 +9,484 @@
|
|
| 9 |
@inject NavigationManager Navigation
|
| 10 |
|
| 11 |
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
<
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
</div>
|
| 28 |
-
</div>
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
{
|
| 32 |
-
<div class="
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
<
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</div>
|
| 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 |
</div>
|
| 69 |
-
</
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
@user.Role
|
| 78 |
-
</span>
|
| 79 |
-
</td>
|
| 80 |
-
<td class="px-6 py-4">
|
| 81 |
-
@if (user.BanStatus == true)
|
| 82 |
-
{
|
| 83 |
-
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-red-500/10 text-red-500">
|
| 84 |
-
Suspended
|
| 85 |
</span>
|
| 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 |
-
var pageNumber = p;
|
| 134 |
-
if (p == _pagedResult.CurrentPage)
|
| 135 |
-
{
|
| 136 |
-
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-main text-body text-xs font-bold">@p</span>
|
| 137 |
-
}
|
| 138 |
-
else if (p == 1 || p == _pagedResult.TotalPages || (p >= _pagedResult.CurrentPage - 1 && p <= _pagedResult.CurrentPage + 1))
|
| 139 |
{
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
-
}
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
</div>
|
| 154 |
-
</div>
|
| 155 |
-
}
|
| 156 |
-
</div>
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
else if (_currentView == ViewMode.Details && _selectedUser != null)
|
| 160 |
-
{
|
| 161 |
-
<div class="animate-fade">
|
| 162 |
-
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 163 |
-
<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>
|
| 164 |
-
Back to Directory
|
| 165 |
-
</button>
|
| 166 |
-
|
| 167 |
-
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 168 |
-
<div class="lg:col-span-1">
|
| 169 |
-
<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">
|
| 170 |
-
<div class="absolute inset-0 bg-primary/5"></div>
|
| 171 |
-
<div class="relative z-10 flex flex-col items-center">
|
| 172 |
-
<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">
|
| 173 |
-
@(_selectedUser.User.FullName?.Substring(0, Math.Min(2, _selectedUser.User.FullName.Length)).ToUpper() ?? "U")
|
| 174 |
-
</div>
|
| 175 |
-
<h2 class="text-2xl font-bold text-center mb-2">@_selectedUser.User.FullName</h2>
|
| 176 |
-
<div class="inline-flex rounded-full px-4 py-1.5 text-[10px] font-black uppercase tracking-widest @(_selectedUser.User.Role == "Admin" ? "bg-amber-500/10 text-amber-500 border border-amber-500/20" : "bg-blue-500/10 text-blue-500 border border-blue-500/20")">
|
| 177 |
-
@_selectedUser.User.Role
|
| 178 |
</div>
|
| 179 |
</div>
|
| 180 |
-
|
| 181 |
</div>
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
|
| 198 |
-
<div class="
|
| 199 |
-
<div>
|
| 200 |
-
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Email Address</h4>
|
| 201 |
-
<p class="text-sm font-semibold text-main">@_selectedUser.User.Email</p>
|
| 202 |
-
</div>
|
| 203 |
-
<div>
|
| 204 |
-
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Phone Number</h4>
|
| 205 |
-
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.PhoneNumber) ? "-" : _selectedUser.User.PhoneNumber)</p>
|
| 206 |
-
</div>
|
| 207 |
-
<div>
|
| 208 |
-
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Student ID</h4>
|
| 209 |
-
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.StudentId) ? "-" : _selectedUser.User.StudentId)</p>
|
| 210 |
-
</div>
|
| 211 |
<div>
|
| 212 |
-
<
|
| 213 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
</div>
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</div>
|
| 219 |
-
</div>
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
</div>
|
| 250 |
-
<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" />
|
| 251 |
</div>
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</div>
|
| 256 |
-
<p class="mt-4 text-xs text-muted font-medium italic">Credits will be added instantly for automated subscription purchases.</p>
|
| 257 |
</div>
|
| 258 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
<div class="mt-12 pt-12 border-t border-border">
|
| 263 |
-
<h3 class="text-2xl font-bold text-main mb-8">Manage Subscription</h3>
|
| 264 |
-
|
| 265 |
-
@if (_selectedUser.CurrentSubscription != null && _selectedUser.CurrentSubscription.IsActive)
|
| 266 |
{
|
| 267 |
-
<
|
| 268 |
-
<
|
| 269 |
-
|
| 270 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
</div>
|
| 272 |
-
<div class="
|
| 273 |
-
<
|
| 274 |
-
<
|
| 275 |
</div>
|
| 276 |
-
</
|
| 277 |
}
|
| 278 |
-
else
|
| 279 |
{
|
| 280 |
-
<
|
| 281 |
-
<
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
<
|
| 294 |
-
|
| 295 |
-
<
|
| 296 |
-
<
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
<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>
|
| 301 |
-
}
|
| 302 |
-
else
|
| 303 |
-
{
|
| 304 |
-
<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>
|
| 305 |
-
}
|
| 306 |
</div>
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</div>
|
| 310 |
</div>
|
| 311 |
</div>
|
| 312 |
-
|
| 313 |
}
|
| 314 |
-
else if (
|
| 315 |
{
|
| 316 |
<div class="animate-fade">
|
| 317 |
-
<
|
| 318 |
-
|
| 319 |
-
Back to Directory
|
| 320 |
-
</button>
|
| 321 |
-
|
| 322 |
-
<div class="max-w-3xl mx-auto">
|
| 323 |
-
<div class="mb-10 text-center">
|
| 324 |
-
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-3">@(_currentView == ViewMode.Create ? "Register New Member" : "Edit Member Details")</h1>
|
| 325 |
-
<p class="text-lg text-muted">@(_currentView == ViewMode.Create ? "Add a new reader to the library system." : $"Update profile details for {_editUser?.FullName}.")</p>
|
| 326 |
-
</div>
|
| 327 |
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
| 330 |
{
|
| 331 |
-
<
|
| 332 |
-
<DataAnnotationsValidator />
|
| 333 |
-
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 334 |
-
<div class="sm:col-span-2">
|
| 335 |
-
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 336 |
-
<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" />
|
| 337 |
-
<ValidationMessage For="@(() => _createRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 338 |
-
</div>
|
| 339 |
-
<div>
|
| 340 |
-
<label class="block text-sm font-bold text-main mb-2">Email Address <span class="text-red-500">*</span></label>
|
| 341 |
-
<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" />
|
| 342 |
-
<ValidationMessage For="@(() => _createRequest.Email)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 343 |
-
</div>
|
| 344 |
-
<div>
|
| 345 |
-
<label class="block text-sm font-bold text-main mb-2">Password <span class="text-red-500">*</span></label>
|
| 346 |
-
<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="••••••••" />
|
| 347 |
-
<ValidationMessage For="@(() => _createRequest.Password)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 348 |
-
</div>
|
| 349 |
-
<div>
|
| 350 |
-
<label class="block text-sm font-bold text-main mb-2">Phone Number</label>
|
| 351 |
-
<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" />
|
| 352 |
-
</div>
|
| 353 |
-
<div>
|
| 354 |
-
<label class="block text-sm font-bold text-main mb-2">Student ID</label>
|
| 355 |
-
<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" />
|
| 356 |
-
</div>
|
| 357 |
-
<div class="sm:col-span-2">
|
| 358 |
-
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 359 |
-
<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" />
|
| 360 |
-
</div>
|
| 361 |
-
</div>
|
| 362 |
-
<div class="pt-8 border-t border-border flex justify-end gap-4 mt-8">
|
| 363 |
-
<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>
|
| 364 |
-
<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>
|
| 365 |
-
</div>
|
| 366 |
-
</EditForm>
|
| 367 |
}
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
{
|
| 370 |
-
<
|
| 371 |
-
<
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 375 |
-
<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" />
|
| 376 |
-
<ValidationMessage For="@(() => _updateRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 377 |
</div>
|
| 378 |
<div>
|
| 379 |
-
<
|
| 380 |
-
<
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
<
|
| 384 |
-
<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" />
|
| 385 |
-
</div>
|
| 386 |
-
<div class="sm:col-span-2">
|
| 387 |
-
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 388 |
-
<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" />
|
| 389 |
</div>
|
| 390 |
</div>
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
</div>
|
| 395 |
-
</
|
| 396 |
}
|
| 397 |
</div>
|
| 398 |
-
|
| 399 |
</div>
|
| 400 |
}
|
| 401 |
</div>
|
| 402 |
|
| 403 |
@code {
|
| 404 |
private enum ViewMode { List, Details, Create, Edit }
|
|
|
|
|
|
|
| 405 |
private ViewMode _currentView = ViewMode.List;
|
|
|
|
| 406 |
|
| 407 |
private IEnumerable<UserDto>? _allUsers;
|
| 408 |
private PagedResult<UserDto>? _pagedResult;
|
|
|
|
| 409 |
private bool _isLoading = true;
|
| 410 |
private const int PageSize = 10;
|
| 411 |
|
|
@@ -418,7 +499,33 @@
|
|
| 418 |
|
| 419 |
protected override async Task OnInitializedAsync()
|
| 420 |
{
|
| 421 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
}
|
| 423 |
|
| 424 |
private async Task LoadUsers()
|
|
@@ -669,6 +776,26 @@
|
|
| 669 |
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 670 |
}
|
| 671 |
}
|
| 672 |
-
}
|
| 673 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
@inject NavigationManager Navigation
|
| 10 |
|
| 11 |
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 12 |
+
<!-- Top Tabs Navigation -->
|
| 13 |
+
<div class="flex items-center gap-8 border-b border-border mb-10">
|
| 14 |
+
<button @onclick="() => SetTab(AdminTab.Directory)" class="pb-4 px-2 text-sm font-black uppercase tracking-widest transition-all relative @(_activeTab == AdminTab.Directory ? "text-primary" : "text-muted hover:text-main")">
|
| 15 |
+
Member Directory
|
| 16 |
+
@if (_activeTab == AdminTab.Directory) { <div class="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-t-full"></div> }
|
| 17 |
+
</button>
|
| 18 |
+
<button @onclick="() => SetTab(AdminTab.Approvals)" class="pb-4 px-2 text-sm font-black uppercase tracking-widest transition-all relative @(_activeTab == AdminTab.Approvals ? "text-primary" : "text-muted hover:text-main")">
|
| 19 |
+
Membership Approvals
|
| 20 |
+
@if (_pendingSubscriptions?.Any() == true)
|
| 21 |
+
{
|
| 22 |
+
<span class="ml-2 bg-red-500 text-white text-[10px] px-2 py-0.5 rounded-full">@_pendingSubscriptions.Count()</span>
|
| 23 |
+
}
|
| 24 |
+
@if (_activeTab == AdminTab.Approvals) { <div class="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-t-full"></div> }
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
@if (_activeTab == AdminTab.Directory)
|
| 29 |
+
{
|
| 30 |
+
@if (_currentView == ViewMode.List)
|
| 31 |
{
|
| 32 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
| 33 |
+
<div>
|
| 34 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Members Directory</h1>
|
| 35 |
+
<p class="text-muted text-lg">
|
| 36 |
+
Manage library members and administrator accounts.
|
| 37 |
+
<span class="ml-2 text-xs text-muted font-bold">(@(_allUsers?.Count() ?? 0) total)</span>
|
| 38 |
+
</p>
|
| 39 |
+
</div>
|
| 40 |
+
<div class="flex items-center gap-3">
|
| 41 |
+
<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">
|
| 42 |
+
<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>
|
| 43 |
+
Add Member
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
</div>
|
| 47 |
+
|
| 48 |
+
@if (_isLoading)
|
| 49 |
+
{
|
| 50 |
+
<div class="space-y-4">
|
| 51 |
+
@for (int i = 0; i < 5; i++)
|
| 52 |
+
{
|
| 53 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div>
|
| 54 |
+
}
|
| 55 |
+
</div>
|
| 56 |
+
}
|
| 57 |
+
else if (_pagedResult != null)
|
| 58 |
+
{
|
| 59 |
+
<div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
|
| 60 |
+
<div class="overflow-x-auto">
|
| 61 |
+
<table class="w-full text-left border-collapse min-w-max whitespace-nowrap">
|
| 62 |
+
<thead class="bg-body border-b border-border">
|
| 63 |
+
<tr>
|
| 64 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
|
| 65 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Contact</th>
|
| 66 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Role</th>
|
| 67 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th>
|
| 68 |
+
<th class="px-6 py-4 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Actions</th>
|
| 69 |
+
</tr>
|
| 70 |
+
</thead>
|
| 71 |
+
<tbody class="divide-y divide-border">
|
| 72 |
+
@foreach (var user in _pagedResult.Items)
|
| 73 |
+
{
|
| 74 |
+
<tr class="hover:bg-body transition-colors group">
|
| 75 |
+
<td class="px-6 py-4">
|
| 76 |
+
<div class="flex items-center gap-4">
|
| 77 |
+
<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">
|
| 78 |
+
@(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U")
|
| 79 |
+
</div>
|
| 80 |
+
<div>
|
| 81 |
+
<button @onclick="() => ShowDetails(user.Id)" class="text-sm font-bold text-main hover:text-primary transition-colors text-left">@user.FullName</button>
|
| 82 |
+
@if (!string.IsNullOrEmpty(user.StudentId))
|
| 83 |
+
{
|
| 84 |
+
<div class="text-xs text-muted font-medium mt-0.5">ID: @user.StudentId</div>
|
| 85 |
+
}
|
| 86 |
+
</div>
|
| 87 |
</div>
|
| 88 |
+
</td>
|
| 89 |
+
<td class="px-6 py-4">
|
| 90 |
+
<div class="text-sm text-main font-medium">@user.Email</div>
|
| 91 |
+
<div class="text-xs text-muted">@(string.IsNullOrEmpty(user.PhoneNumber) ? "No phone" : user.PhoneNumber)</div>
|
| 92 |
+
</td>
|
| 93 |
+
<td class="px-6 py-4">
|
| 94 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase @(user.Role == "Admin" ? "bg-amber-500/10 text-amber-500" : "bg-blue-500/10 text-blue-500")">
|
| 95 |
+
@user.Role
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
</span>
|
| 97 |
+
</td>
|
| 98 |
+
<td class="px-6 py-4">
|
| 99 |
+
@if (user.BanStatus == true)
|
| 100 |
+
{
|
| 101 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-red-500/10 text-red-500">
|
| 102 |
+
Suspended
|
| 103 |
+
</span>
|
| 104 |
+
}
|
| 105 |
+
else if (user.IsActive)
|
| 106 |
+
{
|
| 107 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-green-500/10 text-green-500">
|
| 108 |
+
Active
|
| 109 |
+
</span>
|
| 110 |
+
}
|
| 111 |
+
else
|
| 112 |
+
{
|
| 113 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-body text-muted border border-border">
|
| 114 |
+
Inactive
|
| 115 |
+
</span>
|
| 116 |
+
}
|
| 117 |
+
</td>
|
| 118 |
+
<td class="px-6 py-4 text-right">
|
| 119 |
+
<div class="flex items-center justify-end gap-2">
|
| 120 |
+
<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">
|
| 121 |
+
<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>
|
| 122 |
+
</button>
|
| 123 |
+
<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">
|
| 124 |
+
<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>
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</td>
|
| 128 |
+
</tr>
|
| 129 |
+
}
|
| 130 |
+
</tbody>
|
| 131 |
+
</table>
|
| 132 |
+
</div>
|
| 133 |
|
| 134 |
+
@if (_pagedResult.TotalPages > 1)
|
| 135 |
+
{
|
| 136 |
+
<div class="px-6 py-4 border-t border-border flex items-center justify-between bg-body">
|
| 137 |
+
<p class="text-xs text-muted">
|
| 138 |
+
Showing <span class="font-bold text-main">@((_pagedResult.CurrentPage - 1) * _pagedResult.PageSize + 1)</span>
|
| 139 |
+
to
|
| 140 |
+
<span class="font-bold text-main">@(Math.Min(_pagedResult.CurrentPage * _pagedResult.PageSize, _pagedResult.TotalCount))</span>
|
| 141 |
+
of <span class="font-bold text-main">@_pagedResult.TotalCount</span> members
|
| 142 |
+
</p>
|
| 143 |
+
<div class="flex items-center gap-2">
|
| 144 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage - 1)" disabled="@(!_pagedResult.HasPreviousPage)"
|
| 145 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 146 |
+
<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>
|
| 147 |
+
</button>
|
| 148 |
|
| 149 |
+
@for (int p = 1; p <= _pagedResult.TotalPages; p++)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
{
|
| 151 |
+
var pageNumber = p;
|
| 152 |
+
if (p == _pagedResult.CurrentPage)
|
| 153 |
+
{
|
| 154 |
+
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-main text-body text-xs font-bold">@p</span>
|
| 155 |
+
}
|
| 156 |
+
else if (p == 1 || p == _pagedResult.TotalPages || (p >= _pagedResult.CurrentPage - 1 && p <= _pagedResult.CurrentPage + 1))
|
| 157 |
+
{
|
| 158 |
+
<button @onclick="() => ChangePage(pageNumber)"
|
| 159 |
+
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>
|
| 160 |
+
}
|
| 161 |
+
else if (p == _pagedResult.CurrentPage - 2 || p == _pagedResult.CurrentPage + 2)
|
| 162 |
+
{
|
| 163 |
+
<span class="w-8 h-8 flex items-center justify-center text-xs text-muted">...</span>
|
| 164 |
+
}
|
| 165 |
}
|
|
|
|
| 166 |
|
| 167 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage + 1)" disabled="@(!_pagedResult.HasNextPage)"
|
| 168 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 169 |
+
<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>
|
| 170 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
+
}
|
| 174 |
</div>
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
else if (_currentView == ViewMode.Details && _selectedUser != null)
|
| 178 |
+
{
|
| 179 |
+
<div class="animate-fade">
|
| 180 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 181 |
+
<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>
|
| 182 |
+
Back to Directory
|
| 183 |
+
</button>
|
| 184 |
+
|
| 185 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 186 |
+
<div class="lg:col-span-1">
|
| 187 |
+
<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">
|
| 188 |
+
<div class="absolute inset-0 bg-primary/5"></div>
|
| 189 |
+
<div class="relative z-10 flex flex-col items-center">
|
| 190 |
+
<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">
|
| 191 |
+
@(_selectedUser.User.FullName?.Substring(0, Math.Min(2, _selectedUser.User.FullName.Length)).ToUpper() ?? "U")
|
| 192 |
+
</div>
|
| 193 |
+
<h2 class="text-2xl font-bold text-center mb-2">@_selectedUser.User.FullName</h2>
|
| 194 |
+
<div class="inline-flex rounded-full px-4 py-1.5 text-[10px] font-black uppercase tracking-widest @(_selectedUser.User.Role == "Admin" ? "bg-amber-500/10 text-amber-500 border border-amber-500/20" : "bg-blue-500/10 text-blue-500 border border-blue-500/20")">
|
| 195 |
+
@_selectedUser.User.Role
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
</div>
|
| 199 |
</div>
|
| 200 |
|
| 201 |
+
<div class="lg:col-span-2 space-y-8">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
<div>
|
| 203 |
+
<h1 class="text-4xl font-extrabold text-main tracking-tight mb-4">Member Profile</h1>
|
| 204 |
+
<div class="flex items-center gap-3">
|
| 205 |
+
@if (_selectedUser.User.BanStatus == true)
|
| 206 |
+
{
|
| 207 |
+
<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>
|
| 208 |
+
}
|
| 209 |
+
else if (_selectedUser.User.IsActive)
|
| 210 |
+
{
|
| 211 |
+
<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>
|
| 212 |
+
}
|
| 213 |
+
</div>
|
| 214 |
</div>
|
| 215 |
+
|
| 216 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8 border-y border-border py-8">
|
| 217 |
+
<div>
|
| 218 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Email Address</h4>
|
| 219 |
+
<p class="text-sm font-semibold text-main">@_selectedUser.User.Email</p>
|
| 220 |
+
</div>
|
| 221 |
+
<div>
|
| 222 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Phone Number</h4>
|
| 223 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.PhoneNumber) ? "-" : _selectedUser.User.PhoneNumber)</p>
|
| 224 |
+
</div>
|
| 225 |
+
<div>
|
| 226 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Student ID</h4>
|
| 227 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.StudentId) ? "-" : _selectedUser.User.StudentId)</p>
|
| 228 |
+
</div>
|
| 229 |
+
<div>
|
| 230 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Joined Date</h4>
|
| 231 |
+
<p class="text-sm font-semibold text-main">@_selectedUser.User.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="sm:col-span-2">
|
| 234 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Physical Address</h4>
|
| 235 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.Address) ? "-" : _selectedUser.User.Address)</p>
|
| 236 |
+
</div>
|
| 237 |
</div>
|
|
|
|
| 238 |
|
| 239 |
+
<div class="flex flex-wrap items-center gap-4 pt-2">
|
| 240 |
+
<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>
|
| 241 |
+
|
| 242 |
+
<button @onclick="UpdateRole" class="bg-body border border-border text-main py-3 px-8 rounded-xl font-bold hover:bg-card transition-colors">
|
| 243 |
+
Make @(_selectedUser.User.Role == "Admin" ? "Member" : "Admin")
|
| 244 |
+
</button>
|
| 245 |
|
| 246 |
+
<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">
|
| 247 |
+
<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>
|
| 248 |
+
</button>
|
| 249 |
+
</div>
|
| 250 |
|
| 251 |
+
<!-- Wallet Management Section -->
|
| 252 |
+
<div class="mt-12 pt-12 border-t border-border">
|
| 253 |
+
<div class="flex items-center justify-between mb-8">
|
| 254 |
+
<h3 class="text-2xl font-bold text-main">Library Wallet</h3>
|
| 255 |
+
<div class="bg-primary/5 border border-primary/20 px-6 py-3 rounded-2xl">
|
| 256 |
+
<span class="text-[10px] font-black text-primary uppercase tracking-widest block mb-1">Current Balance</span>
|
| 257 |
+
<span class="text-2xl font-black text-main">@_selectedUser.User.Balance.ToString("N0") MMK</span>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<div class="bg-card border border-border rounded-3xl p-8 shadow-sm">
|
| 262 |
+
<h4 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-6">Add Credits (Cash Deposit)</h4>
|
| 263 |
+
<div class="flex flex-col sm:flex-row gap-4">
|
| 264 |
+
<div class="flex-1 relative">
|
| 265 |
+
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
| 266 |
+
<span class="text-muted text-xs font-black">MMK</span>
|
| 267 |
+
</div>
|
| 268 |
+
<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" />
|
| 269 |
+
</div>
|
| 270 |
+
<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)">
|
| 271 |
+
Confirm Deposit
|
| 272 |
+
</button>
|
| 273 |
+
</div>
|
| 274 |
+
<p class="mt-4 text-xs text-muted font-medium italic">Credits will be added instantly for automated subscription purchases.</p>
|
| 275 |
</div>
|
| 276 |
</div>
|
| 277 |
|
| 278 |
+
|
| 279 |
+
<!-- Subscription Management -->
|
| 280 |
+
<div class="mt-12 pt-12 border-t border-border">
|
| 281 |
+
<h3 class="text-2xl font-bold text-main mb-8">Manage Subscription</h3>
|
| 282 |
+
|
| 283 |
+
@if (_selectedUser.CurrentSubscription != null && _selectedUser.CurrentSubscription.IsActive)
|
| 284 |
+
{
|
| 285 |
+
<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">
|
| 286 |
+
<div>
|
| 287 |
+
<span class="text-[10px] text-green-600 font-black uppercase tracking-widest mb-1 block">Active Plan</span>
|
| 288 |
+
<h4 class="text-2xl font-black text-green-700">@_selectedUser.CurrentSubscription.MembershipType</h4>
|
| 289 |
+
</div>
|
| 290 |
+
<div class="sm:text-right">
|
| 291 |
+
<div class="text-[10px] text-green-600/70 font-black uppercase tracking-widest mb-1">Expires On</div>
|
| 292 |
+
<div class="font-bold text-green-700">@_selectedUser.CurrentSubscription.ExpiryDate.ToString("MMMM dd, yyyy")</div>
|
| 293 |
</div>
|
|
|
|
| 294 |
</div>
|
| 295 |
+
}
|
| 296 |
+
else
|
| 297 |
+
{
|
| 298 |
+
<div class="bg-card border border-dashed border-border rounded-2xl p-6 mb-8 text-center">
|
| 299 |
+
<p class="text-muted font-medium">No active subscription for this member.</p>
|
| 300 |
+
</div>
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
<h4 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-6">Assign New Plan</h4>
|
| 304 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 305 |
+
@foreach (var plan in _selectedUser.AvailableMemberships)
|
| 306 |
+
{
|
| 307 |
+
var isCurrent = _selectedUser.CurrentSubscription?.IsActive == true && _selectedUser.CurrentSubscription.MembershipId == plan.Id;
|
| 308 |
+
<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")">
|
| 309 |
+
@if (isCurrent)
|
| 310 |
+
{
|
| 311 |
+
<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>
|
| 312 |
+
}
|
| 313 |
+
<h5 class="font-bold text-main text-xl mb-1">@plan.Type</h5>
|
| 314 |
+
<p class="text-muted font-medium text-sm mb-6 flex-grow">@plan.Price.ToString("N0") MMK / @plan.DurationMonths mo</p>
|
| 315 |
+
|
| 316 |
+
@if (!isCurrent)
|
| 317 |
+
{
|
| 318 |
+
<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>
|
| 319 |
+
}
|
| 320 |
+
else
|
| 321 |
+
{
|
| 322 |
+
<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>
|
| 323 |
+
}
|
| 324 |
+
</div>
|
| 325 |
+
}
|
| 326 |
</div>
|
|
|
|
| 327 |
</div>
|
| 328 |
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
}
|
| 332 |
+
else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit)
|
| 333 |
+
{
|
| 334 |
+
<div class="animate-fade">
|
| 335 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 336 |
+
<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>
|
| 337 |
+
Back to Directory
|
| 338 |
+
</button>
|
| 339 |
+
|
| 340 |
+
<div class="max-w-3xl mx-auto">
|
| 341 |
+
<div class="mb-10 text-center">
|
| 342 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-3">@(_currentView == ViewMode.Create ? "Register New Member" : "Edit Member Details")</h1>
|
| 343 |
+
<p class="text-lg text-muted">@(_currentView == ViewMode.Create ? "Add a new reader to the library system." : $"Update profile details for {_editUser?.FullName}.")</p>
|
| 344 |
+
</div>
|
| 345 |
|
| 346 |
+
<div class="bg-card border border-border p-8 sm:p-10 rounded-3xl shadow-sm">
|
| 347 |
+
@if (_currentView == ViewMode.Create)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
{
|
| 349 |
+
<EditForm Model="@_createRequest" OnValidSubmit="HandleCreate" class="space-y-6">
|
| 350 |
+
<DataAnnotationsValidator />
|
| 351 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 352 |
+
<div class="sm:col-span-2">
|
| 353 |
+
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 354 |
+
<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" />
|
| 355 |
+
<ValidationMessage For="@(() => _createRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 356 |
+
</div>
|
| 357 |
+
<div>
|
| 358 |
+
<label class="block text-sm font-bold text-main mb-2">Email Address <span class="text-red-500">*</span></label>
|
| 359 |
+
<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" />
|
| 360 |
+
<ValidationMessage For="@(() => _createRequest.Email)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 361 |
+
</div>
|
| 362 |
+
<div>
|
| 363 |
+
<label class="block text-sm font-bold text-main mb-2">Password <span class="text-red-500">*</span></label>
|
| 364 |
+
<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="••••••••" />
|
| 365 |
+
<ValidationMessage For="@(() => _createRequest.Password)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 366 |
+
</div>
|
| 367 |
+
<div>
|
| 368 |
+
<label class="block text-sm font-bold text-main mb-2">Phone Number</label>
|
| 369 |
+
<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" />
|
| 370 |
+
</div>
|
| 371 |
+
<div>
|
| 372 |
+
<label class="block text-sm font-bold text-main mb-2">Student ID</label>
|
| 373 |
+
<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" />
|
| 374 |
+
</div>
|
| 375 |
+
<div class="sm:col-span-2">
|
| 376 |
+
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 377 |
+
<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" />
|
| 378 |
+
</div>
|
| 379 |
</div>
|
| 380 |
+
<div class="pt-8 border-t border-border flex justify-end gap-4 mt-8">
|
| 381 |
+
<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>
|
| 382 |
+
<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>
|
| 383 |
</div>
|
| 384 |
+
</EditForm>
|
| 385 |
}
|
| 386 |
+
else if (_currentView == ViewMode.Edit)
|
| 387 |
{
|
| 388 |
+
<EditForm Model="@_updateRequest" OnValidSubmit="HandleUpdate" class="space-y-6">
|
| 389 |
+
<DataAnnotationsValidator />
|
| 390 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 391 |
+
<div class="sm:col-span-2">
|
| 392 |
+
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 393 |
+
<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" />
|
| 394 |
+
<ValidationMessage For="@(() => _updateRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 395 |
+
</div>
|
| 396 |
+
<div>
|
| 397 |
+
<label class="block text-sm font-bold text-main mb-2">Phone Number</label>
|
| 398 |
+
<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" />
|
| 399 |
+
</div>
|
| 400 |
+
<div>
|
| 401 |
+
<label class="block text-sm font-bold text-main mb-2">Student ID</label>
|
| 402 |
+
<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" />
|
| 403 |
+
</div>
|
| 404 |
+
<div class="sm:col-span-2">
|
| 405 |
+
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 406 |
+
<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" />
|
| 407 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</div>
|
| 409 |
+
<div class="pt-8 border-t border-border flex justify-end gap-4 mt-8">
|
| 410 |
+
<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>
|
| 411 |
+
<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>
|
| 412 |
+
</div>
|
| 413 |
+
</EditForm>
|
| 414 |
+
}
|
| 415 |
</div>
|
| 416 |
</div>
|
| 417 |
</div>
|
| 418 |
+
}
|
| 419 |
}
|
| 420 |
+
else if (_activeTab == AdminTab.Approvals)
|
| 421 |
{
|
| 422 |
<div class="animate-fade">
|
| 423 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Membership Approvals</h1>
|
| 424 |
+
<p class="text-muted text-lg mb-8">Review and approve pending "Pay Cash at Library" subscription requests.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
+
@if (_isLoading)
|
| 427 |
+
{
|
| 428 |
+
<div class="space-y-4">
|
| 429 |
+
@for (int i = 0; i < 3; i++)
|
| 430 |
{
|
| 431 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
}
|
| 433 |
+
</div>
|
| 434 |
+
}
|
| 435 |
+
else if (_pendingSubscriptions == null || !_pendingSubscriptions.Any())
|
| 436 |
+
{
|
| 437 |
+
<div class="bg-card rounded-3xl border border-dashed border-border p-20 text-center">
|
| 438 |
+
<div class="w-20 h-20 bg-primary/5 rounded-full flex items-center justify-center mx-auto mb-6 text-primary">
|
| 439 |
+
<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>
|
| 440 |
+
</div>
|
| 441 |
+
<h3 class="text-xl font-bold text-main mb-2">All Caught Up!</h3>
|
| 442 |
+
<p class="text-muted">No pending membership requests to approve.</p>
|
| 443 |
+
</div>
|
| 444 |
+
}
|
| 445 |
+
else
|
| 446 |
+
{
|
| 447 |
+
<div class="grid grid-cols-1 gap-6">
|
| 448 |
+
@foreach (var sub in _pendingSubscriptions)
|
| 449 |
{
|
| 450 |
+
<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">
|
| 451 |
+
<div class="flex items-center gap-6">
|
| 452 |
+
<div class="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center text-primary font-black text-xl">
|
| 453 |
+
@(sub.UserName?.Substring(0, Math.Min(2, sub.UserName.Length)).ToUpper() ?? "U")
|
|
|
|
|
|
|
|
|
|
| 454 |
</div>
|
| 455 |
<div>
|
| 456 |
+
<h4 class="text-xl font-bold text-main mb-1">@sub.UserName</h4>
|
| 457 |
+
<p class="text-sm text-muted mb-2">@sub.UserEmail</p>
|
| 458 |
+
<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">
|
| 459 |
+
@sub.MembershipType
|
| 460 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
</div>
|
| 462 |
</div>
|
| 463 |
+
|
| 464 |
+
<div class="flex items-center gap-4 ml-auto sm:ml-0">
|
| 465 |
+
<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">
|
| 466 |
+
Reject
|
| 467 |
+
</button>
|
| 468 |
+
<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">
|
| 469 |
+
Approve
|
| 470 |
+
</button>
|
| 471 |
</div>
|
| 472 |
+
</div>
|
| 473 |
}
|
| 474 |
</div>
|
| 475 |
+
}
|
| 476 |
</div>
|
| 477 |
}
|
| 478 |
</div>
|
| 479 |
|
| 480 |
@code {
|
| 481 |
private enum ViewMode { List, Details, Create, Edit }
|
| 482 |
+
private enum AdminTab { Directory, Approvals }
|
| 483 |
+
|
| 484 |
private ViewMode _currentView = ViewMode.List;
|
| 485 |
+
private AdminTab _activeTab = AdminTab.Directory;
|
| 486 |
|
| 487 |
private IEnumerable<UserDto>? _allUsers;
|
| 488 |
private PagedResult<UserDto>? _pagedResult;
|
| 489 |
+
private IEnumerable<SubscriptionDto>? _pendingSubscriptions;
|
| 490 |
private bool _isLoading = true;
|
| 491 |
private const int PageSize = 10;
|
| 492 |
|
|
|
|
| 499 |
|
| 500 |
protected override async Task OnInitializedAsync()
|
| 501 |
{
|
| 502 |
+
await LoadData();
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
private async Task LoadData()
|
| 506 |
+
{
|
| 507 |
+
_isLoading = true;
|
| 508 |
+
try
|
| 509 |
+
{
|
| 510 |
+
_allUsers = await ApiClient.GetUsersAsync();
|
| 511 |
+
_pendingSubscriptions = await ApiClient.GetPendingSubscriptionsAsync();
|
| 512 |
+
ApplyPagination(1);
|
| 513 |
+
}
|
| 514 |
+
catch (Exception ex)
|
| 515 |
+
{
|
| 516 |
+
await JS.InvokeVoidAsync("alert", "Error loading data: " + ex.Message);
|
| 517 |
+
}
|
| 518 |
+
finally
|
| 519 |
+
{
|
| 520 |
+
_isLoading = false;
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
private async Task SetTab(AdminTab tab)
|
| 525 |
+
{
|
| 526 |
+
_activeTab = tab;
|
| 527 |
+
_currentView = ViewMode.List;
|
| 528 |
+
await LoadData();
|
| 529 |
}
|
| 530 |
|
| 531 |
private async Task LoadUsers()
|
|
|
|
| 776 |
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 777 |
}
|
| 778 |
}
|
|
|
|
| 779 |
|
| 780 |
+
private async Task HandleApproval(Guid subscriptionId, bool approve)
|
| 781 |
+
{
|
| 782 |
+
try
|
| 783 |
+
{
|
| 784 |
+
var success = await ApiClient.ApproveSubscriptionAsync(new ApproveSubscriptionRequest
|
| 785 |
+
{
|
| 786 |
+
SubscriptionId = subscriptionId,
|
| 787 |
+
Approve = approve
|
| 788 |
+
});
|
| 789 |
|
| 790 |
+
if (success)
|
| 791 |
+
{
|
| 792 |
+
await JS.InvokeVoidAsync("alert", approve ? "Subscription approved!" : "Subscription rejected.");
|
| 793 |
+
await LoadData(); // Refresh list
|
| 794 |
+
}
|
| 795 |
+
}
|
| 796 |
+
catch (Exception ex)
|
| 797 |
+
{
|
| 798 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 799 |
+
}
|
| 800 |
+
}
|
| 801 |
+
}
|
BlazorWebAssembly/Pages/Membership.razor
CHANGED
|
@@ -26,9 +26,10 @@
|
|
| 26 |
}
|
| 27 |
else
|
| 28 |
{
|
| 29 |
-
<!--
|
| 30 |
<AuthorizeView>
|
| 31 |
<Authorized>
|
|
|
|
| 32 |
@if (_currentSub != null)
|
| 33 |
{
|
| 34 |
<div class="bg-primary/5 border border-primary/20 rounded-[2.5rem] p-10 mb-12 flex flex-col md:flex-row items-center justify-between gap-8 shadow-sm relative overflow-hidden group">
|
|
@@ -51,34 +52,33 @@
|
|
| 51 |
</div>
|
| 52 |
</div>
|
| 53 |
</div>
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
</div>
|
| 66 |
</div>
|
| 67 |
</div>
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
</
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
</
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
@if (_queuedRewards.Any())
|
| 84 |
{
|
|
@@ -307,4 +307,3 @@
|
|
| 307 |
}
|
| 308 |
}
|
| 309 |
}
|
| 310 |
-
|
|
|
|
| 26 |
}
|
| 27 |
else
|
| 28 |
{
|
| 29 |
+
<!-- Auth Section -->
|
| 30 |
<AuthorizeView>
|
| 31 |
<Authorized>
|
| 32 |
+
<!-- Current Subscription Banner -->
|
| 33 |
@if (_currentSub != null)
|
| 34 |
{
|
| 35 |
<div class="bg-primary/5 border border-primary/20 rounded-[2.5rem] p-10 mb-12 flex flex-col md:flex-row items-center justify-between gap-8 shadow-sm relative overflow-hidden group">
|
|
|
|
| 52 |
</div>
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
+
}
|
| 56 |
|
| 57 |
+
<!-- Wallet & Loyalty Summary (Always visible for logged in) -->
|
| 58 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-12">
|
| 59 |
+
<div class="bg-card border border-border rounded-3xl p-6 flex items-center justify-between shadow-sm">
|
| 60 |
+
<div class="flex items-center gap-4">
|
| 61 |
+
<div class="w-12 h-12 bg-primary/10 rounded-2xl flex items-center justify-center text-primary">
|
| 62 |
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
| 63 |
+
</div>
|
| 64 |
+
<div>
|
| 65 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">Loyalty Points</p>
|
| 66 |
+
<p class="text-xl font-black text-main">@(_loyaltyAccount?.CurrentBalance.ToString("N0") ?? "0")</p>
|
|
|
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="bg-card border border-border rounded-3xl p-6 flex items-center justify-between shadow-sm">
|
| 71 |
+
<div class="flex items-center gap-4">
|
| 72 |
+
<div class="w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center text-emerald-500">
|
| 73 |
+
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 74 |
+
</div>
|
| 75 |
+
<div>
|
| 76 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">Wallet Balance</p>
|
| 77 |
+
<p class="text-xl font-black text-main">@_walletBalance.ToString("N0") <span class="text-xs font-bold text-muted">MMK</span></p>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
+
</div>
|
|
|
|
| 82 |
|
| 83 |
@if (_queuedRewards.Any())
|
| 84 |
{
|
|
|
|
| 307 |
}
|
| 308 |
}
|
| 309 |
}
|
|
|
BlazorWebAssembly/Services/LibraryApiClient.cs
CHANGED
|
@@ -182,9 +182,24 @@ namespace BlazorWebAssembly.Services
|
|
| 182 |
|
| 183 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 184 |
{
|
|
|
|
| 185 |
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 186 |
}
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
public async Task<SubscriptionUpgradePreviewDto?> GetUpgradePreviewAsync(int membershipId)
|
| 189 |
{
|
| 190 |
try
|
|
|
|
| 182 |
|
| 183 |
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 184 |
{
|
| 185 |
+
await EnsureAuthHeader();
|
| 186 |
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 187 |
}
|
| 188 |
|
| 189 |
+
public async Task<IEnumerable<SubscriptionDto>> GetPendingSubscriptionsAsync()
|
| 190 |
+
{
|
| 191 |
+
await EnsureAuthHeader();
|
| 192 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/pending") ?? Enumerable.Empty<SubscriptionDto>();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
public async Task<bool> ApproveSubscriptionAsync(ApproveSubscriptionRequest request)
|
| 196 |
+
{
|
| 197 |
+
await EnsureAuthHeader();
|
| 198 |
+
var response = await _httpClient.PostAsJsonAsync("api/subscriptions/approve", request);
|
| 199 |
+
return response.IsSuccessStatusCode;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
public async Task<SubscriptionUpgradePreviewDto?> GetUpgradePreviewAsync(int membershipId)
|
| 204 |
{
|
| 205 |
try
|
DbConnect/Entities/UserSubscription.cs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
using System.Collections.Generic;
|
| 3 |
|
| 4 |
namespace DbConnect.Entities;
|
|
@@ -20,6 +20,8 @@ public partial class UserSubscription
|
|
| 20 |
public string Status { get; set; } = null!;
|
| 21 |
|
| 22 |
public string? ExternalRedemptionId { get; set; }
|
|
|
|
|
|
|
| 23 |
|
| 24 |
public virtual Membership Membership { get; set; } = null!;
|
| 25 |
|
|
|
|
| 1 |
+
using System;
|
| 2 |
using System.Collections.Generic;
|
| 3 |
|
| 4 |
namespace DbConnect.Entities;
|
|
|
|
| 20 |
public string Status { get; set; } = null!;
|
| 21 |
|
| 22 |
public string? ExternalRedemptionId { get; set; }
|
| 23 |
+
public string? PaymentMethod { get; set; }
|
| 24 |
+
|
| 25 |
|
| 26 |
public virtual Membership Membership { get; set; } = null!;
|
| 27 |
|
LibraryManagement.Shared/Models/LibraryDtos.cs
CHANGED
|
@@ -129,6 +129,8 @@ namespace LibraryManagement.Shared.Models
|
|
| 129 |
public DateTime StartDate { get; set; }
|
| 130 |
public DateTime ExpiryDate { get; set; }
|
| 131 |
public bool IsActive { get; set; }
|
|
|
|
|
|
|
| 132 |
public string? ExternalRedemptionId { get; set; }
|
| 133 |
public bool IsExpired => DateTime.UtcNow > ExpiryDate;
|
| 134 |
}
|
|
@@ -144,6 +146,12 @@ namespace LibraryManagement.Shared.Models
|
|
| 144 |
public int MembershipId { get; set; }
|
| 145 |
}
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
public class SubscriptionUpgradePreviewDto
|
| 148 |
{
|
| 149 |
public decimal OriginalPrice { get; set; }
|
|
|
|
| 129 |
public DateTime StartDate { get; set; }
|
| 130 |
public DateTime ExpiryDate { get; set; }
|
| 131 |
public bool IsActive { get; set; }
|
| 132 |
+
public string Status { get; set; } = "Active";
|
| 133 |
+
public string? PaymentMethod { get; set; }
|
| 134 |
public string? ExternalRedemptionId { get; set; }
|
| 135 |
public bool IsExpired => DateTime.UtcNow > ExpiryDate;
|
| 136 |
}
|
|
|
|
| 146 |
public int MembershipId { get; set; }
|
| 147 |
}
|
| 148 |
|
| 149 |
+
public class ApproveSubscriptionRequest
|
| 150 |
+
{
|
| 151 |
+
public Guid SubscriptionId { get; set; }
|
| 152 |
+
public bool Approve { get; set; }
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
public class SubscriptionUpgradePreviewDto
|
| 156 |
{
|
| 157 |
public decimal OriginalPrice { get; set; }
|