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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user