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