feat: implement category CRUD with image upload, extend staff profile fields, and add membership level/EXP management

This commit is contained in:
Ho Ngoc Hai
2026-03-05 03:03:48 +07:00
parent 4d6c9c6ba3
commit c86500214b
22 changed files with 1000 additions and 65 deletions

View File

@@ -204,7 +204,7 @@
<button @onclick='() => _productView = "grid"' style="padding:6px 10px;border:none;cursor:pointer;background:@(_productView == "grid" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "grid" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="grid-3x3" style="width:14px;height:14px;"></i></button>
<button @onclick='() => _productView = "list"' style="padding:6px 10px;border:none;border-left:1px solid var(--admin-border-subtle);cursor:pointer;background:@(_productView == "list" ? "var(--admin-orange-primary)" : "var(--admin-bg-elevated)");color:@(_productView == "list" ? "#FFF" : "var(--admin-text-secondary)");"><i data-lucide="list" style="width:14px;height:14px;"></i></button>
</div>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _newProductCategoryId = ""; _formMessage = null; _showProductForm = !_showProductForm; })">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingProductId = null; _newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductType = "PreparedFood"; _newProductCategoryId = ""; _formMessage = null; _productImageFile = null; _productImagePreview = null; _showProductForm = !_showProductForm; })">
<i data-lucide="plus-circle" style="width:16px;height:16px;"></i>
Thêm sản phẩm
</button>
@@ -235,6 +235,15 @@
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Hình ảnh</label>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
@if (_productImagePreview != null)
{
<img src="@_productImagePreview" alt="Preview" style="max-width:80px;max-height:80px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnProductImageSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="@(_editingProductId.HasValue ? SaveProduct : AddProduct)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingProductId.HasValue ? "Cập nhật" : "Lưu")</button>
@@ -264,7 +273,14 @@
<button @onclick="@(() => EditProduct(p))" 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="@(() => DeleteProduct(p.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>
<div style="width:48px;height:48px;border-radius:12px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;"><i data-lucide="package" style="color:#FF5C00;width:24px;height:24px;"></i></div>
@if (!string.IsNullOrWhiteSpace(p.ImageUrl))
{
<img src="@p.ImageUrl" alt="@p.Name" style="width:48px;height:48px;border-radius:12px;object-fit:cover;margin:0 auto 12px;" />
}
else
{
<div style="width:48px;height:48px;border-radius:12px;background:rgba(255,92,0,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 12px;"><i data-lucide="package" style="color:#FF5C00;width:24px;height:24px;"></i></div>
}
<div style="font-weight:600;font-size:14px;margin-bottom:4px;">@p.Name</div>
<div style="font-size:13px;color:var(--admin-text-tertiary);margin-bottom:4px;">@(p.CategoryName ?? "—")</div>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:6px;background:@($"{typeColor}22");color:@typeColor;">@typeLabel</span>
@@ -334,7 +350,7 @@
<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; })">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingCategoryId = null; _newCategoryName = ""; _newCategoryDesc = ""; _newCategoryOrder = 0; _categoryFormMessage = null; _categoryImageFile = null; _categoryImagePreview = null; _newCategoryImageUrl = ""; _showCategoryForm = !_showCategoryForm; })">
<i data-lucide="plus" style="width:16px;height:16px;"></i>Thêm danh mục
</button>
</div>
@@ -342,14 +358,20 @@
{
<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 style="display:grid;grid-template-columns:1fr 1fr 1fr;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><label style="font-size:12px;color:var(--admin-text-secondary);display:block;margin-bottom:4px;">Hình ảnh</label>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:8px;text-align:center;">
@if (_categoryImagePreview != null) { <img src="@_categoryImagePreview" alt="Preview" style="max-width:60px;max-height:60px;border-radius:6px;margin-bottom:4px;" /> }
<InputFile OnChange="OnCategoryImageSelected" accept="image/*" style="font-size:11px;width:100%;" />
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<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>
@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>
}
@@ -735,7 +757,7 @@
case "staff":
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@(_staff.Count) nhân viên</h3>
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _showStaffForm = !_showStaffForm; })">
<button class="admin-btn-primary" style="display:inline-flex;align-items:center;gap:8px;" @onclick="@(() => { _editingStaffId = null; _newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffRole = "Cashier"; _staffFormMessage = null; _createStaffAccount = false; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _newStaffAddress = ""; _staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null; _showStaffForm = !_showStaffForm; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>
Thêm nhân viên
</button>
@@ -746,6 +768,8 @@
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingStaffId.HasValue ? "Chỉnh sửa nhân viên" : "Thêm nhân viên mới")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Họ</label><input type="text" @bind="_newStaffLastName" placeholder="Nguyễn" 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;font-weight:600;display:block;margin-bottom:4px;">Tên</label><input type="text" @bind="_newStaffFirstName" placeholder="Văn A" 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;font-weight:600;display:block;margin-bottom:4px;">Mã NV *</label><input type="text" @bind="_newStaffCode" placeholder="VD: NV001" 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;font-weight:600;display:block;margin-bottom:4px;">Vai trò</label>
<select @bind="_newStaffRole" 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);">
@@ -758,6 +782,28 @@
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">SĐT</label><input type="text" @bind="_newStaffPhone" placeholder="0912345678" 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;font-weight:600;display:block;margin-bottom:4px;">Email *</label><input type="email" @bind="_newStaffEmail" placeholder="nv@goodgo.vn" 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="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Địa chỉ</label><input type="text" @bind="_newStaffAddress" placeholder="Số nhà, đường, phường..." 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="margin-top:12px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">CCCD / Giấy tờ tùy thân</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
<label style="font-size:11px;color:var(--admin-text-tertiary);display:block;margin-bottom:6px;">Mặt trước</label>
@if (_staffDocFrontPreview != null)
{
<img src="@_staffDocFrontPreview" alt="CCCD trước" style="max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnStaffDocFrontSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
<div style="border:2px dashed var(--admin-border-subtle);border-radius:8px;padding:12px;text-align:center;">
<label style="font-size:11px;color:var(--admin-text-tertiary);display:block;margin-bottom:6px;">Mặt sau</label>
@if (_staffDocBackPreview != null)
{
<img src="@_staffDocBackPreview" alt="CCCD sau" style="max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px;" />
}
<InputFile OnChange="OnStaffDocBackSelected" accept="image/*" style="font-size:12px;width:100%;" />
</div>
</div>
</div>
@if (!_editingStaffId.HasValue)
{
@@ -767,9 +813,7 @@
</label>
@if (_createStaffAccount)
{
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:10px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Họ *</label><input type="text" @bind="_newStaffLastName" placeholder="Nguyễn" 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;font-weight:600;display:block;margin-bottom:4px;">Tên *</label><input type="text" @bind="_newStaffFirstName" placeholder="Văn A" 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="margin-top:10px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mật khẩu *</label><input type="password" @bind="_newStaffPassword" placeholder="Min 8 ký tự" 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>
}
@@ -808,6 +852,7 @@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nhân viên</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mã NV</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Vai trò</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>
@@ -816,8 +861,10 @@
</tr></thead><tbody>
@foreach (var s in _staff)
{
var staffDisplayName = !string.IsNullOrWhiteSpace(s.LastName) || !string.IsNullOrWhiteSpace(s.FirstName) ? $"{s.LastName} {s.FirstName}".Trim() : null;
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(s.EmployeeCode ?? s.Id.ToString()[..6])</td>
<td style="padding:12px 16px;font-weight:600;">@(staffDisplayName ?? s.EmployeeCode ?? s.Id.ToString()[..6])</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(s.EmployeeCode ?? "—")</td>
<td style="padding:12px 16px;">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span class="admin-status-badge @(s.Status == "Active" ? "admin-status-badge--online" : "admin-status-badge--offline")" style="font-size:11px;padding:2px 10px;"><span class="admin-status-badge__dot" style="width:5px;height:5px;"></span>@(s.Status ?? "—")</span></td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.Phone ?? s.Email ?? "—")</td>
@@ -837,6 +884,156 @@
// ═══ CUSTOMERS + MEMBERSHIP LEVELS ═══
case "customers":
<div style="display:flex;gap:8px;margin-bottom:16px;border-bottom:2px solid var(--admin-border-subtle);padding-bottom:8px;">
@foreach (var (tab, label) in new[] { ("members","Khách hàng"), ("levels","Cấp bậc"), ("exp","Điểm EXP") })
{
<button @onclick="@(() => _customerSubTab = tab)"
style="padding:8px 16px;border-radius:8px 8px 0 0;border:none;font-size:13px;font-weight:600;cursor:pointer;background:@(_customerSubTab == tab ? "var(--admin-orange-primary)" : "transparent");color:@(_customerSubTab == tab ? "#FFF" : "var(--admin-text-secondary)");">
@label
</button>
}
</div>
@if (_customerSubTab == "levels")
{
@* ─── Level CRUD ─── *@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_memberLevels.Count cấp bậc</h3>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick='() => { _showLevelForm = !_showLevelForm; _editingLevelId = null; _newLevelNumber = _memberLevels.Count + 1; _newLevelName = ""; _newLevelRequiredExp = 0; _newLevelDescription = ""; _newLevelBadgeColor = "#CD7F32"; }'>
<i data-lucide="plus" style="width:14px;height:14px;margin-right:4px;"></i>Thêm cấp bậc
</button>
</div>
@if (_showLevelForm)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">@(_editingLevelId.HasValue ? "Sửa cấp bậc" : "Thêm cấp bậc")</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số level *</label><input type="number" @bind="_newLevelNumber" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Tên cấp bậc *</label><input type="text" @bind="_newLevelName" placeholder="Bronze" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">EXP cần *</label><input type="number" @bind="_newLevelRequiredExp" min="0" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div style="grid-column:span 2;"><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mô tả</label><input type="text" @bind="_newLevelDescription" placeholder="Mô tả quyền lợi..." style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Màu badge</label><input type="color" @bind="_newLevelBadgeColor" style="width:100%;height:36px;padding:2px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);cursor:pointer;" /></div>
</div>
@if (_levelFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_levelFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_levelFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_levelFormMessage</div>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="SaveLevel">Lưu</button>
<button style="padding:6px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;" @onclick="() => _showLevelForm = false">Hủy</button>
</div>
</div>
</div>
}
<div class="admin-panel">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Level</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tên</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">EXP cần</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Mô tả</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Màu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Thành viên</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);"></th>
</tr></thead><tbody>
@foreach (var lvl in _memberLevels.OrderBy(l => l.Level))
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:700;color:var(--admin-orange-primary);">@lvl.Level</td>
<td style="padding:12px 16px;font-weight:600;">@lvl.Name</td>
<td style="padding:12px 16px;text-align:right;font-size:13px;">@lvl.RequiredExp.ToString("N0")</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-secondary);">@(lvl.Description ?? "—")</td>
<td style="padding:12px 16px;text-align:center;"><span style="display:inline-block;width:20px;height:20px;border-radius:50%;background:@(lvl.BadgeColor ?? "#CCC");"></span></td>
<td style="padding:12px 16px;text-align:right;font-weight:600;">@lvl.MemberCount</td>
<td style="padding:12px 16px;text-align:center;">
<button @onclick="@(() => EditLevel(lvl))" style="padding:4px 8px;border-radius:6px;border:none;background:rgba(59,130,246,0.1);color:#3B82F6;font-size:11px;cursor:pointer;margin-right:4px;">Sửa</button>
<button @onclick="@(() => DeleteLevel(lvl.Id))" style="padding:4px 8px;border-radius:6px;border:none;background:rgba(239,68,68,0.1);color:#EF4444;font-size:11px;cursor:pointer;">Xóa</button>
</td>
</tr>
}
</tbody></table>
</div>
</div>
}
else if (_customerSubTab == "exp")
{
@* ─── EXP Management ─── *@
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cộng điểm EXP</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Thành viên</label>
<select @bind="_expMemberId" @bind:after="LoadMemberProgress" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="">-- Chọn --</option>
@foreach (var m in _members)
{
<option value="@m.Id">@(m.DisplayName ?? m.Id.ToString()[..8]) (@m.TotalExpEarned EXP)</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Số điểm</label><input type="number" @bind="_expPoints" min="1" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nguồn</label>
<select @bind="_expSourceId" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);">
<option value="1">Mua hàng</option><option value="2">Giới thiệu</option><option value="3">Hoạt động</option>
<option value="4">Khuyến mãi</option><option value="5">Đánh giá</option><option value="6">Check-in</option><option value="7">Admin</option>
</select>
</div>
</div>
@if (_expFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_expFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_expFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_expFormMessage</div>
}
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;margin-top:12px;" @onclick="AddExpToMember">Cộng EXP</button>
</div>
</div>
@if (_memberProgress != null)
{
<div class="admin-panel" style="margin-bottom:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Tiến trình: @(_memberProgress.CurrentLevelName ?? "Level") @_memberProgress.CurrentLevel</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:16px;margin-bottom:16px;">
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">EXP hiện tại</span><div style="font-weight:700;font-size:18px;color:var(--admin-orange-primary);">@_memberProgress.CurrentExp.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Tổng EXP</span><div style="font-weight:700;font-size:18px;">@_memberProgress.TotalExpEarned.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Cần thêm</span><div style="font-weight:700;font-size:18px;">@_memberProgress.ExpToNextLevel.ToString("N0")</div></div>
<div><span style="font-size:11px;color:var(--admin-text-tertiary);">Level kế</span><div style="font-weight:700;font-size:18px;">@(_memberProgress.NextLevelName ?? "Max")</div></div>
</div>
<div style="background:var(--admin-border-subtle);border-radius:8px;height:12px;overflow:hidden;">
<div style="background:var(--admin-orange-primary);height:100%;border-radius:8px;width:@(_memberProgress.ProgressPercent)%;transition:width 0.3s;"></div>
</div>
<div style="text-align:right;font-size:12px;color:var(--admin-text-tertiary);margin-top:4px;">@_memberProgress.ProgressPercent%</div>
</div>
</div>
}
@if (_expHistory.Any())
{
<div class="admin-panel">
<div class="admin-panel__header"><h3 class="admin-panel__title">Lịch sử EXP</h3></div>
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table" style="width:100%;"><thead><tr>
<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;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Điểm</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Nguồn</th>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Tham chiếu</th>
<th style="padding:12px 16px;text-align:right;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Level</th>
</tr></thead><tbody>
@foreach (var tx in _expHistory)
{
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-size:13px;">@tx.CreatedAt.ToString("dd/MM/yyyy HH:mm")</td>
<td style="padding:12px 16px;text-align:right;font-weight:600;color:@(tx.Points > 0 ? "#22C55E" : "#EF4444");">@(tx.Points > 0 ? "+" : "")@tx.Points</td>
<td style="padding:12px 16px;font-size:13px;">@(tx.Source ?? "—")</td>
<td style="padding:12px 16px;font-size:12px;color:var(--admin-text-tertiary);font-family:monospace;">@(tx.ReferenceId ?? "—")</td>
<td style="padding:12px 16px;text-align:right;">@tx.LevelAtTime</td>
</tr>
}
</tbody></table>
</div>
</div>
}
}
else
{
@* ─── Members list (existing) ─── *@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;font-weight:700;">@_members.Count khách hàng</h3>
<div style="display:flex;gap:8px;align-items:center;">
@@ -985,6 +1182,7 @@
</div>
</div>
}
}
break;
// ═══ TABLES (Restaurant) ═══
@@ -1562,8 +1760,22 @@
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm lịch làm việc</h3></div>
<div class="admin-panel__body">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;">
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Nhân viên (ID)</label><input type="text" @bind="_newSchedStaffIdStr" placeholder="Staff ID..." 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;font-weight:600;display:block;margin-bottom:4px;">Thứ (1=T2..7=CN)</label><input type="number" @bind="_newSchedDay" min="1" max="7" 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;font-weight:600;display:block;margin-bottom:4px;">Nhân viên</label>
<select @bind="_newSchedStaffIdStr" 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);">
<option value="">-- Chọn NV --</option>
@foreach (var st in _staff)
{
var stName = !string.IsNullOrWhiteSpace(st.LastName) || !string.IsNullOrWhiteSpace(st.FirstName) ? $"{st.LastName} {st.FirstName}".Trim() : (st.EmployeeCode ?? st.Id.ToString()[..8]);
<option value="@st.Id">@stName</option>
}
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Ngày</label>
<select @bind="_newSchedDay" 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);">
<option value="1">Thứ 2</option><option value="2">Thứ 3</option><option value="3">Thứ 4</option>
<option value="4">Thứ 5</option><option value="5">Thứ 6</option><option value="6">Thứ 7</option><option value="0">Chủ nhật</option>
</select>
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Bắt đầu</label><input type="text" @bind="_newSchedStart" 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;font-weight:600;display:block;margin-bottom:4px;">Kết thúc</label><input type="text" @bind="_newSchedEnd" placeholder="17: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>
@@ -1594,8 +1806,10 @@
</tr></thead><tbody>
@foreach (var s in _staffSchedules.OrderBy(x => x.DayOfWeek).ThenBy(x => x.StartTime))
{
var schedStaff = _staff.FirstOrDefault(st => st.Id == s.StaffId);
var schedStaffName = schedStaff != null && (!string.IsNullOrWhiteSpace(schedStaff.LastName) || !string.IsNullOrWhiteSpace(schedStaff.FirstName)) ? $"{schedStaff.LastName} {schedStaff.FirstName}".Trim() : (s.EmployeeCode ?? s.StaffId.ToString()[..8]);
<tr style="border-top:1px solid var(--admin-border-subtle);">
<td style="padding:12px 16px;font-weight:600;">@(s.EmployeeCode ?? s.StaffId.ToString()[..8])</td>
<td style="padding:12px 16px;font-weight:600;">@schedStaffName</td>
<td style="padding:12px 16px;font-size:13px;color:var(--admin-text-tertiary);">@(s.Role ?? "—")</td>
<td style="padding:12px 16px;text-align:center;font-weight:600;color:var(--admin-orange-primary);">@DayLabel(s.DayOfWeek)</td>
<td style="padding:12px 16px;text-align:center;">@s.StartTime</td>
@@ -1858,7 +2072,7 @@
<div style="margin-bottom:16px;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:8px;">Ngày kinh doanh</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
@foreach (var (day, code) in new[] { ("T2","Mon"),("T3","Tue"),("T4","Wed"),("T5","Thu"),("T6","Fri"),("T7","Sat"),("CN","Sun") })
@foreach (var (day, code) in new[] { ("T2","Monday"),("T3","Tuesday"),("T4","Wednesday"),("T5","Thursday"),("T6","Friday"),("T7","Saturday"),("CN","Sunday") })
{
var isOn = _settingsOpenDays.Contains(code);
<button type="button" @onclick="@(() => ToggleDay(code))"
@@ -1870,14 +2084,26 @@
</div>
</div>
</div>
@* ─── Features config JSON ─── *@
@* ─── Features config toggles ─── *@
<div class="admin-panel" style="margin-top:16px;">
<div class="admin-panel__header"><h3 class="admin-panel__title">Cấu hình tính năng (JSON)</h3></div>
<div class="admin-panel__header"><h3 class="admin-panel__title">Tính năng cửa hàng</h3></div>
<div class="admin-panel__body">
<textarea @bind="_settingsFeaturesConfig" rows="6"
placeholder="{}"
style="width:100%;padding:10px 14px;border-radius:8px;background:var(--admin-bg-elevated);border:1px solid var(--admin-border-subtle);font-size:13px;font-family:monospace;color:var(--admin-text-primary);resize:vertical;"></textarea>
<p style="font-size:12px;color:var(--admin-text-tertiary);margin:8px 0 0;">Nhập JSON để bật/tắt tính năng cho cửa hàng này.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
@{ void RenderToggle(string label, bool isOn, Action<bool> setter) {
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;border:1px solid var(--admin-border-subtle);cursor:pointer;" @onclick="@(() => { setter(!isOn); StateHasChanged(); })">
<div style="width:36px;height:20px;border-radius:10px;background:@(isOn ? "var(--admin-orange-primary)" : "var(--admin-border-subtle)");position:relative;transition:0.2s;">
<div style="width:16px;height:16px;border-radius:50%;background:white;position:absolute;top:2px;@(isOn ? "right:2px;" : "left:2px;");transition:0.2s;"></div>
</div>
<span style="font-size:13px;font-weight:500;">@label</span>
</div>;
} }
@{ RenderToggle("Quản lý tồn kho", _featHasInventory, v => _featHasInventory = v); }
@{ RenderToggle("Đặt lịch hẹn", _featHasBooking, v => _featHasBooking = v); }
@{ RenderToggle("Quản lý bàn", _featHasTables, v => _featHasTables = v); }
@{ RenderToggle("Hiển thị bếp", _featHasKitchen, v => _featHasKitchen = v); }
@{ RenderToggle("Vận chuyển", _featHasShipping, v => _featHasShipping = v); }
@{ RenderToggle("Giao hàng", _featHasDelivery, v => _featHasDelivery = v); }
</div>
</div>
</div>
@* ─── Save button ─── *@
@@ -2457,10 +2683,43 @@
private PosDataService.ShopSettingsInfo? _shopSettings;
private string _settingsOpenTime = "";
private string _settingsCloseTime = "";
private string _settingsOpenDays = "";
private string _settingsFeaturesConfig = "{}";
private List<string> _settingsOpenDays = new();
private bool _featHasInventory, _featHasBooking, _featHasTables;
private bool _featHasKitchen, _featHasShipping, _featHasDelivery;
private string? _settingsMessage;
private bool _settingsSuccess;
// Customer sub-tab state
private string _customerSubTab = "members"; // members | levels | exp
// Level CRUD state
private bool _showLevelForm;
private Guid? _editingLevelId;
private int _newLevelNumber;
private string _newLevelName = "";
private int _newLevelRequiredExp;
private string _newLevelDescription = "";
private string _newLevelBadgeColor = "#CD7F32";
private string? _levelFormMessage;
private bool _levelFormSuccess;
// EXP management state
private Guid? _expMemberId;
private int _expPoints = 100;
private int _expSourceId = 7;
private string? _expFormMessage;
private bool _expFormSuccess;
private PosDataService.MemberProgressInfo? _memberProgress;
private List<PosDataService.ExpTransactionInfo> _expHistory = new();
// Staff extended fields state
private string _newStaffAddress = "";
// Image upload state
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _productImageFile;
private string? _productImagePreview;
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _categoryImageFile;
private string? _categoryImagePreview;
private string _newCategoryImageUrl = "";
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _staffDocFrontFile;
private string? _staffDocFrontPreview;
private Microsoft.AspNetCore.Components.Forms.IBrowserFile? _staffDocBackFile;
private string? _staffDocBackPreview;
// Top products state
private List<PosDataService.TopProductInfo> _topProducts = new();
// Tables CRUD state
@@ -2642,10 +2901,18 @@
_shopSettings = await DataService.GetShopSettingsAsync(_shopGuid.Value);
if (_shopSettings != null)
{
_settingsOpenTime = _shopSettings.OpenTime ?? "";
_settingsCloseTime = _shopSettings.CloseTime ?? "";
_settingsOpenDays = _shopSettings.OpenDays ?? "";
_settingsFeaturesConfig= string.IsNullOrWhiteSpace(_shopSettings.FeaturesConfig) ? "{}" : _shopSettings.FeaturesConfig;
_settingsOpenTime = _shopSettings.OpenTime ?? "";
_settingsCloseTime = _shopSettings.CloseTime ?? "";
_settingsOpenDays = _shopSettings.OpenDays ?? new();
if (_shopSettings.Features != null)
{
_featHasInventory = _shopSettings.Features.HasInventory;
_featHasBooking = _shopSettings.Features.HasBooking;
_featHasTables = _shopSettings.Features.HasTables;
_featHasKitchen = _shopSettings.Features.HasKitchen;
_featHasShipping = _shopSettings.Features.HasShipping;
_featHasDelivery = _shopSettings.Features.HasDelivery;
}
}
}
catch { /* non-fatal */ }
@@ -2655,11 +2922,16 @@
{
if (!_shopGuid.HasValue) return;
_settingsMessage = null;
var features = new PosDataService.ShopFeaturesInfo
{
HasInventory = _featHasInventory, HasBooking = _featHasBooking, HasTables = _featHasTables,
HasKitchen = _featHasKitchen, HasShipping = _featHasShipping, HasDelivery = _featHasDelivery
};
var req = new PosDataService.UpdateShopSettingsRequest(
FeaturesConfig: _settingsFeaturesConfig,
OpenTime: string.IsNullOrWhiteSpace(_settingsOpenTime) ? null : _settingsOpenTime,
CloseTime: string.IsNullOrWhiteSpace(_settingsCloseTime) ? null : _settingsCloseTime,
OpenDays: _settingsOpenDays);
Features: features,
OpenTime: string.IsNullOrWhiteSpace(_settingsOpenTime) ? null : _settingsOpenTime,
CloseTime: string.IsNullOrWhiteSpace(_settingsCloseTime) ? null : _settingsCloseTime,
OpenDays: _settingsOpenDays.Any() ? _settingsOpenDays : null);
var ok = await DataService.UpdateShopSettingsAsync(_shopGuid.Value, req);
_settingsSuccess = ok;
_settingsMessage = ok ? "Đã lưu thiết lập thành công!" : "Lỗi khi lưu thiết lập.";
@@ -2668,10 +2940,8 @@
private void ToggleDay(string code)
{
var days = _settingsOpenDays.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
if (days.Contains(code)) days.Remove(code);
else days.Add(code);
_settingsOpenDays = string.Join(",", days);
if (_settingsOpenDays.Contains(code)) _settingsOpenDays.Remove(code);
else _settingsOpenDays.Add(code);
StateHasChanged();
}
@@ -2800,10 +3070,11 @@
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid) ? cid : null;
var imgUrl = await UploadFileIfNeeded(_productImageFile);
await DataService.CreateProductAsync(new PosDataService.CreateProductRequest(
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, imgUrl, null, catId));
_formMessage = $"Đã thêm '{_newProductName}' thành công!"; _formSuccess = true;
_newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductCategoryId = "";
_newProductName = ""; _newProductPrice = 0; _newProductDesc = ""; _newProductCategoryId = ""; _productImageFile = null; _productImagePreview = null;
_products = await DataService.GetAllProductsAsync(_shopGuid);
}
catch (Exception ex) { _formMessage = $"Lỗi: {ex.Message}"; _formSuccess = false; }
@@ -2827,6 +3098,8 @@
_newProductType = p.Type ?? "PreparedFood";
_newProductDesc = p.Description ?? "";
_newProductCategoryId = p.CategoryId?.ToString() ?? "";
_productImageFile = null;
_productImagePreview = p.ImageUrl;
_formMessage = null;
_showProductForm = true;
}
@@ -2841,8 +3114,9 @@
try
{
Guid? catId = Guid.TryParse(_newProductCategoryId, out var cid2) ? cid2 : null;
var imgUrl2 = await UploadFileIfNeeded(_productImageFile);
await DataService.UpdateProductAsync(_editingProductId.Value, new PosDataService.CreateProductRequest(
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, null, null, catId));
_shopGuid.Value, _newProductName, _newProductDesc, _newProductPrice, _newProductType, imgUrl2, null, catId));
_formMessage = $"Đã cập nhật '{_newProductName}' thành công!"; _formSuccess = true;
_editingProductId = null;
_products = await DataService.GetAllProductsAsync(_shopGuid);
@@ -2863,12 +3137,13 @@
}
try
{
var docFrontUrl = await UploadFileIfNeeded(_staffDocFrontFile);
var docBackUrl = await UploadFileIfNeeded(_staffDocBackFile);
if (_createStaffAccount)
{
if (string.IsNullOrWhiteSpace(_newStaffEmail) || string.IsNullOrWhiteSpace(_newStaffPassword)
|| string.IsNullOrWhiteSpace(_newStaffFirstName) || string.IsNullOrWhiteSpace(_newStaffLastName))
if (string.IsNullOrWhiteSpace(_newStaffEmail) || string.IsNullOrWhiteSpace(_newStaffPassword))
{
_staffFormMessage = "Vui lòng nhập đầy đủ email, mật khẩu, họ và tên."; _staffFormSuccess = false; return;
_staffFormMessage = "Vui lòng nhập đầy đủ email mật khẩu."; _staffFormSuccess = false; return;
}
var (ok, err) = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
_newStaffEmail, _newStaffPassword, _newStaffFirstName, _newStaffLastName, _newStaffRole, _shopGuid));
@@ -2878,10 +3153,12 @@
else
{
await DataService.CreateStaffAsync(new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole,
_newStaffFirstName, _newStaffLastName, _newStaffAddress, null, docFrontUrl, docBackUrl));
_staffFormMessage = $"Đã thêm NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
}
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _createStaffAccount = false;
_newStaffCode = ""; _newStaffPhone = ""; _newStaffEmail = ""; _newStaffFirstName = ""; _newStaffLastName = ""; _newStaffPassword = ""; _newStaffAddress = ""; _createStaffAccount = false;
_staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null;
_staff = await DataService.GetStaffAsync();
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
@@ -2894,6 +3171,12 @@
_newStaffRole = s.Role ?? "Cashier";
_newStaffPhone = s.Phone ?? "";
_newStaffEmail = s.Email ?? "";
_newStaffFirstName = s.FirstName ?? "";
_newStaffLastName = s.LastName ?? "";
_newStaffAddress = s.Address ?? "";
_staffDocFrontFile = null; _staffDocBackFile = null;
_staffDocFrontPreview = s.DocumentFrontUrl;
_staffDocBackPreview = s.DocumentBackUrl;
_staffFormMessage = null;
_showStaffForm = true;
}
@@ -2907,10 +3190,14 @@
}
try
{
var docFrontUrl = await UploadFileIfNeeded(_staffDocFrontFile) ?? (_staffDocFrontPreview?.StartsWith("data:") == true ? null : _staffDocFrontPreview);
var docBackUrl = await UploadFileIfNeeded(_staffDocBackFile) ?? (_staffDocBackPreview?.StartsWith("data:") == true ? null : _staffDocBackPreview);
await DataService.UpdateStaffAsync(_editingStaffId.Value, new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole,
_newStaffFirstName, _newStaffLastName, _newStaffAddress, null, docFrontUrl, docBackUrl));
_staffFormMessage = $"Đã cập nhật NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
_editingStaffId = null;
_staffDocFrontFile = null; _staffDocBackFile = null; _staffDocFrontPreview = null; _staffDocBackPreview = null;
_staff = await DataService.GetStaffAsync();
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
@@ -2937,7 +3224,8 @@
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);
var catImgUrl = await UploadFileIfNeeded(_categoryImageFile) ?? _newCategoryImageUrl;
var req = new PosDataService.AdminCreateCategoryRequest(_shopGuid ?? Guid.Empty, _newCategoryName, _newCategoryDesc, _newCategoryOrder, string.IsNullOrWhiteSpace(catImgUrl) ? null : catImgUrl);
bool ok;
if (_editingCategoryId.HasValue)
ok = await DataService.UpdateCategoryAsync(_editingCategoryId.Value, req);
@@ -2945,9 +3233,9 @@
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); }
if (ok) { _showCategoryForm = false; _categoryImageFile = null; _categoryImagePreview = null; _newCategoryImageUrl = ""; _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 void EditCategory(PosDataService.AdminCategoryInfo c) { _editingCategoryId = c.Id; _newCategoryName = c.Name ?? ""; _newCategoryDesc = c.Description ?? ""; _newCategoryOrder = c.DisplayOrder; _newCategoryImageUrl = c.ImageUrl ?? ""; _categoryImageFile = null; _categoryImagePreview = c.ImageUrl; _showCategoryForm = true; _categoryFormMessage = null; }
private async Task DeleteCategoryItem(Guid id) { await DataService.DeleteCategoryAsync(id); _categories = await DataService.GetAllCategoriesAsync(_shopGuid); }
// ═══ INVENTORY OPERATIONS ═══
@@ -3079,6 +3367,112 @@
_members = await DataService.GetMembersAsync();
}
// ═══ LEVEL CRUD ═══
private async Task SaveLevel()
{
_levelFormMessage = null;
if (string.IsNullOrWhiteSpace(_newLevelName) || _newLevelNumber <= 0)
{ _levelFormMessage = "Nhập tên và số level."; _levelFormSuccess = false; return; }
var req = new PosDataService.CreateLevelRequest(_newLevelNumber, _newLevelName, _newLevelRequiredExp, _newLevelDescription, _newLevelBadgeColor);
if (_editingLevelId.HasValue)
{
var (ok, err) = await DataService.UpdateLevelAsync(_editingLevelId.Value, req);
_levelFormSuccess = ok; _levelFormMessage = ok ? "Đã cập nhật!" : err ?? "Lỗi.";
}
else
{
var (ok, err) = await DataService.CreateLevelAsync(req);
_levelFormSuccess = ok; _levelFormMessage = ok ? "Đã thêm!" : err ?? "Lỗi.";
}
if (_levelFormSuccess) { _showLevelForm = false; _memberLevels = await DataService.GetMembershipLevelsAsync(); }
}
private void EditLevel(PosDataService.LevelDefinitionInfo lvl)
{
_editingLevelId = lvl.Id; _newLevelNumber = lvl.LevelNumber; _newLevelName = lvl.Name;
_newLevelRequiredExp = lvl.RequiredExp; _newLevelDescription = lvl.Description ?? "";
_newLevelBadgeColor = lvl.BadgeColor ?? "#CD7F32"; _showLevelForm = true;
}
private async Task DeleteLevel(Guid id)
{
await DataService.DeleteLevelAsync(id);
_memberLevels = await DataService.GetMembershipLevelsAsync();
}
// ═══ EXP MANAGEMENT ═══
private async Task AddExpToMember()
{
_expFormMessage = null;
if (!_expMemberId.HasValue || _expPoints <= 0)
{ _expFormMessage = "Chọn thành viên và nhập số điểm > 0."; _expFormSuccess = false; return; }
var result = await DataService.AddExperienceAsync(_expMemberId.Value, new PosDataService.AddExpRequest(_expPoints, _expSourceId, null));
if (result != null)
{
_expFormSuccess = true;
_expFormMessage = result.LeveledUp ? $"Đã cộng {result.PointsAdded} EXP! Lên level {result.CurrentLevel}!" : $"Đã cộng {result.PointsAdded} EXP! Tổng: {result.TotalExpEarned}";
_memberProgress = await DataService.GetMemberProgressAsync(_expMemberId.Value);
_expHistory = await DataService.GetExperienceHistoryAsync(_expMemberId.Value);
_members = await DataService.GetMembersAsync();
}
else { _expFormMessage = "Lỗi khi cộng EXP."; _expFormSuccess = false; }
}
private async Task LoadMemberProgress()
{
if (_expMemberId.HasValue)
{
_memberProgress = await DataService.GetMemberProgressAsync(_expMemberId.Value);
_expHistory = await DataService.GetExperienceHistoryAsync(_expMemberId.Value);
}
else { _memberProgress = null; _expHistory = new(); }
}
// ═══ IMAGE UPLOAD HELPERS ═══
private async Task OnProductImageSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_productImageFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 200, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_productImagePreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task OnCategoryImageSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_categoryImageFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 200, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_categoryImagePreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task OnStaffDocFrontSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_staffDocFrontFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 300, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_staffDocFrontPreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task OnStaffDocBackSelected(Microsoft.AspNetCore.Components.Forms.InputFileChangeEventArgs e)
{
_staffDocBackFile = e.File;
var format = "image/png";
var resized = await e.File.RequestImageFileAsync(format, 300, 200);
var buffer = new byte[resized.Size];
await resized.OpenReadStream().ReadAsync(buffer);
_staffDocBackPreview = $"data:{format};base64,{Convert.ToBase64String(buffer)}";
StateHasChanged();
}
private async Task<string?> UploadFileIfNeeded(Microsoft.AspNetCore.Components.Forms.IBrowserFile? file)
{
if (file == null) return null;
using var stream = file.OpenReadStream(maxAllowedSize: 10_485_760);
return await DataService.UploadImageAsync(stream, file.Name, file.ContentType);
}
// ═══ TABLE CRUD ═══
private void EditTable(PosDataService.TableInfo table)
{

View File

@@ -139,10 +139,11 @@ public class PosDataService
{
public string? Category => CategoryName;
}
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder);
public record CategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder, string? ImageUrl = null);
public record TableInfo(Guid Id, string TableNumber, int Capacity, string? Zone, string Status, Guid? SessionId, int? GuestCount, DateTime? StartedAt);
public record AppointmentInfo(Guid Id, Guid? CustomerId, Guid? StaffId, Guid? ResourceId, Guid ServiceId, DateTime StartTime, DateTime EndTime, string Status, string? ResourceName);
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName);
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName,
string? FirstName = null, string? LastName = null, string? Address = null, string? ProfilePhotoUrl = null, string? DocumentFrontUrl = null, string? DocumentBackUrl = null);
public async Task<List<ShopInfo>> GetShopsAsync()
=> await GetListFromApiAsync<ShopInfo>("api/bff/shops");
@@ -172,7 +173,7 @@ public class PosDataService
public record AdminProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description,
string? ImageUrl, bool IsActive, string? Type, Guid ShopId, DateTime CreatedAt, string? CategoryName, Guid? CategoryId = null);
public record AdminCategoryInfo(Guid Id, string Name, string? Description, int DisplayOrder,
Guid ShopId, Guid? ParentId, bool IsActive);
Guid ShopId, Guid? ParentId, bool IsActive, string? ImageUrl = null);
public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price,
string? Type, string? Sku, string? ImageUrl, Guid? CategoryId = null);
@@ -234,7 +235,9 @@ public class PosDataService
// ═══ STAFF CREATE ═══
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role,
string? FirstName = null, string? LastName = null, string? Address = null,
string? ProfilePhotoUrl = null, string? DocumentFrontUrl = null, string? DocumentBackUrl = null);
public async Task<bool> CreateStaffAsync(CreateStaffRequest req)
{
@@ -436,11 +439,95 @@ public class PosDataService
// ═══ MEMBERSHIP LEVELS ═══
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
public record LevelDefinitionInfo(Guid Id, int LevelNumber, string Name, int RequiredExp,
string? Description = null, string? BadgeColor = null, bool IsActive = true, int MemberCount = 0,
int MinExp = 0, int MaxExp = 0)
{
public int Level => LevelNumber;
};
public async Task<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
=> await GetListFromApiAsync<LevelDefinitionInfo>("api/bff/membership/levels");
// ═══ LEVEL DEFINITION CRUD ═══
public record CreateLevelRequest(int LevelNumber, string Name, int RequiredExp, string? Description, string? BadgeColor);
public async Task<(bool Ok, string? Error)> CreateLevelAsync(CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/membership/levels", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
}
public async Task<(bool Ok, string? Error)> UpdateLevelAsync(Guid levelId, CreateLevelRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/membership/levels/{levelId}", req, _writeOptions);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, await TryExtractError(resp));
}
public async Task<bool> DeleteLevelAsync(Guid levelId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/membership/levels/{levelId}");
return resp.IsSuccessStatusCode;
}
// ═══ EXP MANAGEMENT ═══
public record AddExpRequest(int Points, int SourceId, string? ReferenceId);
public record AddExpResult(Guid MemberId, int PointsAdded, int CurrentExp, int TotalExpEarned,
int PreviousLevel, int CurrentLevel, bool LeveledUp);
public record MemberProgressInfo(Guid MemberId, int CurrentLevel, string? CurrentLevelName,
int CurrentExp, int TotalExpEarned, int ExpToNextLevel, int ProgressPercent,
int? NextLevel, string? NextLevelName, string? BadgeColor);
public record ExpTransactionInfo(Guid Id, int Points, string? Source, int SourceId,
string? ReferenceId, int LevelAtTime, DateTime CreatedAt);
public async Task<AddExpResult?> AddExperienceAsync(Guid memberId, AddExpRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync($"api/bff/members/{memberId}/experience", req, _writeOptions);
if (resp.IsSuccessStatusCode)
return await resp.Content.ReadFromJsonAsync<AddExpResult>(_jsonOptions);
return null;
}
public async Task<MemberProgressInfo?> GetMemberProgressAsync(Guid memberId)
=> await GetObjectFromApiAsync<MemberProgressInfo>($"api/bff/members/{memberId}/progress");
public async Task<List<ExpTransactionInfo>> GetExperienceHistoryAsync(Guid memberId)
=> await GetListFromApiAsync<ExpTransactionInfo>($"api/bff/members/{memberId}/experience");
// ═══ FILE UPLOAD (Storage Service) ═══
public async Task<string?> UploadImageAsync(Stream fileStream, string fileName, string contentType)
{
AttachToken();
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(streamContent, "file", fileName);
var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// EN: Try to extract download URL or file ID from response
if (doc.RootElement.TryGetProperty("downloadUrl", out var url))
return url.GetString();
if (doc.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("downloadUrl", out var dUrl)) return dUrl.GetString();
if (data.TryGetProperty("fileId", out var fId)) return $"api/bff/files/{fId.GetString()}/download";
}
if (doc.RootElement.TryGetProperty("fileId", out var fileId))
return $"api/bff/files/{fileId.GetString()}/download";
return null;
}
// ═══ SHOP STATS (aggregated per-shop) ═══
public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue);
@@ -512,7 +599,7 @@ public class PosDataService
// 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 record AdminCreateCategoryRequest(Guid ShopId, string Name, string? Description, int DisplayOrder, string? ImageUrl = null);
public async Task<bool> CreateCategoryAsync(AdminCreateCategoryRequest req)
{
@@ -588,8 +675,24 @@ public class PosDataService
// ═══ SHOP SETTINGS ═══
public record ShopSettingsInfo(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
public record UpdateShopSettingsRequest(string? FeaturesConfig, string? OpenTime, string? CloseTime, string? OpenDays);
public record ShopFeaturesInfo
{
public bool HasInventory { get; init; }
public bool HasBooking { get; init; }
public bool HasTables { get; init; }
public bool HasKitchen { get; init; }
public bool HasShipping { get; init; }
public bool HasDelivery { get; init; }
}
public record ShopSettingsInfo
{
public Guid ShopId { get; init; }
public ShopFeaturesInfo? Features { get; init; }
public string? OpenTime { get; init; }
public string? CloseTime { get; init; }
public List<string>? OpenDays { get; init; }
}
public record UpdateShopSettingsRequest(ShopFeaturesInfo? Features, string? OpenTime, string? CloseTime, List<string>? OpenDays);
public async Task<ShopSettingsInfo?> GetShopSettingsAsync(Guid shopId)
=> await GetObjectFromApiAsync<ShopSettingsInfo>($"api/bff/shops/{shopId}/settings");

View File

@@ -81,4 +81,56 @@ public class MembershipController : ControllerBase
[HttpGet("membership/levels")]
public Task<IActionResult> GetMembershipLevels() =>
_membership.GetAsync("/api/v1/levels").ProxyAsync();
// ═══ LEVEL CRUD ═══
/// <summary>
/// EN: Create a membership level.
/// VI: Tạo cấp bậc membership.
/// </summary>
[HttpPost("membership/levels")]
public Task<IActionResult> CreateLevel([FromBody] JsonElement body) =>
_membership.PostAsJsonAsync("/api/v1/levels", body).ProxyAsync();
/// <summary>
/// EN: Update a membership level.
/// VI: Cập nhật cấp bậc membership.
/// </summary>
[HttpPut("membership/levels/{id:guid}")]
public Task<IActionResult> UpdateLevel(Guid id, [FromBody] JsonElement body) =>
_membership.PutAsJsonAsync($"/api/v1/levels/{id}", body).ProxyAsync();
/// <summary>
/// EN: Deactivate a membership level.
/// VI: Vô hiệu hóa cấp bậc membership.
/// </summary>
[HttpDelete("membership/levels/{id:guid}")]
public Task<IActionResult> DeleteLevel(Guid id) =>
_membership.DeleteAsync($"/api/v1/levels/{id}").ProxyAsync();
// ═══ EXPERIENCE MANAGEMENT ═══
/// <summary>
/// EN: Add experience points to a member.
/// VI: Cộng điểm kinh nghiệm cho thành viên.
/// </summary>
[HttpPost("members/{memberId:guid}/experience")]
public Task<IActionResult> AddExperience(Guid memberId, [FromBody] JsonElement body) =>
_membership.PostAsJsonAsync($"/api/v1/members/{memberId}/experience", body).ProxyAsync();
/// <summary>
/// EN: Get member level progress.
/// VI: Lấy tiến trình cấp bậc thành viên.
/// </summary>
[HttpGet("members/{memberId:guid}/progress")]
public Task<IActionResult> GetMemberProgress(Guid memberId) =>
_membership.GetAsync($"/api/v1/members/{memberId}/progress").ProxyAsync();
/// <summary>
/// EN: Get member experience history.
/// VI: Lấy lịch sử điểm kinh nghiệm thành viên.
/// </summary>
[HttpGet("members/{memberId:guid}/experience")]
public Task<IActionResult> GetExperienceHistory(Guid memberId, [FromQuery] int pageSize = 20) =>
_membership.GetAsync($"/api/v1/members/{memberId}/experience?pageSize={pageSize}").ProxyAsync();
}

View File

@@ -85,7 +85,9 @@ public class StaffController : ControllerBase
{
email = iamPayload.email,
role = body.TryGetProperty("role", out var r) ? r.GetString() : "Cashier",
shopId = body.TryGetProperty("shopId", out var s) ? s.GetString() : null as string
shopId = body.TryGetProperty("shopId", out var s) ? s.GetString() : null as string,
firstName = iamPayload.firstName,
lastName = iamPayload.lastName
};
return await _merchant.PostAsJsonAsync("/api/v1/merchants/me/staff/invite", invitePayload).ProxyAsync();
}

View File

@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Mvc;
using WebClientTpos.Server.Infrastructure;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// EN: Storage controller — proxies to StorageService for file uploads/downloads (MinIO).
/// VI: Controller lưu trữ — proxy đến StorageService cho upload/download file (MinIO).
/// </summary>
[ApiController]
[Route("api/bff")]
public class StorageController : ControllerBase
{
private readonly HttpClient _storage;
public StorageController(IHttpClientFactory httpClientFactory)
{
_storage = httpClientFactory.CreateClient("StorageService");
}
/// <summary>
/// EN: Upload a file (image) to storage.
/// VI: Upload file (ảnh) lên storage.
/// </summary>
[HttpPost("files/upload")]
[RequestSizeLimit(10_485_760)] // 10MB
public async Task<IActionResult> UploadFile(IFormFile file, [FromQuery] string? accessLevel = "public")
{
using var content = new MultipartFormDataContent();
using var stream = file.OpenReadStream();
var streamContent = new StreamContent(stream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(file.ContentType);
content.Add(streamContent, "file", file.FileName);
var response = await _storage.PostAsync($"/api/v1/files/upload?accessLevel={accessLevel}", content);
var body = await response.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)response.StatusCode,
Content = body,
ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
/// <summary>
/// EN: Download a file by ID.
/// VI: Tải file theo ID.
/// </summary>
[HttpGet("files/{fileId:guid}/download")]
public async Task<IActionResult> DownloadFile(Guid fileId)
{
var response = await _storage.GetAsync($"/api/v1/files/{fileId}/download");
if (!response.IsSuccessStatusCode)
{
var errBody = await response.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)response.StatusCode,
Content = errBody,
ContentType = "application/json"
};
}
var fileStream = await response.Content.ReadAsStreamAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
var fileName = response.Content.Headers.ContentDisposition?.FileName?.Trim('"') ?? "file";
return File(fileStream, contentType, fileName);
}
}

View File

@@ -117,6 +117,7 @@ AddServiceClient("PromotionService", "PromotionService__BaseUrl", "http://loca
AddServiceClient("BookingService", "BookingService__BaseUrl", "http://localhost:5020");
AddServiceClient("FnbEngine", "FnbEngine__BaseUrl", "http://localhost:5019");
AddServiceClient("IamService", "IamService__BaseUrl", "http://localhost:5001");
AddServiceClient("StorageService", "StorageService__BaseUrl", "http://localhost:5002");
var app = builder.Build();

View File

@@ -40,4 +40,10 @@ public record CreateCategoryCommand : IRequest<Guid>
/// VI: Thứ tự hiển thị.
/// </summary>
public int DisplayOrder { get; init; }
/// <summary>
/// EN: Category image URL.
/// VI: URL hình ảnh danh mục.
/// </summary>
public string? ImageUrl { get; init; }
}

View File

@@ -31,8 +31,15 @@ public class CreateCategoryCommandHandler : IRequestHandler<CreateCategoryComman
parentId: request.ParentId,
displayOrder: request.DisplayOrder);
// EN: Update image if provided
// VI: Cập nhật hình ảnh nếu được cung cấp
if (request.ImageUrl != null)
{
category.UpdateImage(request.ImageUrl);
}
// EN: Add to context and save
// VI: Thêm vào context và lư
// VI: Thêm vào context và lưu
_context.Categories.Add(category);
await _context.SaveChangesAsync(cancellationToken);

View File

@@ -0,0 +1,24 @@
// EN: Command to delete a category.
// VI: Command để xóa danh mục.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a category.
/// VI: Command để xóa danh mục.
/// </summary>
public record DeleteCategoryCommand : IRequest<bool>
{
/// <summary>
/// EN: Category ID to delete.
/// VI: ID danh mục cần xóa.
/// </summary>
public Guid CategoryId { get; init; }
public DeleteCategoryCommand(Guid categoryId)
{
CategoryId = categoryId;
}
}

View File

@@ -0,0 +1,45 @@
// EN: Handler for DeleteCategoryCommand.
// VI: Handler cho DeleteCategoryCommand.
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for deleting a category.
/// VI: Handler xóa danh mục.
/// </summary>
public class DeleteCategoryCommandHandler : IRequestHandler<DeleteCategoryCommand, bool>
{
private readonly CatalogContext _context;
public DeleteCategoryCommandHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<bool> Handle(DeleteCategoryCommand request, CancellationToken cancellationToken)
{
// EN: Retrieve category
// VI: Lấy danh mục
var category = await _context.Categories
.FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
if (category == null)
{
return false;
}
// EN: Deactivate category instead of hard delete
// VI: Vô hiệu hóa danh mục thay vì xóa cứng
category.Deactivate();
// EN: Save changes
// VI: Lưu thay đổi
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,12 @@
// EN: Command to update an existing category.
// VI: Command để cập nhật danh mục hiện tại.
using MediatR;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Command to update category information.
/// VI: Command để cập nhật thông tin danh mục.
/// </summary>
public record UpdateCategoryCommand(Guid CategoryId, string Name, string? Description, int DisplayOrder, string? ImageUrl) : IRequest<bool>;

View File

@@ -0,0 +1,52 @@
// EN: Handler for UpdateCategoryCommand.
// VI: Handler cho UpdateCategoryCommand.
using MediatR;
using Microsoft.EntityFrameworkCore;
using CatalogService.Infrastructure;
namespace CatalogService.API.Application.Commands;
/// <summary>
/// EN: Handler for updating a category.
/// VI: Handler cập nhật danh mục.
/// </summary>
public class UpdateCategoryCommandHandler : IRequestHandler<UpdateCategoryCommand, bool>
{
private readonly CatalogContext _context;
public UpdateCategoryCommandHandler(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<bool> Handle(UpdateCategoryCommand request, CancellationToken cancellationToken)
{
// EN: Retrieve category
// VI: Lấy danh mục
var category = await _context.Categories
.FirstOrDefaultAsync(c => c.Id == request.CategoryId, cancellationToken);
if (category == null)
{
return false;
}
// EN: Update basic information
// VI: Cập nhật thông tin cơ bản
category.UpdateInfo(request.Name, request.Description, request.DisplayOrder);
// EN: Update image if provided
// VI: Cập nhật hình ảnh nếu được cung cấp
if (request.ImageUrl != null)
{
category.UpdateImage(request.ImageUrl);
}
// EN: Save changes
// VI: Lưu thay đổi
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -45,6 +45,12 @@ public record CategoryDto
/// </summary>
public int DisplayOrder { get; init; }
/// <summary>
/// EN: Category image URL.
/// VI: URL hình ảnh danh mục.
/// </summary>
public string? ImageUrl { get; init; }
/// <summary>
/// EN: Is category active.
/// VI: Danh mục có đang hoạt động không.

View File

@@ -50,6 +50,7 @@ public class GetCategoriesQueryHandler : IRequestHandler<GetCategoriesQuery, Lis
Description = c.Description,
ParentId = c.ParentId,
DisplayOrder = c.DisplayOrder,
ImageUrl = c.ImageUrl,
IsActive = c.IsActive,
CreatedAt = c.CreatedAt,
UpdatedAt = c.UpdatedAt

View File

@@ -89,4 +89,35 @@ public class CategoriesController : ControllerBase
new { shopId = command.ShopId },
categoryId);
}
/// <summary>
/// EN: Update an existing category.
/// VI: Cập nhật danh mục hiện tại.
/// </summary>
[HttpPut("{categoryId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateCategory(
Guid categoryId,
[FromBody] UpdateCategoryCommand command,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(command with { CategoryId = categoryId }, cancellationToken);
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Delete (deactivate) a category.
/// VI: Xóa (vô hiệu hóa) danh mục.
/// </summary>
[HttpDelete("{categoryId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteCategory(
Guid categoryId,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(new DeleteCategoryCommand(categoryId), cancellationToken);
return result ? Ok() : NotFound();
}
}

View File

@@ -17,6 +17,7 @@ public class Category : Entity
private string? _description;
private Guid? _parentId;
private int _displayOrder;
private string? _imageUrl;
private bool _isActive;
private DateTime _createdAt;
private DateTime? _updatedAt;
@@ -45,6 +46,12 @@ public class Category : Entity
/// </summary>
public Guid? ParentId => _parentId;
/// <summary>
/// EN: Category image URL.
/// VI: URL hình ảnh danh mục.
/// </summary>
public string? ImageUrl => _imageUrl;
/// <summary>
/// EN: Display order.
/// VI: Thứ tự hiển thị.
@@ -86,7 +93,8 @@ public class Category : Entity
string name,
string? description = null,
Guid? parentId = null,
int displayOrder = 0)
int displayOrder = 0,
string? imageUrl = null)
{
if (shopId == Guid.Empty)
throw new DomainException("Shop ID cannot be empty");
@@ -99,6 +107,7 @@ public class Category : Entity
_description = description?.Trim();
_parentId = parentId;
_displayOrder = displayOrder;
_imageUrl = imageUrl;
_isActive = true;
_createdAt = DateTime.UtcNow;
}
@@ -118,6 +127,16 @@ public class Category : Entity
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update category image.
/// VI: Cập nhật hình ảnh danh mục.
/// </summary>
public void UpdateImage(string? imageUrl)
{
_imageUrl = imageUrl;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update parent category.
/// VI: Cập nhật danh mục cha.

View File

@@ -43,6 +43,11 @@ public class CategoryEntityTypeConfiguration : IEntityTypeConfiguration<Category
.HasField("_parentId")
.HasColumnName("parent_id");
builder.Property(c => c.ImageUrl)
.HasField("_imageUrl")
.HasColumnName("image_url")
.HasMaxLength(500);
builder.Property(c => c.DisplayOrder)
.HasField("_displayOrder")
.HasColumnName("display_order")

View File

@@ -20,6 +20,8 @@ public record InviteStaffCommand : IRequest<InviteStaffResult>
public string Role { get; init; } = "Cashier";
public Guid? ShopId { get; init; }
public Guid? BranchId { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
public record InviteStaffResult(Guid StaffId, string Email, string Status);
@@ -70,7 +72,9 @@ public class InviteStaffCommandHandler : IRequestHandler<InviteStaffCommand, Inv
merchant.Id,
request.Email,
role,
StaffPermissions.ViewSales | StaffPermissions.ProcessPayment);
StaffPermissions.ViewSales | StaffPermissions.ProcessPayment,
request.FirstName,
request.LastName);
_staffRepository.Add(staff);
await _staffRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
@@ -104,6 +108,12 @@ public record UpdateStaffCommand : IRequest<bool>
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string? Role { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string? Address { get; init; }
public string? ProfilePhotoUrl { get; init; }
public string? DocumentFrontUrl { get; init; }
public string? DocumentBackUrl { get; init; }
}
/// <summary>
@@ -141,7 +151,15 @@ public class UpdateStaffCommandHandler : IRequestHandler<UpdateStaffCommand, boo
if (staff.MerchantId != merchant.Id)
throw new DomainException("You do not have permission to update this staff");
staff.Update(request.EmployeeCode, request.Phone);
staff.Update(
request.EmployeeCode,
request.Phone,
request.FirstName,
request.LastName,
request.Address,
request.ProfilePhotoUrl,
request.DocumentFrontUrl,
request.DocumentBackUrl);
if (!string.IsNullOrEmpty(request.Role))
{

View File

@@ -51,6 +51,12 @@ public class GetMyStaffQueryHandler : IRequestHandler<GetMyStaffQuery, IReadOnly
Email = s.Email ?? string.Empty,
EmployeeCode = s.EmployeeCode,
Phone = s.Phone,
FirstName = s.FirstName,
LastName = s.LastName,
Address = s.Address,
ProfilePhotoUrl = s.ProfilePhotoUrl,
DocumentFrontUrl = s.DocumentFrontUrl,
DocumentBackUrl = s.DocumentBackUrl,
Role = Enumeration.FromValue<StaffRole>(s.RoleId).Name,
Status = Enumeration.FromValue<StaffStatus>(s.StatusId).Name,
Permissions = (int)s.Permissions,
@@ -90,6 +96,12 @@ public record StaffDto
public string Email { get; init; } = null!;
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string? Address { get; init; }
public string? ProfilePhotoUrl { get; init; }
public string? DocumentFrontUrl { get; init; }
public string? DocumentBackUrl { get; init; }
public string Role { get; init; } = null!;
public string Status { get; init; } = null!;
public int Permissions { get; init; }

View File

@@ -55,7 +55,9 @@ public class StaffController : ControllerBase
Email = request.Email,
Role = request.Role,
ShopId = request.ShopId,
BranchId = request.BranchId
BranchId = request.BranchId,
FirstName = request.FirstName,
LastName = request.LastName
};
var result = await _mediator.Send(command);
_logger.LogInformation("Staff invited: {Email}", request.Email);
@@ -83,7 +85,13 @@ public class StaffController : ControllerBase
StaffId = staffId,
EmployeeCode = request.EmployeeCode,
Phone = request.Phone,
Role = request.Role
Role = request.Role,
FirstName = request.FirstName,
LastName = request.LastName,
Address = request.Address,
ProfilePhotoUrl = request.ProfilePhotoUrl,
DocumentFrontUrl = request.DocumentFrontUrl,
DocumentBackUrl = request.DocumentBackUrl
};
await _mediator.Send(command);
return Ok(new { message = "Staff updated successfully" });
@@ -189,6 +197,8 @@ public record InviteStaffRequest
public string Role { get; init; } = "Cashier";
public Guid? ShopId { get; init; }
public Guid? BranchId { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
public record UpdateStaffRequest
@@ -196,6 +206,12 @@ public record UpdateStaffRequest
public string? EmployeeCode { get; init; }
public string? Phone { get; init; }
public string? Role { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string? Address { get; init; }
public string? ProfilePhotoUrl { get; init; }
public string? DocumentFrontUrl { get; init; }
public string? DocumentBackUrl { get; init; }
}
public record AcceptInviteRequest

View File

@@ -31,6 +31,12 @@ public class MerchantStaff : Entity, IAggregateRoot
private DateTime? _terminatedAt;
private DateTime _createdAt;
private DateTime? _updatedAt;
private string? _firstName;
private string? _lastName;
private string? _address;
private string? _profilePhotoUrl;
private string? _documentFrontUrl;
private string? _documentBackUrl;
private readonly List<DeviceToken> _deviceTokens = new();
private readonly List<ShopMember> _shopAssignments = new();
@@ -137,6 +143,13 @@ public class MerchantStaff : Entity, IAggregateRoot
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
public string? FirstName => _firstName;
public string? LastName => _lastName;
public string? Address => _address;
public string? ProfilePhotoUrl => _profilePhotoUrl;
public string? DocumentFrontUrl => _documentFrontUrl;
public string? DocumentBackUrl => _documentBackUrl;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
@@ -153,7 +166,9 @@ public class MerchantStaff : Entity, IAggregateRoot
Guid merchantId,
string email,
StaffRole role,
StaffPermissions permissions = StaffPermissions.None)
StaffPermissions permissions = StaffPermissions.None,
string? firstName = null,
string? lastName = null)
{
if (merchantId == Guid.Empty)
throw new DomainException("Merchant ID cannot be empty");
@@ -172,6 +187,8 @@ public class MerchantStaff : Entity, IAggregateRoot
_status = StaffStatus.Invited,
StatusId = StaffStatus.Invited.Id,
_permissions = permissions,
_firstName = firstName?.Trim(),
_lastName = lastName?.Trim(),
_createdAt = DateTime.UtcNow
};
@@ -203,10 +220,24 @@ public class MerchantStaff : Entity, IAggregateRoot
/// EN: Update staff information.
/// VI: Cập nhật thông tin nhân viên.
/// </summary>
public void Update(string? employeeCode, string? phone)
public void Update(
string? employeeCode,
string? phone,
string? firstName = null,
string? lastName = null,
string? address = null,
string? profilePhotoUrl = null,
string? documentFrontUrl = null,
string? documentBackUrl = null)
{
_employeeCode = employeeCode?.Trim();
_phone = phone?.Trim();
_firstName = firstName?.Trim();
_lastName = lastName?.Trim();
_address = address?.Trim();
_profilePhotoUrl = profilePhotoUrl?.Trim();
_documentFrontUrl = documentFrontUrl?.Trim();
_documentBackUrl = documentBackUrl?.Trim();
_updatedAt = DateTime.UtcNow;
}

View File

@@ -82,6 +82,36 @@ public class MerchantStaffEntityTypeConfiguration : IEntityTypeConfiguration<Mer
.HasField("_updatedAt")
.HasColumnName("updated_at");
builder.Property(s => s.FirstName)
.HasField("_firstName")
.HasColumnName("first_name")
.HasMaxLength(100);
builder.Property(s => s.LastName)
.HasField("_lastName")
.HasColumnName("last_name")
.HasMaxLength(100);
builder.Property(s => s.Address)
.HasField("_address")
.HasColumnName("address")
.HasMaxLength(500);
builder.Property(s => s.ProfilePhotoUrl)
.HasField("_profilePhotoUrl")
.HasColumnName("profile_photo_url")
.HasMaxLength(500);
builder.Property(s => s.DocumentFrontUrl)
.HasField("_documentFrontUrl")
.HasColumnName("document_front_url")
.HasMaxLength(500);
builder.Property(s => s.DocumentBackUrl)
.HasField("_documentBackUrl")
.HasColumnName("document_back_url")
.HasMaxLength(500);
// EN: Configure navigation to device tokens
// VI: Cấu hình navigation đến device tokens
builder.HasMany(s => s.DeviceTokens)