feat: add shop lifecycle management UI — deactivate & close shop

- Add "Danger Zone" section to ShopSettings with deactivate/close actions
- CloseShopConfirmDialog: type shop name to confirm (GitHub-style)
- BFF: proxy endpoints POST /shops/{id}/deactivate and /close
- MerchantApiService: DeactivateShopAsync(), CloseShopAsync()
- CTO report documenting the gap and implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-14 07:35:30 +07:00
parent 659e8e05e5
commit 6263eeb05d
5 changed files with 375 additions and 0 deletions

117
CTO_REPORT_SHOP_DELETE.md Normal file
View File

@@ -0,0 +1,117 @@
# CTO Report — Missing Shop Delete/Deactivate UI
> Date: 2026-03-14
> Reporter: QA Team (Automated Chrome Testing)
> Priority: P1 (Functional Gap)
> Status: OPEN
---
## Issue Summary
Trang **Thiết lập cửa hàng** (`/admin/shop/{shopId}/settings`) không có chức năng xóa, đóng, hoặc vô hiệu hóa cửa hàng. Admin tạo shop mới nhưng không thể xóa/đóng shop từ UI.
**URL kiểm tra**: `http://localhost:3001/admin/shop/0d25b74e-f855-4fba-9ca4-baa2c89e4811/settings`
---
## Current State
### UI (ShopSettings.razor) — Thiếu hoàn toàn
Trang Thiết lập chỉ có 3 section:
1. **Thông tin cửa hàng** — Tên, Ngành hàng (read-only)
2. **Giờ & ngày hoạt động** — Giờ mở/đóng, ngày kinh doanh
3. **Tính năng cửa hàng** — 6 toggles (tồn kho, bàn, bếp, đặt lịch, vận chuyển, giao hàng)
**Không có**: Nút xóa, đóng, vô hiệu hóa, hoặc lưu trữ cửa hàng.
### Backend (merchant-service-net) — Đã có đầy đủ
API endpoints đã implement và có handler + validation:
| Action | Endpoint | Domain Method | Status Transition |
|--------|----------|---------------|-------------------|
| Vô hiệu hóa | `POST /api/v1/shops/{shopId}/deactivate` | `Shop.SetInactive()` | Active → Inactive (reversible) |
| Đóng cửa hàng | `POST /api/v1/shops/{shopId}/close` | `Shop.Close()` | Any → Closed (permanent) |
| Xóa mềm | — (no controller endpoint) | `Shop.Delete()` | Sets `_isDeleted = true` |
**Authorization**: Handlers validate merchant ownership via JWT claims trước khi thực hiện.
**Domain Events**: `ShopClosedDomainEvent` được raise khi đóng shop.
**Shop Status Enum**: `Draft``Active``Inactive` (reversible) → `Closed` (permanent)
### Files liên quan
| Layer | File | Notes |
|-------|------|-------|
| UI | `Pages/Admin/Shop/ShopSettings.razor` | Thiếu delete/deactivate UI |
| UI | `Pages/Admin/Shop/ShopPage.razor` | Chỉ có edit, không có delete |
| API | `ShopsController.cs` (line 211-229) | Endpoints `/deactivate`, `/close` đã có |
| Handler | `ShopStatusCommandHandlers.cs` | `SetShopInactiveCommandHandler`, `CloseShopCommandHandler` |
| Domain | `Shop.cs` (line 298-319, 407-414) | `SetInactive()`, `Close()`, `Delete()` methods |
| Domain | `ShopStatus.cs` | Status enum: Draft, Active, Inactive, Closed |
---
## Impact
- **User Experience**: Admin không thể quản lý lifecycle cửa hàng (tạo → vô hiệu hóa → đóng/xóa)
- **Data Hygiene**: Shop test/demo không thể xóa, gây lộn xộn trong danh sách shop
- **Business Logic**: Merchant tạo shop mới cho mùa kinh doanh (ví dụ pop-up store) nhưng không thể đóng khi hết mùa
---
## Recommended Implementation
### Option A: Thêm "Danger Zone" vào ShopSettings.razor (Recommended)
Thêm section cuối trang Thiết lập, tương tự GitHub repository settings:
```
┌─ Vùng nguy hiểm ──────────────────────────────────────┐
│ │
│ Tạm ngưng cửa hàng [Tạm ngưng] │
│ Shop sẽ không hiển thị trên POS. │
│ Có thể kích hoạt lại. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ Đóng cửa hàng vĩnh viễn [Đóng shop] │
│ Không thể hoàn tác. Tất cả dữ liệu │
│ sẽ được lưu trữ nhưng shop sẽ bị khóa. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ Xóa cửa hàng [Xóa shop] │
│ Xóa mềm — shop sẽ không hiển thị │
│ nhưng dữ liệu vẫn được giữ trong DB. │
│ │
└─────────────────────────────────────────────────────────┘
```
### UX Requirements
1. **Confirmation dialog** (MudDialog) với nhập tên shop để xác nhận
2. **Phân quyền**: Chỉ Owner/Admin mới thấy Danger Zone
3. **Cascading effect notice**: Thông báo impact lên staff, orders, inventory
4. **"Tạm ngưng"** có nút **"Kích hoạt lại"** khi shop đang Inactive
### API Calls (BFF)
- Cần thêm BFF proxy endpoints trong `WebClientTpos.Server`:
- `POST /api/bff/shops/{shopId}/deactivate`
- `POST /api/bff/shops/{shopId}/close`
- Forward đến `MerchantService` endpoints đã có
### Effort Estimate
- **Frontend**: ~2-3 hours (Danger Zone UI + confirmation dialogs)
- **BFF**: ~30 minutes (proxy endpoints)
- **Backend**: 0 (đã có đầy đủ)
- **Testing**: ~1 hour (E2E verify)
---
## Priority Justification
**P1 (High)** vì:
- Backend đã implement đầy đủ → chỉ thiếu UI, effort thấp
- Đây là chức năng quản lý cơ bản mà mọi merchant platform cần có
- Ảnh hưởng trực tiếp đến trải nghiệm admin khi quản lý nhiều shop

View File

@@ -0,0 +1,49 @@
@* EN: Dialog requiring the user to type the shop name to confirm permanent closure. *@
@* VI: Dialog yêu cầu người dùng nhập tên cửa hàng để xác nhận đóng vĩnh viễn. *@
<MudDialog>
<DialogContent>
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="true" Class="mb-4">
Thao tác này không thể hoàn tác. Cửa hàng sẽ bị khóa vĩnh viễn.
</MudAlert>
<MudText Typo="Typo.body2" Class="mb-3">
Để xác nhận, vui lòng nhập tên cửa hàng <strong>@ShopName</strong> vào ô bên dưới:
</MudText>
<MudTextField @bind-Value="_confirmText"
Label="Nhập tên cửa hàng"
Variant="Variant.Outlined"
Immediate="true"
FullWidth="true"
Placeholder="@ShopName" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text" Style="text-transform: none;">Hủy</MudButton>
<MudButton OnClick="Confirm" Variant="Variant.Filled" Color="Color.Error"
Disabled="@(!IsConfirmValid)" Style="text-transform: none;">
Đóng cửa hàng vĩnh viễn
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
/// <summary>
/// EN: The shop name the user must type to confirm.
/// VI: Tên cửa hàng mà người dùng phải nhập để xác nhận.
/// </summary>
[Parameter] public string ShopName { get; set; } = "";
private string _confirmText = "";
/// <summary>
/// EN: Check if the typed text matches the shop name (case-insensitive).
/// VI: Kiểm tra xem text nhập vào có khớp tên shop không (không phân biệt hoa thường).
/// </summary>
private bool IsConfirmValid =>
!string.IsNullOrWhiteSpace(_confirmText) &&
string.Equals(_confirmText.Trim(), ShopName.Trim(), StringComparison.OrdinalIgnoreCase);
private void Cancel() => MudDialog.Cancel();
private void Confirm() => MudDialog.Close(DialogResult.Ok(true));
}

View File

@@ -1,6 +1,10 @@
@using WebClientTpos.Client.Services @using WebClientTpos.Client.Services
@using WebClientTpos.Client.Pages.Admin.Shop @using WebClientTpos.Client.Pages.Admin.Shop
@inject PosDataService DataService @inject PosDataService DataService
@inject MerchantApiService MerchantApi
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@* ─── Shop info (read-only) ─── *@ @* ─── Shop info (read-only) ─── *@
<div class="admin-panel"> <div class="admin-panel">
@@ -74,6 +78,54 @@
} }
</div> </div>
@* ─── EN: Danger Zone — shop lifecycle actions (deactivate, close) ─── *@
@* ─── VI: Vùng nguy hiểm — thao tác vòng đời cửa hàng (tạm ngưng, đóng) ─── *@
<MudPaper Class="mt-6 pa-0" Style="border: 1px solid #f44336; border-radius: 8px; background: rgba(244,67,54,0.04); overflow: hidden;">
<div style="padding: 16px 20px; border-bottom: 1px solid rgba(244,67,54,0.2);">
<MudText Typo="Typo.h6" Style="color: #f44336; font-size: 16px; font-weight: 700;">Vùng nguy hiểm</MudText>
</div>
@* EN: Deactivate shop — temporarily disable *@
@* VI: Tạm ngưng cửa hàng — vô hiệu hóa tạm thời *@
<div style="padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(244,67,54,0.12);">
<div>
<MudText Typo="Typo.subtitle1" Style="font-weight: 600; font-size: 14px;">Tạm ngưng cửa hàng</MudText>
<MudText Typo="Typo.body2" Style="color: var(--admin-text-tertiary, #999); font-size: 13px; margin-top: 2px;">
Cửa hàng sẽ không hiển thị trên POS. Có thể kích hoạt lại sau.
</MudText>
</div>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" Size="Size.Small"
OnClick="OnDeactivateShop" Disabled="_isDangerActionRunning"
Style="min-width: 120px; text-transform: none;">
@if (_isDangerActionRunning && _dangerAction == "deactivate")
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" Style="width:16px;height:16px;" />
}
Tạm ngưng
</MudButton>
</div>
@* EN: Close shop permanently — irreversible *@
@* VI: Đóng cửa hàng vĩnh viễn — không thể hoàn tác *@
<div style="padding: 16px 20px; display: flex; align-items: center; justify-content: space-between;">
<div>
<MudText Typo="Typo.subtitle1" Style="font-weight: 600; font-size: 14px;">Đóng cửa hàng vĩnh viễn</MudText>
<MudText Typo="Typo.body2" Style="color: var(--admin-text-tertiary, #999); font-size: 13px; margin-top: 2px;">
Không thể hoàn tác. Tất cả dữ liệu sẽ được lưu trữ nhưng cửa hàng sẽ bị khóa vĩnh viễn.
</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Error" Size="Size.Small"
OnClick="OnCloseShop" Disabled="_isDangerActionRunning"
Style="min-width: 120px; text-transform: none;">
@if (_isDangerActionRunning && _dangerAction == "close")
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" Style="width:16px;height:16px;" />
}
Đóng vĩnh viễn
</MudButton>
</div>
</MudPaper>
@code { @code {
[Parameter] public Guid ShopId { get; set; } [Parameter] public Guid ShopId { get; set; }
[Parameter] public string? ShopName { get; set; } [Parameter] public string? ShopName { get; set; }
@@ -149,4 +201,109 @@
else _settingsOpenDays.Add(code); else _settingsOpenDays.Add(code);
StateHasChanged(); StateHasChanged();
} }
// EN: Danger Zone state
// VI: Trạng thái Vùng nguy hiểm
private bool _isDangerActionRunning;
private string? _dangerAction;
/// <summary>
/// EN: Show deactivate confirmation dialog and call API.
/// VI: Hiển thị dialog xác nhận tạm ngưng và gọi API.
/// </summary>
private async Task OnDeactivateShop()
{
if (ShopId == Guid.Empty) return;
var confirmed = await DialogService.ShowMessageBox(
"Tạm ngưng cửa hàng",
$"Bạn có chắc muốn tạm ngưng cửa hàng {_shopName ?? "này"}?",
yesText: "Tạm ngưng", cancelText: "Hủy");
if (confirmed != true) return;
_isDangerActionRunning = true;
_dangerAction = "deactivate";
StateHasChanged();
try
{
var ok = await MerchantApi.DeactivateShopAsync(ShopId);
if (ok)
{
Snackbar.Add("Đã tạm ngưng cửa hàng thành công.", Severity.Success);
NavigationManager.NavigateTo("/admin");
}
else
{
Snackbar.Add("Không thể tạm ngưng cửa hàng. Vui lòng thử lại.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
}
finally
{
_isDangerActionRunning = false;
_dangerAction = null;
StateHasChanged();
}
}
/// <summary>
/// EN: Show close confirmation dialog (requires typing shop name) and call API.
/// VI: Hiển thị dialog xác nhận đóng (yêu cầu nhập tên shop) và gọi API.
/// </summary>
private async Task OnCloseShop()
{
if (ShopId == Guid.Empty) return;
var displayName = _shopName ?? "cửa hàng";
// EN: Custom inline dialog requiring the user to type the shop name to confirm.
// VI: Dialog tùy chỉnh yêu cầu người dùng nhập tên cửa hàng để xác nhận.
var parameters = new DialogParameters<CloseShopConfirmDialog>
{
{ x => x.ShopName, displayName }
};
var options = new DialogOptions
{
CloseButton = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<CloseShopConfirmDialog>("Đóng cửa hàng vĩnh viễn", parameters, options);
var result = await dialog.Result;
if (result == null || result.Canceled) return;
_isDangerActionRunning = true;
_dangerAction = "close";
StateHasChanged();
try
{
var ok = await MerchantApi.CloseShopAsync(ShopId);
if (ok)
{
Snackbar.Add("Đã đóng cửa hàng vĩnh viễn.", Severity.Success);
NavigationManager.NavigateTo("/admin");
}
else
{
Snackbar.Add("Không thể đóng cửa hàng. Vui lòng thử lại.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
}
finally
{
_isDangerActionRunning = false;
_dangerAction = null;
StateHasChanged();
}
}
} }

View File

@@ -99,6 +99,42 @@ public class MerchantApiService
} }
} }
/// <summary>
/// EN: Deactivate a shop (temporarily disable operations).
/// VI: Tạm ngưng hoạt động shop (vô hiệu hóa tạm thời).
/// </summary>
public async Task<bool> DeactivateShopAsync(Guid shopId)
{
try
{
await AttachTokenAsync();
var response = await _http.PostAsync($"/api/bff/shops/{shopId}/deactivate", null);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
/// <summary>
/// EN: Permanently close a shop.
/// VI: Đóng shop vĩnh viễn.
/// </summary>
public async Task<bool> CloseShopAsync(Guid shopId)
{
try
{
await AttachTokenAsync();
var response = await _http.PostAsync($"/api/bff/shops/{shopId}/close", null);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
/// <summary> /// <summary>
/// EN: Attach Bearer token to HttpClient for authorized requests. /// EN: Attach Bearer token to HttpClient for authorized requests.
/// VI: Gắn Bearer token vào HttpClient cho các request cần xác thực. /// VI: Gắn Bearer token vào HttpClient cho các request cần xác thực.

View File

@@ -211,6 +211,22 @@ public class ShopController : ControllerBase
public Task<IActionResult> PublishShop(Guid shopId) => public Task<IActionResult> PublishShop(Guid shopId) =>
_merchant.PostAsync($"/api/v1/shops/{shopId}/publish", null).ProxyAsync(); _merchant.PostAsync($"/api/v1/shops/{shopId}/publish", null).ProxyAsync();
/// <summary>
/// EN: Deactivate a shop (set inactive, can be reactivated later).
/// VI: Tạm ngưng hoạt động cửa hàng (có thể kích hoạt lại sau).
/// </summary>
[HttpPost("shops/{shopId:guid}/deactivate")]
public Task<IActionResult> DeactivateShop(Guid shopId) =>
_merchant.PostAsync($"/api/v1/shops/{shopId}/deactivate", null).ProxyAsync();
/// <summary>
/// EN: Close a shop permanently (irreversible).
/// VI: Đóng cửa hàng vĩnh viễn (không thể hoàn tác).
/// </summary>
[HttpPost("shops/{shopId:guid}/close")]
public Task<IActionResult> CloseShop(Guid shopId) =>
_merchant.PostAsync($"/api/v1/shops/{shopId}/close", null).ProxyAsync();
/// <summary> /// <summary>
/// EN: Get device tokens registered for this merchant's staff. /// EN: Get device tokens registered for this merchant's staff.
/// VI: Lấy danh sách device token đã đăng ký cho nhân viên của merchant. /// VI: Lấy danh sách device token đã đăng ký cho nhân viên của merchant.