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