diff --git a/CTO_REPORT_SHOP_DELETE.md b/CTO_REPORT_SHOP_DELETE.md new file mode 100644 index 00000000..484cc8be --- /dev/null +++ b/CTO_REPORT_SHOP_DELETE.md @@ -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 diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/CloseShopConfirmDialog.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/CloseShopConfirmDialog.razor new file mode 100644 index 00000000..ac138c3c --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/CloseShopConfirmDialog.razor @@ -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. *@ + + + + + Thao tác này không thể hoàn tác. Cửa hàng sẽ bị khóa vĩnh viễn. + + + Để xác nhận, vui lòng nhập tên cửa hàng @ShopName vào ô bên dưới: + + + + + Hủy + + Đóng cửa hàng vĩnh viễn + + + + +@code { + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; + + /// + /// 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. + /// + [Parameter] public string ShopName { get; set; } = ""; + + private string _confirmText = ""; + + /// + /// 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). + /// + 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)); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopSettings.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopSettings.razor index 9877b098..d6a679ab 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopSettings.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopSettings.razor @@ -1,6 +1,10 @@ @using WebClientTpos.Client.Services @using WebClientTpos.Client.Pages.Admin.Shop @inject PosDataService DataService +@inject MerchantApiService MerchantApi +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject NavigationManager NavigationManager @* ─── Shop info (read-only) ─── *@
@@ -74,6 +78,54 @@ }
+@* ─── 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) ─── *@ + +
+ Vùng nguy hiểm +
+ + @* EN: Deactivate shop — temporarily disable *@ + @* VI: Tạm ngưng cửa hàng — vô hiệu hóa tạm thời *@ +
+
+ Tạm ngưng cửa hàng + + Cửa hàng sẽ không hiển thị trên POS. Có thể kích hoạt lại sau. + +
+ + @if (_isDangerActionRunning && _dangerAction == "deactivate") + { + + } + Tạm ngưng + +
+ + @* EN: Close shop permanently — irreversible *@ + @* VI: Đóng cửa hàng vĩnh viễn — không thể hoàn tác *@ +
+
+ Đóng cửa hàng vĩnh viễn + + 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. + +
+ + @if (_isDangerActionRunning && _dangerAction == "close") + { + + } + Đóng vĩnh viễn + +
+
+ @code { [Parameter] public Guid ShopId { get; set; } [Parameter] public string? ShopName { get; set; } @@ -149,4 +201,109 @@ else _settingsOpenDays.Add(code); StateHasChanged(); } + + // EN: Danger Zone state + // VI: Trạng thái Vùng nguy hiểm + private bool _isDangerActionRunning; + private string? _dangerAction; + + /// + /// 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. + /// + 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(); + } + } + + /// + /// 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. + /// + 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 + { + { x => x.ShopName, displayName } + }; + var options = new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Small, + FullWidth = true + }; + var dialog = await DialogService.ShowAsync("Đó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(); + } + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/MerchantApiService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/MerchantApiService.cs index 1e6e2903..d4d181ec 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/MerchantApiService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/MerchantApiService.cs @@ -99,6 +99,42 @@ public class MerchantApiService } } + /// + /// 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). + /// + public async Task DeactivateShopAsync(Guid shopId) + { + try + { + await AttachTokenAsync(); + var response = await _http.PostAsync($"/api/bff/shops/{shopId}/deactivate", null); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + /// + /// EN: Permanently close a shop. + /// VI: Đóng shop vĩnh viễn. + /// + public async Task CloseShopAsync(Guid shopId) + { + try + { + await AttachTokenAsync(); + var response = await _http.PostAsync($"/api/bff/shops/{shopId}/close", null); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + /// /// 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. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs index 0a45d16a..4a488bcf 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/ShopController.cs @@ -211,6 +211,22 @@ public class ShopController : ControllerBase public Task PublishShop(Guid shopId) => _merchant.PostAsync($"/api/v1/shops/{shopId}/publish", null).ProxyAsync(); + /// + /// 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). + /// + [HttpPost("shops/{shopId:guid}/deactivate")] + public Task DeactivateShop(Guid shopId) => + _merchant.PostAsync($"/api/v1/shops/{shopId}/deactivate", null).ProxyAsync(); + + /// + /// EN: Close a shop permanently (irreversible). + /// VI: Đóng cửa hàng vĩnh viễn (không thể hoàn tác). + /// + [HttpPost("shops/{shopId:guid}/close")] + public Task CloseShop(Guid shopId) => + _merchant.PostAsync($"/api/v1/shops/{shopId}/close", null).ProxyAsync(); + /// /// 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.