feat(superadmin): implement subscription plan management for merchants

- Add UpdateMerchantPlanCommand in MerchantService (Admin can set plan 0-3)
- Add PUT /api/v1/admin/merchants/{id}/plan endpoint
- Add BFF proxy PUT /api/bff/superadmin/merchants/{id}/plan with audit logging
- Add SubscriptionPlanId + SubscriptionPlanName to AdminMerchantListItemDto and AdminMerchantDetailDto
- Rewrite GetMerchantDetailQueryHandler to use EF joins (fix NullRef on Ignored nav properties)
- Add plan selector UI in MerchantDetail subscription tab (4 clickable plan cards)
- Save button appears when plan differs from current, with success/error feedback
- Add UpdateMerchantPlanAsync to SuperAdminApiService frontend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-29 00:07:49 +07:00
parent b378f39872
commit e3893efa56
8 changed files with 227 additions and 41 deletions

View File

@@ -141,6 +141,7 @@
else if (_activeTab == "subscription")
{
<div class="sa-panel__body">
@* Current plan display *@
<div style="display:flex;align-items:center;gap:16px;padding:20px;background:var(--sa-bg-interactive);border-radius:10px;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--sa-blue-bg);display:flex;align-items:center;justify-content:center;">
<i data-lucide="credit-card" style="width:24px;height:24px;color:var(--sa-blue-light);"></i>
@@ -152,9 +153,46 @@
<div style="font-size:13px;color:var(--sa-text-tertiary);">Gói hiện tại</div>
</div>
</div>
<p style="margin-top:16px;font-size:13px;color:var(--sa-text-tertiary);">
Tính năng nâng/hạ gói sẽ được phát triển trong phiên bản tiếp theo.
</p>
@* Plan selector *@
<h4 style="font-size:14px;font-weight:700;color:var(--sa-text-primary);margin:24px 0 12px;">Đổi gói đăng ký</h4>
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:12px;">
@foreach (var plan in _planOptions)
{
var isActive = plan.Id == (_detail.SubscriptionPlanId);
<div @onclick="@(() => SelectPlan(plan.Id))"
style="padding:16px;border-radius:10px;cursor:pointer;border:2px solid @(isActive ? "var(--sa-blue-light)" : "var(--sa-border)");background:@(isActive ? "var(--sa-blue-bg)" : "var(--sa-bg-interactive)");transition:all 0.15s ease;">
<div style="font-size:15px;font-weight:700;color:var(--sa-text-primary);">@plan.Name</div>
<div style="font-size:13px;color:var(--sa-blue-light);font-weight:600;margin-top:4px;">
@(plan.Price > 0 ? $"{plan.Price:N0}đ/tháng" : plan.Name == "Enterprise" ? "Liên hệ" : "Miễn phí")
</div>
<div style="font-size:11px;color:var(--sa-text-tertiary);margin-top:6px;">
@plan.MaxShops cửa hàng • @plan.MaxStaff nhân viên
</div>
@if (isActive)
{
<span class="sa-badge sa-badge--success" style="margin-top:8px;">Hiện tại</span>
}
</div>
}
</div>
@if (_selectedPlanId != _detail.SubscriptionPlanId)
{
<div style="margin-top:16px;display:flex;align-items:center;gap:12px;">
<button class="sa-btn-primary" @onclick="SavePlanAsync">
<i data-lucide="save" style="width:14px;height:14px;"></i>
Lưu thay đổi → @(_planOptions.FirstOrDefault(p => p.Id == _selectedPlanId)?.Name)
</button>
<button class="sa-btn-outline" @onclick="@(() => _selectedPlanId = _detail.SubscriptionPlanId)">
Hủy
</button>
@if (!string.IsNullOrEmpty(_planMessage))
{
<span style="font-size:13px;color:@(_planSuccess ? "var(--sa-success)" : "var(--sa-danger)");">@_planMessage</span>
}
</div>
}
</div>
}
</div>
@@ -166,11 +204,44 @@
private SuperAdminApiService.MerchantDetailDto? _detail;
private string _activeTab = "info";
private int _selectedPlanId;
private string? _planMessage;
private bool _planSuccess;
private record PlanOption(int Id, string Name, decimal Price, int MaxShops, int MaxStaff);
private static readonly PlanOption[] _planOptions =
[
new(0, "Starter", 0, 1, 5),
new(1, "Growth", 299000, 3, 15),
new(2, "Pro", 799000, 10, 50),
new(3, "Enterprise", 0, 999, 999),
];
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_detail = await Api.GetMerchantDetailAsync(MerchantId);
if (_detail != null)
_selectedPlanId = _detail.SubscriptionPlanId;
}
private void SelectPlan(int planId) => _selectedPlanId = planId;
private async Task SavePlanAsync()
{
_planMessage = null;
var (ok, err) = await Api.UpdateMerchantPlanAsync(MerchantId, _selectedPlanId);
if (ok)
{
_planSuccess = true;
_planMessage = "Đã cập nhật gói đăng ký thành công!";
_detail = await Api.GetMerchantDetailAsync(MerchantId);
}
else
{
_planSuccess = false;
_planMessage = err ?? "Cập nhật thất bại";
}
}
private async Task ApproveAsync()

View File

@@ -45,7 +45,8 @@ public class SuperAdminApiService
string? Status, string? VerificationStatus,
string? TaxCode, string? Address, string? City, string? District,
string? Email, string? Phone, string? Website,
int ShopsCount, int StaffCount, string? PlanName,
int ShopsCount, int StaffCount,
int SubscriptionPlanId, string? SubscriptionPlanName, string? PlanName,
DateTime CreatedAt, DateTime? VerifiedAt, DateTime? LastLoginAt,
List<ShopSummaryDto>? Shops);
@@ -143,6 +144,18 @@ public class SuperAdminApiService
catch { return null; }
}
public async Task<(bool Success, string? Error)> UpdateMerchantPlanAsync(Guid id, int planId)
{
try
{
var response = await _http.PutAsJsonAsync(
$"/api/bff/superadmin/merchants/{id}/plan", new { planId }, _json);
if (response.IsSuccessStatusCode) return (true, null);
return (false, await ExtractError(response));
}
catch (Exception ex) { return (false, ex.Message); }
}
public async Task<(bool Success, string? Error)> ApproveMerchantAsync(Guid id, string? note = null)
{
try

View File

@@ -158,6 +158,15 @@ public class SuperAdminController : ControllerBase
return result;
}
[HttpPut("merchants/{id}/plan")]
public async Task<IActionResult> UpdateMerchantPlan(Guid id, [FromBody] JsonElement body)
{
var client = CreateAuthClient("MerchantService");
var result = await ProxyPut(client, $"/api/v1/admin/merchants/{id}/plan", body);
_ = LogAuditAsync(31, "Merchant", id, $"Update merchant plan");
return result;
}
[HttpPost("merchants/{id}/reactivate")]
public async Task<IActionResult> ReactivateMerchant(Guid id)
{
@@ -365,6 +374,22 @@ public class SuperAdminController : ControllerBase
}
}
private async Task<IActionResult> ProxyPut(HttpClient client, string url, JsonElement body)
{
try
{
var json = JsonSerializer.Serialize(body, _json);
var response = await client.PutAsync(url, new StringContent(json, System.Text.Encoding.UTF8, "application/json"));
var content = await response.Content.ReadAsStringAsync();
return Content(content, "application/json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Proxy PUT failed: {Url}", url);
return StatusCode(502, new { success = false, error = new { message = "Service unavailable" } });
}
}
private async Task<IActionResult> ProxyPost(HttpClient client, string url)
{
try