feat(web-client-tpos): Phase A — categories CRUD, order management, shop update, reports

BFF Endpoints (6 new):
- POST/PUT/DELETE categories — full CRUD with shop ownership validation
- GET orders/{id} — order detail with items
- PUT orders/{id}/cancel — cancel non-completed orders (status=6)
- PUT shops/{id} — update name, phone, email, hours
- GET reports/revenue — daily/weekly/monthly revenue aggregation

PosDataService (8 new methods):
- CreateCategory, UpdateCategory, DeleteCategory
- GetOrderDetail, CancelOrder
- UpdateShop
- GetRevenueReport

ShopPage UI (222 lines):
- Menu tab: categories table with add/edit/delete
- Finance tab: expandable order rows with items + cancel button
- Overview tab: shop info edit form
- Reports tab: period selector (Ngày/Tuần/Tháng) + revenue table
This commit is contained in:
Ho Ngoc Hai
2026-03-03 21:22:25 +07:00
parent aab80fd697
commit 14d6c4012c
5 changed files with 535 additions and 42 deletions

View File

@@ -1,18 +1,30 @@
# Context — GoodGo Platform
> Updated: 2026-03-03 20:45
# Context — GoodGo POS Platform
> Updated: 2026-03-03 21:01
## Current Status
- **P1 + P2 + P3**: ✅ Hoàn thành
- **Phases Completed**: P1 (Order+Receipt), P2 (Dashboard), P3 (Products+Staff+Inventory CRUD)
- **Branch**: master
- **Last Commits**: `8cba902` (Products CRUD) → `15b17f5` (Staff CRUD + Inventory)
- **Last Commit**: `aab80fd` (CreateStaff INSERT fix)
- **Container**: web-client-tpos-net-local healthy on port 3001
## Recent Changes
- Products: Full CRUD (Create/Read/Update/Delete) in ShopPage Admin
- Staff: Full CRUD (Create/Read/Update/Delete=soft) in ShopPage Admin
- Inventory: Update quantity/reorder level via BFF endpoint
- Bug fixes: CreateStaff auth, created_at NOT NULL
## Active Work
- **Phase A**: Categories CRUD → Order Management → Shop Update → Reports
- **Phase B**: Promotions CRUD → Apply discount POS → Customer CRUD
- **Phase C**: Table CRUD → KDS → Recipes
- **Phase D**: Appointments CRUD → Service Packages → Treatments
- **Phase E**: Shifts → RBAC → Shop Settings
## Tech Stack
- Backend: BFF Controller (Dapper + Npgsql → PostgreSQL)
- Frontend: Blazor WASM (ShopPage.razor + PosDataService.cs)
- Deploy: Docker Compose (deployments/local)
## Key Files
- BFF: `apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs`
- Service: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs`
- Admin UI: `apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopPage.razor`
- Deploy: `deployments/local/docker-compose.yml``docker compose build --no-cache web-client-tpos-net`
## DB Schema Reference
- `merchant_service`: merchants, shops, merchant_staff, shop_tables, shop_resources
- `catalog_service`: products, categories
- `order_service`: orders, order_items
- `inventory_service`: inventory_items, inventory_transactions
- `membership_service`: members, level_definitions
- `wallet_service`: wallets, wallet_transactions
- `promotion_service`: promotions

View File

@@ -1,34 +1,30 @@
# Task Plan — GoodGo Platform
> Updated: 2026-03-03 20:45
# Plan — POS Feature Implementation
> Updated: 2026-03-03 21:01
## ✅ Completed Tasks
1. ~~Admin Users & Roles — CRUD, phân quyền~~
2. ~~POS Unified page — 3 tabs (Sale, History, Dashboard)~~
3. ~~POS Inline Payment Flow~~
4. ~~Replace hardcoded POS data with API endpoints~~
5. ~~P1: Order API Create — POST /api/bff/pos/orders~~
6. ~~P1: Print Receipt — Thermal 80mm popup~~
7. ~~P2: Dashboard Real Data — hourly/popular/payment~~
8. ~~P2: History Date Filter — today/7d/30d~~
9. ~~P3: Products CRUD — Create/Read/Update/Delete~~
10. ~~P3: Staff CRUD — Create/Read/Update/Delete (soft-delete)~~
11. ~~P3: Inventory Update — PUT quantity/reorder level~~
## Strategy
- Use Claude CLI subagent for heavy BFF + service code
- Parallel edits where possible
- Commit after each phase
- Deploy + verify after Phase A
## Pending Tasks (Ưu tiên cao → thấp)
## Current: Phase A — POS Core
### A1: Categories CRUD
- BFF: POST/PUT/DELETE categories (existing GET works)
- PosDataService: CreateCategory, UpdateCategory, DeleteCategory
- ShopPage: Add/Edit/Delete buttons on category section (or in menu tab)
### 🟡 P3 — Admin Panel (còn lại)
- [ ] **Promotions**: Campaign management UI
- [ ] **Notifications**: Replace hardcoded bell data
### A2: Order Management
- BFF: GET order/{id} details, PUT order/{id}/cancel
- PosDataService: GetOrderDetailAsync, CancelOrderAsync
- ShopPage finance tab: expand order rows → view items, cancel button
### 🟢 P4 — Frontend Apps
- [x] **web-client-tpos-net**: Deployed locally (port 3001) ✅
- [ ] **app-client-base-swift**: iOS app review
- [ ] **web-docs**: Documentation site
### A3: Shop Update
- BFF: PUT shop/{id} (name, address, hours, phone)
- PosDataService: UpdateShopAsync
- ShopPage overview: edit shop info form
### 🟢 P5 — Infrastructure
- [ ] **Observability**: Prometheus + Grafana
- [ ] **CI/CD**: GitHub Actions
- [ ] **Testing**: Unit + Integration tests coverage
### A4: Basic Reports
- BFF: GET reports/revenue?period=day|week|month
- ShopPage reports tab: revenue table with totals
## Next Task
> **Deploy & verify** web-client-tpos-net then continue with Promotions or P4
## Next: Phase B → C → D → E (see task.md)

View File

@@ -151,6 +151,40 @@
{
@RenderEmpty("layout-dashboard", "#3B82F6", "Chào mừng đến cửa hàng!", "Bắt đầu bán hàng qua POS để xem dữ liệu tại đây", "monitor", "Mở POS", $"/pos/{ShopId}/{_posVertical}")
}
@* Shop Info Edit *@
<div class="admin-panel" style="margin-top:20px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Thông tin cửa hàng</h3>
@if (!_editingShop)
{
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="StartEditShop">
<i data-lucide="pencil" style="width:16px;height:16px;"></i>Chỉnh sửa
</button>
}
</div>
@if (_editingShop)
{
<div style="padding:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Tên cửa hàng</label><input @bind="_shopEditName" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Số điện thoại</label><input @bind="_shopEditPhone" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Email</label><input @bind="_shopEditEmail" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Mô tả</label><input @bind="_shopEditDesc" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Giờ mở cửa</label><input @bind="_shopEditOpenTime" placeholder="08:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Giờ đóng cửa</label><input @bind="_shopEditCloseTime" placeholder="22:00" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="SaveShopEdit" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
<button @onclick="@(() => _editingShop = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
@if (_shopEditMessage != null) { <div style="margin-top:8px;padding:8px 12px;border-radius:6px;font-size:13px;@(_shopEditSuccess ? "color:#22C55E;background:rgba(34,197,94,0.1);" : "color:#EF4444;background:rgba(239,68,68,0.1);")">@_shopEditMessage</div> }
</div>
}
else
{
<div style="padding:16px;color:var(--admin-text-secondary);font-size:13px;">@(_shopName ?? "—") — Nhấn "Chỉnh sửa" để cập nhật thông tin.</div>
}
</div>
break;
// ═══ MENU / PRODUCTS ═══
@@ -227,6 +261,54 @@
}
</div>
}
@* Categories Management *@
<div class="admin-panel" style="margin-top:20px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Danh mục (@_categories.Count)</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingCategoryId = null; _newCategoryName = ""; _newCategoryDesc = ""; _newCategoryOrder = 0; _categoryFormMessage = null; _showCategoryForm = !_showCategoryForm; })">
<i data-lucide="plus" style="width:16px;height:16px;"></i>Thêm danh mục
</button>
</div>
@if (_showCategoryForm)
{
<div style="padding:16px;border-bottom:1px solid var(--admin-border-subtle);">
<h4 style="margin:0 0 12px;color:var(--admin-text-primary);">@(_editingCategoryId.HasValue ? "Chỉnh sửa danh mục" : "Thêm danh mục mới")</h4>
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:end;">
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Tên</label><input @bind="_newCategoryName" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Mô tả</label><input @bind="_newCategoryDesc" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);" /></div>
<div style="display:flex;gap:8px;">
<button class="admin-btn-primary" @onclick="@SaveCategory" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingCategoryId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick="@(() => _showCategoryForm = false)" style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:transparent;color:var(--admin-text-secondary);cursor:pointer;"><i data-lucide="x" style="width:14px;height:14px;"></i>Hủy</button>
</div>
</div>
@if (_categoryFormMessage != null) { <div style="margin-top:8px;padding:8px 12px;border-radius:6px;font-size:13px;@(_categoryFormSuccess ? "color:#22C55E;background:rgba(34,197,94,0.1);" : "color:#EF4444;background:rgba(239,68,68,0.1);")">@_categoryFormMessage</div> }
</div>
}
<div style="padding:16px;">
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="font-size:12px;color:var(--admin-text-tertiary);text-align:left;">
<th style="padding:8px 12px;">TÊN</th><th style="padding:8px 12px;">MÔ TẢ</th><th style="padding:8px 12px;">THỨ TỰ</th><th style="padding:8px 12px;text-align:right;">HÀNH ĐỘNG</th>
</tr></thead>
<tbody>
@foreach (var c in _categories)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:10px 12px;color:var(--admin-text-primary);font-weight:500;">@c.Name</td>
<td style="padding:10px 12px;color:var(--admin-text-secondary);font-size:13px;">@c.Description</td>
<td style="padding:10px 12px;color:var(--admin-text-secondary);">@c.DisplayOrder</td>
<td style="padding:10px 12px;text-align:right;">
<div style="display:flex;gap:6px;justify-content:flex-end;">
<button @onclick="@(() => EditCategory(c))" style="background:rgba(59,130,246,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Sửa"><i data-lucide="pencil" style="color:#3B82F6;width:14px;height:14px;"></i></button>
<button @onclick="@(() => DeleteCategoryItem(c.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
@if (!_categories.Any()) { <div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Chưa có danh mục. Nhấn "Thêm danh mục" để tạo mới.</div> }
</div>
</div>
break;
// ═══ INVENTORY ═══
@@ -321,15 +403,51 @@
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Số tiền</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Trạng thái</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Ngày</th>
<th style="padding:12px 16px;width:32px;"></th>
</tr></thead><tbody>
@foreach (var o in _orders.Take(20))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
var isOrderExpanded = _selectedOrderId == o.Id;
<tr style="border-top:1px solid var(--admin-border-subtle);cursor:pointer;@(isOrderExpanded ? "background:rgba(255,92,0,0.05);" : "")" @onclick="@(() => ViewOrderDetail(o.Id))">
<td style="padding:12px 16px;font-size:12px;font-family:monospace;color:var(--admin-text-tertiary);">@o.Id.ToString()[..8]</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@FormatVND(o.TotalAmount)</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge admin-status-badge--online" style="font-size:11px;padding:2px 10px;">@(o.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@o.CreatedAt.ToString("dd/MM HH:mm")</td>
<td style="padding:12px 16px;text-align:center;"><i data-lucide="@(isOrderExpanded ? "chevron-up" : "chevron-down")" style="width:14px;height:14px;color:var(--admin-text-tertiary);"></i></td>
</tr>
@if (isOrderExpanded && _orderDetail != null)
{
<tr><td colspan="5" style="padding:0;">
<div style="padding:16px 20px;background:rgba(255,92,0,0.03);border-top:2px solid var(--admin-orange-primary);">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:12px;">
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">💳 Thanh toán</label><div style="font-size:13px;">@(_orderDetail.Order?.PaymentMethod ?? "—")</div></div>
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">📝 Ghi chú</label><div style="font-size:13px;">@(_orderDetail.Order?.Notes ?? "—")</div></div>
<div><label style="font-size:11px;font-weight:600;color:var(--admin-text-tertiary);display:block;margin-bottom:4px;">🕐 Thời gian</label><div style="font-size:13px;">@(_orderDetail.Order?.CreatedAt.ToString("dd/MM/yyyy HH:mm") ?? "—")</div></div>
</div>
@if (_orderDetail.Items?.Any() == true)
{
<table style="width:100%;border-collapse:collapse;font-size:13px;"><thead><tr style="color:var(--admin-text-tertiary);font-size:11px;">
<th style="text-align:left;padding:4px 8px;">Sản phẩm</th><th style="text-align:center;padding:4px 8px;">SL</th><th style="text-align:right;padding:4px 8px;">Đơn giá</th><th style="text-align:right;padding:4px 8px;">Thành tiền</th>
</tr></thead><tbody>
@foreach (var item in _orderDetail.Items)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:6px 8px;font-weight:500;">@(item.ProductName ?? "—")</td>
<td style="padding:6px 8px;text-align:center;">@item.Quantity</td>
<td style="padding:6px 8px;text-align:right;">@FormatVND(item.UnitPrice)</td>
<td style="padding:6px 8px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@FormatVND(item.Subtotal)</td>
</tr>
}
</tbody></table>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button @onclick="@(() => CancelOrderItem(o.Id))" style="padding:6px 14px;border-radius:8px;border:1px solid rgba(239,68,68,0.3);background:rgba(239,68,68,0.1);color:#EF4444;font-size:12px;cursor:pointer;display:flex;align-items:center;gap:6px;">
<i data-lucide="x-circle" style="width:12px;height:12px;"></i> Hủy đơn
</button>
</div>
</div>
</td></tr>
}
}
</tbody></table>
</div>
@@ -733,6 +851,47 @@
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(139,92,246,0.1);"><i data-lucide="banknote" style="color:#8B5CF6;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@FormatVND(_reportOrders.Any() ? _reportOrders.Average(o => o.TotalAmount) : 0)</span><span class="admin-stat-card__label">Giá trị TB / đơn</span></div></div>
<div class="admin-stat-card"><div class="admin-stat-card__icon" style="background:rgba(236,72,153,0.1);"><i data-lucide="package" style="color:#EC4899;"></i></div><div class="admin-stat-card__content"><span class="admin-stat-card__value">@_reportProducts.Count</span><span class="admin-stat-card__label">Sản phẩm</span></div></div>
</div>
@* Revenue Report *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header">
<h3 class="admin-panel__title">Doanh thu theo kỳ</h3>
<div style="display:flex;gap:4px;background:var(--admin-bg-elevated);border-radius:8px;padding:3px;">
@foreach (var (label, val) in new[] { ("Ngày", "daily"), ("Tuần", "weekly"), ("Tháng", "monthly") })
{
<button @onclick="@(() => LoadRevenueReport(val))"
style="padding:5px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:none;
background:@(_reportPeriod == val ? "var(--admin-orange-primary)" : "transparent");
color:@(_reportPeriod == val ? "#FFF" : "var(--admin-text-tertiary)");">
@label
</button>
}
</div>
</div>
<div class="admin-panel__body" style="padding:0;">
@if (_revenueReport.Any())
{
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="font-size:12px;color:var(--admin-text-tertiary);text-align:left;border-bottom:1px solid var(--admin-border-subtle);">
<th style="padding:10px 16px;">KỲ</th><th style="padding:10px 16px;text-align:right;">ĐƠN HÀNG</th><th style="padding:10px 16px;text-align:right;">DOANH THU</th>
</tr></thead>
<tbody>
@foreach (var r in _revenueReport)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:10px 16px;color:var(--admin-text-primary);font-weight:500;">@r.Period.ToString("dd/MM/yyyy")</td>
<td style="padding:10px 16px;text-align:right;color:var(--admin-text-secondary);">@r.OrderCount</td>
<td style="padding:10px 16px;text-align:right;font-weight:600;color:var(--admin-orange-primary);">@FormatVND(r.Revenue)</td>
</tr>
}
</tbody>
</table>
}
else
{
<div style="text-align:center;padding:24px;color:var(--admin-text-tertiary);font-size:14px;">Nhấn Ngày / Tuần / Tháng để tải dữ liệu doanh thu.</div>
}
</div>
</div>
@if (_reportProducts.Any())
{
<div class="admin-panel" style="margin-bottom:16px;">
@@ -1561,6 +1720,31 @@
private string _customerSearch = "";
// Finance date range filter state
private string _financePeriod = "all"; // 7d, 30d, all
// Category form state
private bool _showCategoryForm;
private Guid? _editingCategoryId;
private string _newCategoryName = "";
private string _newCategoryDesc = "";
private int _newCategoryOrder;
private string? _categoryFormMessage;
private bool _categoryFormSuccess;
private List<PosDataService.AdminCategoryInfo> _categories = new();
// Order detail state
private Guid? _selectedOrderId;
private PosDataService.OrderDetailResponse? _orderDetail;
// Shop edit state
private bool _editingShop;
private string _shopEditName = "";
private string _shopEditPhone = "";
private string _shopEditEmail = "";
private string _shopEditDesc = "";
private string _shopEditOpenTime = "";
private string _shopEditCloseTime = "";
private string? _shopEditMessage;
private bool _shopEditSuccess;
// Revenue report state
private string _reportPeriod = "daily";
private List<PosDataService.RevenueReportItem> _revenueReport = new();
protected override async Task OnInitializedAsync() => await LoadData();
@@ -1609,6 +1793,7 @@
case "menu":
case "products":
_products = await DataService.GetAllProductsAsync(_shopGuid);
_categories = await DataService.GetAllCategoriesAsync(_shopGuid);
break;
case "inventory":
_inventory = await DataService.GetInventoryAsync(_shopGuid);
@@ -1864,4 +2049,39 @@
0 => "CN", 1 => "T2", 2 => "T3", 3 => "T4",
4 => "T5", 5 => "T6", 6 => "T7", _ => $"#{dow}"
};
// ═══ CATEGORY CRUD ═══
private async Task SaveCategory()
{
if (string.IsNullOrWhiteSpace(_newCategoryName)) { _categoryFormMessage = "Tên danh mục không được trống."; _categoryFormSuccess = false; return; }
var req = new PosDataService.AdminCreateCategoryRequest(_shopGuid ?? Guid.Empty, _newCategoryName, _newCategoryDesc, _newCategoryOrder);
bool ok;
if (_editingCategoryId.HasValue)
ok = await DataService.UpdateCategoryAsync(_editingCategoryId.Value, req);
else
ok = await DataService.CreateCategoryAsync(req);
_categoryFormMessage = ok ? (_editingCategoryId.HasValue ? "Đã cập nhật danh mục!" : "Đã thêm danh mục!") : "Lỗi khi lưu danh mục.";
_categoryFormSuccess = ok;
if (ok) { _showCategoryForm = false; _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
}
private void EditCategory(PosDataService.AdminCategoryInfo c) { _editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _showCategoryForm = true; _categoryFormMessage = null; }
private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
// ═══ ORDER DETAIL ═══
private async Task ViewOrderDetail(Guid orderId) { if (_selectedOrderId == orderId) { _selectedOrderId = null; _orderDetail = null; return; } _selectedOrderId = orderId; _orderDetail = await DataService.GetOrderDetailAsync(orderId); }
private async Task CancelOrderItem(Guid orderId) { var ok = await DataService.CancelOrderAsync(orderId); if (ok) { _selectedOrderId = null; _orderDetail = null; await LoadData(); } }
// ═══ SHOP EDIT ═══
private void StartEditShop() { _editingShop = true; _shopEditName = _shopName ?? ""; _shopEditPhone = ""; _shopEditEmail = ""; _shopEditDesc = ""; _shopEditOpenTime = ""; _shopEditCloseTime = ""; _shopEditMessage = null; }
private async Task SaveShopEdit()
{
var req = new PosDataService.UpdateShopRequest(_shopEditName, _shopEditPhone, _shopEditEmail, _shopEditDesc, _shopEditOpenTime, _shopEditCloseTime, null);
var ok = await DataService.UpdateShopAsync(_shopGuid!.Value, req);
_shopEditMessage = ok ? "Đã cập nhật cửa hàng!" : "Lỗi khi cập nhật.";
_shopEditSuccess = ok;
if (ok) { _editingShop = false; await LoadData(); }
}
// ═══ REVENUE REPORT ═══
private async Task LoadRevenueReport(string period) { _reportPeriod = period; _revenueReport = await DataService.GetRevenueReportAsync(period, _shopGuid); }
}

View File

@@ -299,4 +299,82 @@ public class PosDataService
return await resp.Content.ReadFromJsonAsync<CreatePosOrderResponse>(_jsonOptions);
return null;
}
// ═══ CATEGORIES CRUD ═══
// EN: Category create/update request DTO
// VI: DTO tạo/cập nhật danh mục
public record AdminCreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder);
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteCategoryAsync(Guid categoryId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/categories/{categoryId}");
return resp.IsSuccessStatusCode;
}
// ═══ ORDER DETAIL & CANCEL ═══
// EN: Order detail DTOs
// VI: DTOs cho chi tiết đơn hàng
public record OrderDetailInfo(Guid Id, Guid ShopId, decimal TotalAmount, string? Status, int StatusId, string? PaymentMethod, string? Notes, DateTime CreatedAt);
public record OrderItemInfo(Guid Id, string? ProductName, int Quantity, decimal UnitPrice, decimal Subtotal);
public record OrderDetailResponse(OrderDetailInfo? Order, List<OrderItemInfo>? Items);
public async Task<OrderDetailResponse?> GetOrderDetailAsync(Guid orderId)
{
AttachToken();
return await _http.GetFromJsonAsync<OrderDetailResponse>($"api/bff/orders/{orderId}", _jsonOptions);
}
public async Task<bool> CancelOrderAsync(Guid orderId)
{
AttachToken();
using var req = new HttpRequestMessage(HttpMethod.Put, $"api/bff/orders/{orderId}/cancel");
req.Headers.Authorization = _http.DefaultRequestHeaders.Authorization;
var resp = await _http.SendAsync(req);
return resp.IsSuccessStatusCode;
}
// ═══ SHOP UPDATE ═══
// EN: Shop update DTO
// VI: DTO cập nhật thông tin cửa hàng
public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays);
public async Task<bool> UpdateShopAsync(Guid shopId, UpdateShopRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ REVENUE REPORT ═══
// EN: Revenue report item DTO
// VI: DTO cho từng dòng báo cáo doanh thu
public record RevenueReportItem(DateTime Period, long OrderCount, decimal Revenue);
public async Task<List<RevenueReportItem>> GetRevenueReportAsync(string period = "daily", Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue
? $"api/bff/reports/revenue?period={period}&shopId={shopId}"
: $"api/bff/reports/revenue?period={period}";
return await _http.GetFromJsonAsync<List<RevenueReportItem>>(url, _jsonOptions) ?? new();
}
}

View File

@@ -1095,10 +1095,197 @@ public class BffDataController : ControllerBase
}
}
// ═══ CATEGORIES CRUD ═══
/// <summary>
/// EN: Create a category — validates shop ownership.
/// VI: Tạo danh mục — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPost("categories")]
public async Task<IActionResult> CreateCategory([FromBody] CreateCategoryRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Contains(req.ShopId))
return Forbid();
var id = Guid.NewGuid();
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
await conn.ExecuteAsync(
@"INSERT INTO categories (id, shop_id, name, description, display_order, is_active, created_at)
VALUES (@Id, @ShopId, @Name, @Description, @DisplayOrder, true, NOW())",
new { Id = id, req.ShopId, req.Name, req.Description, req.DisplayOrder });
return CreatedAtAction(nameof(GetAllCategories), new { }, new { id });
}
/// <summary>
/// EN: Update a category — validates shop ownership.
/// VI: Cập nhật danh mục — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpPut("categories/{categoryId:guid}")]
public async Task<IActionResult> UpdateCategory(Guid categoryId, [FromBody] CreateCategoryRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var rows = await conn.ExecuteAsync(
@"UPDATE categories SET name=@Name, description=@Description, display_order=@DisplayOrder, updated_at=NOW()
WHERE id=@Id AND shop_id = ANY(@ShopIds)",
new { Id = categoryId, req.Name, req.Description, req.DisplayOrder, ShopIds = myShopIds.ToArray() });
return rows > 0 ? Ok(new { id = categoryId }) : NotFound();
}
/// <summary>
/// EN: Soft-delete a category — validates shop ownership.
/// VI: Xóa mềm danh mục — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpDelete("categories/{categoryId:guid}")]
public async Task<IActionResult> DeleteCategory(Guid categoryId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
await conn.ExecuteAsync(
@"UPDATE categories SET is_active=false, updated_at=NOW()
WHERE id=@Id AND shop_id = ANY(@ShopIds)",
new { Id = categoryId, ShopIds = myShopIds.ToArray() });
return NoContent();
}
// ═══ ORDER DETAIL & CANCEL ═══
/// <summary>
/// EN: Get full order detail with items — validates shop ownership.
/// VI: Lấy chi tiết đơn hàng kèm items — kiểm tra quyền sở hữu shop.
/// </summary>
[HttpGet("orders/{orderId:guid}")]
public async Task<IActionResult> GetOrderDetail(Guid orderId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var order = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT o.id, o.shop_id, o.total_amount, os.name as status, o.status_id,
COALESCE(o.payment_method, 'cash') as payment_method, o.notes, o.created_at
FROM orders o
JOIN order_statuses os ON o.status_id = os.id
WHERE o.id = @Id AND o.shop_id = ANY(@ShopIds)",
new { Id = orderId, ShopIds = myShopIds.ToArray() });
if (order == null) return NotFound();
var items = await conn.QueryAsync<dynamic>(
@"SELECT id, product_name, quantity, unit_price, (quantity * unit_price) as subtotal
FROM order_items
WHERE order_id = @OrderId",
new { OrderId = orderId });
return Ok(new { order, items });
}
/// <summary>
/// EN: Cancel an order — validates ownership; rejects completed/already-cancelled.
/// VI: Hủy đơn hàng — kiểm tra quyền sở hữu; từ chối nếu đã xong hoặc đã hủy.
/// </summary>
[HttpPut("orders/{orderId:guid}/cancel")]
public async Task<IActionResult> CancelOrder(Guid orderId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var rows = await conn.ExecuteAsync(
@"UPDATE orders SET status_id=6, updated_at=NOW()
WHERE id=@Id AND shop_id = ANY(@ShopIds) AND status_id NOT IN (5,6)",
new { Id = orderId, ShopIds = myShopIds.ToArray() });
return rows > 0 ? Ok(new { id = orderId }) : BadRequest(new { message = "Đơn hàng đã hoàn thành hoặc đã hủy." });
}
// ═══ SHOP UPDATE ═══
/// <summary>
/// EN: Update shop info — validates ownership.
/// VI: Cập nhật thông tin cửa hàng — kiểm tra quyền sở hữu.
/// </summary>
[HttpPut("shops/{shopId:guid}")]
public async Task<IActionResult> UpdateShop(Guid shopId, [FromBody] UpdateShopRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Contains(shopId))
return Forbid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var rows = await conn.ExecuteAsync(
@"UPDATE shops SET name=@Name, phone=@Phone, email=@Email, description=@Description,
open_time=@OpenTime, close_time=@CloseTime, updated_at=NOW()
WHERE id=@ShopId AND id = ANY(@ShopIds)",
new { req.Name, req.Phone, req.Email, req.Description,
req.OpenTime, req.CloseTime, ShopId = shopId, ShopIds = myShopIds.ToArray() });
return rows > 0 ? Ok(new { id = shopId }) : NotFound();
}
// ═══ REVENUE REPORT ═══
/// <summary>
/// EN: Get revenue report grouped by day/week/month — scoped to merchant's shops.
/// VI: Lấy báo cáo doanh thu theo ngày/tuần/tháng — lọc theo shops của merchant.
/// </summary>
[HttpGet("reports/revenue")]
public async Task<IActionResult> GetRevenueReport(
[FromQuery] string period = "daily",
[FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any()) return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var sql = period switch
{
"weekly" => @"SELECT date_trunc('week', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue
FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '84 days'
GROUP BY 1 ORDER BY 1 DESC",
"monthly" => @"SELECT date_trunc('month', created_at)::date as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue
FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '365 days'
GROUP BY 1 ORDER BY 1 DESC",
_ => @"SELECT DATE(created_at) as period, COUNT(*) as order_count, COALESCE(SUM(total_amount),0) as revenue
FROM orders WHERE shop_id = ANY(@ShopIds) AND status_id IN (3,5) AND created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(created_at) ORDER BY period DESC"
};
try
{
var report = await conn.QueryAsync<dynamic>(sql, new { ShopIds = targetShopIds.ToArray() });
return Ok(report);
}
catch { return Ok(Array.Empty<object>()); }
}
// EN: Request DTOs / VI: DTO yêu cầu
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice);
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
public record CreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder);
public record UpdateShopRequest(string? Name, string? Phone, string? Email, string? Description, string? OpenTime, string? CloseTime, string? OpenDays);
}