feat(web-client-tpos): add staff update/delete and inventory update CRUD

BFF Endpoints:
- PUT /api/bff/staff/{id} — update employee code, role, phone, email
- DELETE /api/bff/staff/{id} — soft-delete (set status=Terminated)
- PUT /api/bff/inventory/{id} — update quantity and reorder level
- Fix CreateStaff: Forbid→Unauthorized, add created_at to INSERT

PosDataService Methods:
- UpdateStaffAsync(Guid, CreateStaffRequest)
- DeleteStaffAsync(Guid)
- UpdateInventoryAsync(Guid, UpdateInventoryRequest)

ShopPage.razor UI:
- Staff table: new Hành động column with edit/delete buttons
- Staff form: switches between Thêm/Chỉnh sửa mode
- EditStaff, SaveStaffEdit, DeleteStaffMember methods
This commit is contained in:
Ho Ngoc Hai
2026-03-03 20:34:56 +07:00
parent 8cba9021d0
commit 15b17f54ca
3 changed files with 143 additions and 6 deletions

View File

@@ -345,7 +345,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="@(() => { _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; _showStaffForm = !_showStaffForm; })">
<i data-lucide="user-plus" style="width:16px;height:16px;"></i>
Thêm nhân viên
</button>
@@ -353,7 +353,7 @@
@if (_showStaffForm)
{
<div class="admin-panel" style="margin-bottom:16px;border:1px solid rgba(139,92,246,0.3);">
<div class="admin-panel__header"><h3 class="admin-panel__title">Thêm nhân viên mới</h3></div>
<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;">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>
@@ -370,7 +370,7 @@
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Email</label><input type="text" @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>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="admin-btn-primary" @onclick="AddStaff" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>Lưu</button>
<button class="admin-btn-primary" @onclick="@(_editingStaffId.HasValue ? SaveStaffEdit : AddStaff)" style="display:inline-flex;align-items:center;gap:6px;"><i data-lucide="check" style="width:14px;height:14px;"></i>@(_editingStaffId.HasValue ? "Cập nhật" : "Lưu")</button>
<button @onclick="@(() => _showStaffForm = 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 (!string.IsNullOrEmpty(_staffFormMessage))
@@ -397,6 +397,7 @@
<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>
<th style="padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">SĐT</th>
<th style="padding:12px 16px;text-align:center;font-size:12px;text-transform:uppercase;color:var(--admin-text-tertiary);">Hành động</th>
</tr></thead><tbody>
@foreach (var s in _staff)
{
@@ -405,6 +406,12 @@
<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>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<button @onclick="@(() => EditStaff(s))" 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="@(() => DeleteStaffMember(s.Id))" style="background:rgba(239,68,68,0.1);border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;cursor:pointer;" title="Xóa"><i data-lucide="trash-2" style="color:#EF4444;width:14px;height:14px;"></i></button>
</div>
</td>
</tr>
}
</tbody></table>
@@ -1527,6 +1534,7 @@
private bool _formSuccess;
// Staff form state
private bool _showStaffForm;
private Guid? _editingStaffId;
private string _newStaffCode = "";
private string _newStaffRole = "Cashier";
private string _newStaffPhone = "";
@@ -1811,6 +1819,45 @@
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
}
private void EditStaff(PosDataService.StaffInfo s)
{
_editingStaffId = s.Id;
_newStaffCode = s.EmployeeCode ?? "";
_newStaffRole = s.Role ?? "Cashier";
_newStaffPhone = s.Phone ?? "";
_newStaffEmail = s.Email ?? "";
_staffFormMessage = null;
_showStaffForm = true;
}
private async Task SaveStaffEdit()
{
_staffFormMessage = null;
if (string.IsNullOrWhiteSpace(_newStaffCode) || !_merchantId.HasValue || !_editingStaffId.HasValue)
{
_staffFormMessage = "Vui lòng nhập Mã NV."; _staffFormSuccess = false; return;
}
try
{
await DataService.UpdateStaffAsync(_editingStaffId.Value, new PosDataService.CreateStaffRequest(
_merchantId.Value, _newStaffCode, _newStaffPhone, _newStaffEmail, _newStaffRole));
_staffFormMessage = $"Đã cập nhật NV '{_newStaffCode}' thành công!"; _staffFormSuccess = true;
_editingStaffId = null;
_staff = await DataService.GetStaffAsync();
}
catch (Exception ex) { _staffFormMessage = $"Lỗi: {ex.Message}"; _staffFormSuccess = false; }
}
private async Task DeleteStaffMember(Guid staffId)
{
try
{
await DataService.DeleteStaffAsync(staffId);
_staff = await DataService.GetStaffAsync();
}
catch (Exception ex) { _errorMessage = $"Không thể xóa nhân viên: {ex.Message}"; }
}
// EN: Day-of-week label / VI: Nhãn ngày trong tuần
private static string DayLabel(int dow) => dow switch
{

View File

@@ -142,6 +142,29 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
public async Task<bool> UpdateStaffAsync(Guid staffId, CreateStaffRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> DeleteStaffAsync(Guid staffId)
{
AttachToken();
var resp = await _http.DeleteAsync($"api/bff/staff/{staffId}");
return resp.IsSuccessStatusCode;
}
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
public async Task<bool> UpdateInventoryAsync(Guid inventoryId, UpdateInventoryRequest req)
{
AttachToken();
var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
// ═══ STAFF ROLES & SCHEDULES ═══
public record StaffRoleInfo(int Id, string Name);

View File

@@ -464,7 +464,7 @@ public class BffDataController : ControllerBase
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null || merchantId.Value != req.MerchantId)
return Forbid(); // EN: Cannot create staff for another merchant
return Unauthorized(); // EN: Cannot create staff for another merchant
var id = Guid.NewGuid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
@@ -478,12 +478,78 @@ public class BffDataController : ControllerBase
if (statusId == 0) statusId = 1;
await conn.ExecuteAsync(
@"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, joined_at)
VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW())",
@"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, joined_at, created_at)
VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW(), NOW())",
new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId });
return CreatedAtAction(nameof(GetStaff), new { }, new { id });
}
/// <summary>
/// EN: Update a staff member — validates merchant ownership.
/// VI: Cập nhật nhân viên — kiểm tra quyền sở hữu merchant.
/// </summary>
[HttpPut("staff/{staffId:guid}")]
public async Task<IActionResult> UpdateStaff(Guid staffId, [FromBody] CreateStaffRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null || merchantId.Value != req.MerchantId)
return Unauthorized();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var roleId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role });
if (roleId == 0) roleId = 1;
var rows = await conn.ExecuteAsync(
@"UPDATE merchant_staff SET employee_code = @EmployeeCode, phone = @Phone,
email = @Email, role_id = @RoleId
WHERE id = @Id AND merchant_id = @MerchantId",
new { Id = staffId, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId });
return rows > 0 ? Ok(new { id = staffId }) : NotFound();
}
/// <summary>
/// EN: Terminate (soft-delete) a staff member.
/// VI: Chấm dứt (xóa mềm) nhân viên.
/// </summary>
[HttpDelete("staff/{staffId:guid}")]
public async Task<IActionResult> DeleteStaff(Guid staffId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
// EN: Set status to Terminated + record termination date
var terminatedStatusId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_statuses WHERE name = 'Terminated'");
if (terminatedStatusId == 0) terminatedStatusId = 3;
await conn.ExecuteAsync(
@"UPDATE merchant_staff SET status_id = @StatusId, terminated_at = NOW()
WHERE id = @Id AND merchant_id = @MerchantId",
new { Id = staffId, StatusId = terminatedStatusId, MerchantId = merchantId.Value });
return NoContent();
}
/// <summary>
/// EN: Update inventory quantity for a specific item.
/// VI: Cập nhật số lượng tồn kho cho mặt hàng.
/// </summary>
[HttpPut("inventory/{inventoryId:guid}")]
public async Task<IActionResult> UpdateInventory(Guid inventoryId, [FromBody] UpdateInventoryRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null) return Unauthorized();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var rows = await conn.ExecuteAsync(
@"UPDATE inventory_items SET quantity = @Quantity, reorder_level = @ReorderLevel, updated_at = NOW()
WHERE id = @Id AND shop_id = ANY(@ShopIds)",
new { Id = inventoryId, req.Quantity, req.ReorderLevel, ShopIds = myShopIds.ToArray() });
return rows > 0 ? Ok(new { id = inventoryId }) : NotFound();
}
// ═══ STAFF ROLES ═══
[HttpGet("staff/roles")]
public async Task<IActionResult> GetStaffRoles()
@@ -1034,4 +1100,5 @@ public class BffDataController : ControllerBase
public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role);
public record CreatePosOrderRequest(Guid ShopId, string? PaymentMethod, List<PosOrderItemRequest> Items);
public record PosOrderItemRequest(Guid ProductId, string ProductName, int Quantity, decimal UnitPrice);
public record UpdateInventoryRequest(int Quantity, int ReorderLevel);
}