feat: implement category CRUD with image upload, extend staff profile fields, and add membership level/EXP management
This commit is contained in:
@@ -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 và 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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user