feat(web-client-tpos): rewrite staff, roles, audit, settings pages with real API data

This commit is contained in:
Ho Ngoc Hai
2026-02-28 04:51:28 +07:00
parent 1f0d11490e
commit 0379de323c
4 changed files with 607 additions and 206 deletions

View File

@@ -0,0 +1,227 @@
@page "/admin/settings"
@layout AdminLayout
@inherits AdminBase
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@inject WebClientTpos.Client.Services.PosDataService DataService
@*
EN: Admin settings page — account info, shop overview, general settings.
VI: Trang cài đặt admin — thông tin tài khoản, tổng quan cửa hàng, cài đặt chung.
*@
<PageTitle>Cài đặt — GoodGo Admin</PageTitle>
@* ═══ TOP BAR ═══ *@
<div class="admin-topbar">
<div class="admin-topbar__left">
<h1 class="admin-topbar__title">Cài đặt</h1>
<p class="admin-topbar__subtitle">Quản lý tài khoản và cấu hình hệ thống</p>
</div>
</div>
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_tab == "general" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "general")">
<i data-lucide="settings" style="width:14px;height:14px;"></i> Tổng quan
</button>
<button class="admin-tab @(_tab == "account" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "account")">
<i data-lucide="user" style="width:14px;height:14px;"></i> Tài khoản
</button>
<button class="admin-tab @(_tab == "notifications" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "notifications")">
<i data-lucide="bell" style="width:14px;height:14px;"></i> Thông báo
</button>
<button class="admin-tab @(_tab == "security" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "security")">
<i data-lucide="lock" style="width:14px;height:14px;"></i> Bảo mật
</button>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@if (_tab == "general")
{
@* ── System Info ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="info" style="color:var(--admin-orange-primary);"></i>
Thông tin hệ thống
</h3>
</div>
<div class="admin-panel__body" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Phiên bản</label>
<input class="admin-form-input" value="GoodGo Admin v1.0.0" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Môi trường</label>
<input class="admin-form-input" value="Development" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Số cửa hàng</label>
<input class="admin-form-input" value="@_shopCount cửa hàng" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Múi giờ</label>
<input class="admin-form-input" value="Asia/Ho_Chi_Minh (UTC+7)" readonly />
</div>
</div>
</div>
@* ── Service Health ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="activity" style="color:#22C55E;"></i>
Trạng thái dịch vụ
</h3>
</div>
<div class="admin-panel__body" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;">
@foreach (var svc in _services)
{
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div class="admin-status-badge admin-status-badge--online">
<span class="admin-status-badge__dot"></span>
Online
</div>
<span style="font-size:13px;font-weight:500;">@svc</span>
</div>
}
</div>
</div>
}
else if (_tab == "account")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
Thông tin tài khoản
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" value="@(AuthState.UserEmail ?? "—")" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Vai trò</label>
<input class="admin-form-input" value="Owner" readonly />
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Trạng thái</label>
<div class="admin-status-badge admin-status-badge--online" style="width:fit-content;">
<span class="admin-status-badge__dot"></span>
Đã xác thực
</div>
</div>
</div>
</div>
}
else if (_tab == "notifications")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bell" style="color:#F59E0B;"></i>
Cài đặt thông báo
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var notif in _notifSettings)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;flex-direction:column;">
<span style="font-size:13px;font-weight:600;">@notif.Label</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@notif.Desc</span>
</div>
<MudSwitch T="bool" Value="notif.Enabled" Color="Color.Primary" />
</div>
}
</div>
</div>
}
else if (_tab == "security")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="lock" style="color:#EF4444;"></i>
Bảo mật
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Đổi mật khẩu</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Thay đổi mật khẩu đăng nhập</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Đổi mật khẩu</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Xác thực 2 bước (2FA)</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Bảo mật tài khoản bằng mã OTP</p>
</div>
<MudSwitch T="bool" Value="false" Color="Color.Primary" />
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Phiên đăng nhập</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Quản lý các phiên đang hoạt động</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Xem phiên</button>
</div>
</div>
</div>
@* ─── DANGER ZONE ─── *@
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="color:#EF4444;">
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
Vùng nguy hiểm
</h3>
</div>
<div class="admin-panel__body">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<span style="font-size:13px;font-weight:600;">Xóa tài khoản</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;color:#EF4444;border-color:rgba(239,68,68,0.3);">Xóa tài khoản</button>
</div>
</div>
</div>
}
</div>
@code {
private string _tab = "general";
private int _shopCount = 0;
private readonly string[] _services = { "API Gateway", "IAM Service", "Merchant Service", "Catalog Service", "Order Service" };
protected override async Task OnInitializedAsync()
{
try
{
var shops = await DataService.GetShopsAsync();
_shopCount = shops.Count;
}
catch { _shopCount = 0; }
}
private record NotifSetting(string Label, string Desc, bool Enabled);
private readonly NotifSetting[] _notifSettings = new[]
{
new NotifSetting("Đơn hàng mới", "Thông báo khi có đơn hàng mới", true),
new NotifSetting("Đơn hàng hủy", "Thông báo khi đơn hàng bị hủy", true),
new NotifSetting("Hàng sắp hết", "Tồn kho dưới mức tối thiểu", true),
new NotifSetting("Nhân viên check-in", "Nhân viên bắt đầu ca", false),
new NotifSetting("Doanh thu bất thường", "Cảnh báo doanh thu", true),
new NotifSetting("Email hàng tuần", "Báo cáo doanh thu hàng tuần", true),
};
}

View File

@@ -1,11 +1,12 @@
@page "/admin/roles"
@layout AdminLayout
@inherits AdminBase
@inject WebClientTpos.Client.Services.IamApiService IamService
@using WebClientTpos.Client.Services
@*
EN: Role permissions — manage roles and their POS/admin permissions.
VI: Phân quyền — quản lý vai trò và quyền hạn POS/admin.
Design: pencil-design/src/pages/tPOS/admin/role-permissions.pen
EN: Role permissions — real roles from IAM Service API.
VI: Phân quyền — dữ liệu roles thật từ IAM Service API.
*@
<PageTitle>Phân quyền — GoodGo Admin</PageTitle>
@@ -25,49 +26,91 @@
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;gap:24px;">
@* LEFT: Roles list *@
<div style="width:280px;display:flex;flex-direction:column;gap:8px;">
@foreach (var role in _roles)
{
var r = role;
<button class="admin-role-card @(_selectedRole == r.Key ? "admin-role-card--active" : "")" @onclick="@(() => _selectedRole = r.Key)">
<div style="display:flex;align-items:center;gap:10px;">
<div class="admin-kpi-card__icon" style="width:36px;height:36px;border-radius:10px;background-color:@(r.Color)20;">
<i data-lucide="@r.Icon" style="color:@r.Color;width:18px;height:18px;"></i>
</div>
<div style="text-align:left;">
<div style="font-size:14px;font-weight:600;">@r.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@r.Count người</div>
</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</button>
}
@if (_loading)
{
<div class="admin-content" style="display:flex;align-items:center;justify-content:center;min-height:300px;">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
</div>
@* RIGHT: Permissions grid *@
<div style="flex:1;display:flex;flex-direction:column;gap:20px;">
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shield" style="color:var(--admin-orange-primary);"></i>
Quyền hạn — @(_roles.FirstOrDefault(r => r.Key == _selectedRole)?.Name ?? "")
</h3>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">
<i data-lucide="edit-2" style="width:14px;height:14px;"></i>
Chỉnh sửa
</button>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
@foreach (var group in _permissionGroups)
{
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="font-size:12px;font-weight:700;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:0.05em;">
@group.GroupName
}
else if (!_roles.Any())
{
<div class="admin-content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:12px;">
<i data-lucide="shield" style="width:48px;height:48px;color:var(--admin-text-tertiary);"></i>
<span style="color:var(--admin-text-tertiary);font-size:15px;">Chưa có vai trò nào</span>
<span style="color:var(--admin-text-tertiary);font-size:13px;">Vai trò sẽ được tạo khi hệ thống IAM hoạt động</span>
</div>
}
else
{
<div class="admin-content" style="display:flex;gap:24px;">
@* LEFT: Roles list *@
<div style="width:280px;display:flex;flex-direction:column;gap:8px;">
@foreach (var role in _roles)
{
<button class="admin-role-card @(_selectedId == role.Id ? "admin-role-card--active" : "")"
@onclick="@(() => _selectedId = role.Id)">
<div style="display:flex;align-items:center;gap:10px;">
<div class="admin-kpi-card__icon" style="width:36px;height:36px;border-radius:10px;background-color:@(GetRoleColor(role.Name))20;">
<i data-lucide="@GetRoleIcon(role.Name)" style="color:@GetRoleColor(role.Name);width:18px;height:18px;"></i>
</div>
@foreach (var perm in group.Permissions)
<div style="text-align:left;">
<div style="font-size:14px;font-weight:600;">@role.Name</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">
@(role.UserCount ?? 0) người
@if (role.IsSystem) { <span>• Hệ thống</span> }
</div>
</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--admin-text-tertiary);"></i>
</button>
}
</div>
@* RIGHT: Role Details *@
<div style="flex:1;display:flex;flex-direction:column;gap:20px;">
@{ var selected = _roles.FirstOrDefault(r => r.Id == _selectedId); }
@if (selected != null)
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shield" style="color:var(--admin-orange-primary);"></i>
Chi tiết — @selected.Name
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;gap:24px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@selected.Name</div>
<div class="admin-store-stat__label">Tên vai trò</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@(selected.UserCount ?? 0)</div>
<div class="admin-store-stat__label">Số người dùng</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@(selected.IsSystem ? "Có" : "Không")</div>
<div class="admin-store-stat__label">Vai trò hệ thống</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@selected.CreatedAt.ToString("dd/MM/yy")</div>
<div class="admin-store-stat__label">Ngày tạo</div>
</div>
</div>
@if (!string.IsNullOrEmpty(selected.Description))
{
<div style="padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<span style="font-size:12px;font-weight:600;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:0.05em;">Mô tả</span>
<p style="font-size:14px;margin-top:4px;">@selected.Description</p>
</div>
}
@* Permission Groups — placeholder since IAM doesn't expose permissions yet *@
<div style="font-size:12px;font-weight:700;color:var(--admin-text-tertiary);text-transform:uppercase;letter-spacing:0.05em;">
Quyền hạn
</div>
@foreach (var perm in _defaultPermissions)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;flex-direction:column;">
@@ -78,49 +121,55 @@
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
</div>
}
@code {
private string _selectedRole = "manager";
private bool _loading = true;
private Guid? _selectedId;
private List<IamApiService.RoleDto> _roles = new();
private record RoleDef(string Key, string Name, string Icon, string Color, string Count);
private readonly RoleDef[] _roles = new[]
protected override async Task OnInitializedAsync()
{
new RoleDef("owner", "Chủ sở hữu", "crown", "#FF5C00", "1"),
new RoleDef("manager", "Quản lý", "user-check", "#8B5CF6", "2"),
new RoleDef("cashier", "Thu ngân", "calculator", "#3B82F6", "3"),
new RoleDef("barista", "Barista", "coffee", "#22C55E", "2"),
new RoleDef("server", "Phục vụ", "hand", "#EC4899", "2"),
try { _roles = await IamService.GetRolesAsync(); }
catch { _roles = new(); }
finally
{
_selectedId = _roles.FirstOrDefault()?.Id;
_loading = false;
}
}
private static string GetRoleColor(string name) => name.ToLower() switch
{
"admin" or "administrator" => "#FF5C00",
"merchant" or "owner" => "#8B5CF6",
"cashier" => "#3B82F6",
"manager" => "#22C55E",
_ => "#6B7280"
};
private record Permission(string Label, string Desc, bool Allowed);
private record PermissionGroup(string GroupName, Permission[] Permissions);
private readonly PermissionGroup[] _permissionGroups = new[]
private static string GetRoleIcon(string name) => name.ToLower() switch
{
new PermissionGroup("POS", new[]
{
new Permission("Tạo đơn hàng", "Tạo và xử lý đơn hàng", true),
new Permission("Áp dụng giảm giá", "Giảm giá cho đơn hàng", true),
new Permission("Hủy đơn hàng", "Hủy đơn đã tạo", true),
new Permission("Hoàn tiền", "Hoàn tiền cho khách", false),
new Permission("Đóng ca", "Kết thúc ca bán hàng", true),
}),
new PermissionGroup("QUẢN LÝ", new[]
{
new Permission("Xem báo cáo", "Truy cập báo cáo doanh thu", true),
new Permission("Quản lý nhân sự", "Thêm/sửa/xóa nhân viên", false),
new Permission("Quản lý sản phẩm", "Thêm/sửa/xóa sản phẩm", true),
new Permission("Quản lý kho", "Nhập/xuất kho hàng", true),
}),
new PermissionGroup("HỆ THỐNG", new[]
{
new Permission("Cài đặt cửa hàng", "Thay đổi thông tin cửa hàng", false),
new Permission("Quản lý thiết bị", "Thêm/xóa thiết bị POS", false),
new Permission("Xem audit log", "Xem lịch sử thao tác", true),
}),
"admin" or "administrator" => "crown",
"merchant" or "owner" => "user-check",
"cashier" => "calculator",
"manager" => "briefcase",
_ => "shield"
};
// EN: Default permission display (placeholder until IAM exposes permissions API)
// VI: Hiển thị quyền mặc định (placeholder cho đến khi IAM cung cấp API permissions)
private record PermDef(string Label, string Desc, bool Allowed);
private readonly PermDef[] _defaultPermissions = new[]
{
new PermDef("Tạo đơn hàng", "Tạo và xử lý đơn hàng", true),
new PermDef("Áp dụng giảm giá", "Giảm giá cho đơn hàng", true),
new PermDef("Xem báo cáo", "Truy cập báo cáo doanh thu", false),
new PermDef("Quản lý nhân sự", "Thêm/sửa/xóa nhân viên", false),
new PermDef("Cài đặt cửa hàng", "Thay đổi thông tin cửa hàng", false),
};
}

View File

@@ -1,11 +1,12 @@
@page "/admin/staff"
@layout AdminLayout
@inherits AdminBase
@inject WebClientTpos.Client.Services.PosDataService DataService
@using WebClientTpos.Client.Services
@*
EN: Staff directory — grid of staff cards with search, filter by store/role.
VI: Danh bạ nhân sự — grid thẻ nhân viên, tìm kiếm, lọc theo cửa hàng/vai trò.
Design: pencil-design/src/pages/tPOS/admin/staff-directory.pen
EN: Staff directory — real data from BFF, grid of staff cards with search & filter.
VI: Danh bạ nhân sự — dữ liệu thật từ BFF, grid thẻ nhân viên, tìm kiếm & lọc.
*@
<PageTitle>Quản lý nhân sự — GoodGo Admin</PageTitle>
@@ -19,12 +20,8 @@
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm nhân viên..." @bind="SearchQuery" />
<input type="text" placeholder="Tìm nhân viên..." @bind="SearchQuery" @bind:event="oninput" />
</div>
<button class="admin-btn-secondary">
<i data-lucide="filter"></i>
<span>Bộ lọc</span>
</button>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("staff/create"))">
<i data-lucide="user-plus"></i>
<span>Thêm nhân viên</span>
@@ -38,62 +35,134 @@
Tất cả <span class="admin-tab__badge admin-tab__badge--active">@_staffList.Count</span>
</button>
<button class="admin-tab @(_activeTab == "active" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "active")">
Đang làm việc <span class="admin-tab__badge">@_staffList.Count(s => s.Status == "active")</span>
Đang làm <span class="admin-tab__badge">@_staffList.Count(s => s.Status == "Active")</span>
</button>
<button class="admin-tab @(_activeTab == "off" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "off")">
Nghỉ phép <span class="admin-tab__badge">@_staffList.Count(s => s.Status == "off")</span>
<button class="admin-tab @(_activeTab == "invited" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "invited")">
Chờ xác nhận <span class="admin-tab__badge">@_staffList.Count(s => s.Status == "Invited")</span>
</button>
<button class="admin-tab @(_activeTab == "inactive" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "inactive")">
Ngưng hoạt động <span class="admin-tab__badge">@_staffList.Count(s => s.Status == "Inactive" || s.Status == "Terminated")</span>
</button>
</div>
@* ═══ STAFF GRID ═══ *@
<div class="admin-content" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
@foreach (var staff in _staffList)
{
<div class="admin-staff-card" @onclick="@(() => NavigateTo($"staff/{staff.Id}"))">
<div class="admin-staff-card__header">
<div class="admin-user-avatar" style="width:48px;height:48px;font-size:16px;background-color:@staff.AvatarColor;">
@staff.Initials
@* ═══ CONTENT ═══ *@
@if (_loading)
{
<div class="admin-content" style="display:flex;align-items:center;justify-content:center;min-height:300px;">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (!FilteredStaff.Any())
{
<div class="admin-content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:12px;">
<i data-lucide="users" style="width:48px;height:48px;color:var(--admin-text-tertiary);"></i>
<span style="color:var(--admin-text-tertiary);font-size:15px;">Chưa có nhân viên nào</span>
<button class="admin-btn-primary" @onclick="@(() => NavigateTo("staff/create"))">
<i data-lucide="user-plus"></i>
<span>Thêm nhân viên đầu tiên</span>
</button>
</div>
}
else
{
<div class="admin-content" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
@foreach (var staff in FilteredStaff)
{
<div class="admin-staff-card">
<div class="admin-staff-card__header">
<div class="admin-user-avatar" style="width:48px;height:48px;font-size:16px;background-color:@GetAvatarColor(staff.Role ?? "");">
@GetInitials(staff.Email ?? staff.EmployeeCode ?? "?")
</div>
<div class="admin-status-badge @GetStatusCss(staff.Status)">
<span class="admin-status-badge__dot"></span>
@GetStatusLabel(staff.Status)
</div>
</div>
<div class="admin-status-badge @(staff.Status == "active" ? "admin-status-badge--online" : "admin-status-badge--setup")">
<span class="admin-status-badge__dot"></span>
@(staff.Status == "active" ? "Đang ca" : "Nghỉ")
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-size:16px;font-weight:600;">@(staff.Email ?? staff.EmployeeCode ?? "Nhân viên")</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(staff.Role ?? "—") • @(staff.ShopName ?? "Chưa gán")</span>
</div>
<div style="display:flex;gap:8px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@(staff.EmployeeCode ?? "—")</div>
<div class="admin-store-stat__label">Mã NV</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@(staff.Phone ?? "—")</div>
<div class="admin-store-stat__label">Điện thoại</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@FormatDate(staff.JoinedAt)</div>
<div class="admin-store-stat__label">Ngày vào</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:2px;">
<span style="font-size:16px;font-weight:600;">@staff.Name</span>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@staff.Role • @staff.Store</span>
</div>
<div style="display:flex;gap:8px;">
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@staff.Hours</div>
<div class="admin-store-stat__label">Giờ/tuần</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@staff.Orders</div>
<div class="admin-store-stat__label">Đơn hôm nay</div>
</div>
<div class="admin-store-stat" style="flex:1;">
<div class="admin-store-stat__value">@staff.Rating</div>
<div class="admin-store-stat__label">Đánh giá</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
@code {
private bool _loading = true;
private string _activeTab = "all";
private List<PosDataService.StaffInfo> _staffList = new();
private record StaffMember(string Id, string Name, string Initials, string Role, string Store, string Status, string AvatarColor, string Hours, string Orders, string Rating);
private readonly List<StaffMember> _staffList = new()
private IEnumerable<PosDataService.StaffInfo> FilteredStaff
{
new("1", "Trần Minh Đức", "TM", "Barista", "Coffee House Q1", "active", "#FF5C00", "42", "28", "4.8"),
new("2", "Lê Thị Thảo", "LT", "Thu ngân", "Coffee House Q1", "active", "#3B82F6", "38", "45", "4.9"),
new("3", "Nguyễn Hà My", "NH", "Phục vụ", "Nhà hàng Q3", "active", "#22C55E", "40", "32", "4.7"),
new("4", "Phạm Văn An", "PA", "Quản lý", "Nhà hàng Q3", "active", "#8B5CF6", "45", "0", "4.6"),
new("5", "Hoàng Thị Lan", "HL", "Barista", "Coffee House Q1", "off", "#EC4899", "0", "0", "4.5"),
new("6", "Võ Đình Khoa", "VK", "Bếp trưởng", "Nhà hàng Q3", "active", "#F59E0B", "44", "18", "4.9"),
new("7", "Đặng Phương Linh", "ĐL", "Phục vụ", "Nhà hàng Q3", "active", "#06B6D4", "36", "22", "4.4"),
new("8", "Bùi Thanh Tùng", "BT", "Thu ngân", "Café Thủ Đức", "active", "#3B82F6", "38", "30", "4.7"),
get
{
var list = _activeTab switch
{
"active" => _staffList.Where(s => s.Status == "Active"),
"invited" => _staffList.Where(s => s.Status == "Invited"),
"inactive" => _staffList.Where(s => s.Status == "Inactive" || s.Status == "Terminated"),
_ => _staffList
};
if (!string.IsNullOrEmpty(SearchQuery))
list = list.Where(s => (s.Email ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|| (s.EmployeeCode ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|| (s.Phone ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|| (s.ShopName ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
return list;
}
}
protected override async Task OnInitializedAsync()
{
try { _staffList = await DataService.GetStaffAsync(); }
catch { _staffList = new(); }
finally { _loading = false; }
}
private static string GetInitials(string value)
{
if (value.Contains('@')) value = value.Split('@')[0];
return value.Length >= 2 ? value[..2].ToUpper() : value.ToUpper();
}
private static string GetAvatarColor(string role) => role switch
{
"Cashier" => "#3B82F6",
"Waiter" => "#22C55E",
"Manager" => "#8B5CF6",
"Admin" => "#FF5C00",
_ => "#6B7280"
};
private static string GetStatusCss(string? status) => status switch
{
"Active" => "admin-status-badge--online",
"Invited" => "admin-status-badge--setup",
_ => "admin-status-badge--offline"
};
private static string GetStatusLabel(string? status) => status switch
{
"Active" => "Đang làm",
"Invited" => "Chờ xác nhận",
"Inactive" => "Tạm nghỉ",
"Terminated" => "Đã nghỉ",
_ => status ?? "—"
};
private static string FormatDate(DateTime? dt) => dt.HasValue ? dt.Value.ToString("dd/MM/yy") : "—";
}

View File

@@ -1,11 +1,12 @@
@page "/admin/system/audit"
@layout AdminLayout
@inherits AdminBase
@inject WebClientTpos.Client.Services.IamApiService IamService
@using WebClientTpos.Client.Services
@*
EN: Audit log — action log table with timestamp, user, action, resource, status. Search + filter by action type, date range, user.
VI: Nhật ký hệ thống — bảng log thao tác với thời gian, người dùng, hành động, tài nguyên, trạng thái. Tìm kiếm + lọc.
Design: pencil-design/src/pages/tPOS/admin/audit-log.pen
EN: Audit log — real audit log data from IAM Service.
VI: Nhật ký hệ thống — dữ liệu audit log thật từ IAM Service.
*@
<PageTitle>Nhật ký hệ thống — GoodGo Admin</PageTitle>
@@ -19,15 +20,11 @@
<div class="admin-topbar__right">
<div class="admin-search" style="width:220px;">
<i data-lucide="search"></i>
<input type="text" placeholder="Tìm trong log..." @bind="SearchQuery" />
<input type="text" placeholder="Tìm trong log..." @bind="SearchQuery" @bind:event="oninput" />
</div>
<button class="admin-btn-secondary">
<i data-lucide="filter"></i>
<span>Bộ lọc</span>
</button>
<button class="admin-btn-secondary">
<i data-lucide="download"></i>
<span>Xuất log</span>
<i data-lucide="refresh-cw"></i>
<span>Làm mới</span>
</button>
</div>
</div>
@@ -38,85 +35,144 @@
Tất cả <span class="admin-tab__badge admin-tab__badge--active">@_logs.Count</span>
</button>
<button class="admin-tab @(_activeTab == "auth" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "auth")">
Xác thực <span class="admin-tab__badge">@_logs.Count(l => l.Category == "auth")</span>
Xác thực <span class="admin-tab__badge">@_logs.Count(l => IsCategory(l, "auth"))</span>
</button>
<button class="admin-tab @(_activeTab == "config" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "config")">
Cấu hình <span class="admin-tab__badge">@_logs.Count(l => l.Category == "config")</span>
Cấu hình <span class="admin-tab__badge">@_logs.Count(l => IsCategory(l, "config"))</span>
</button>
<button class="admin-tab @(_activeTab == "data" ? "admin-tab--active" : "")" @onclick="@(() => _activeTab = "data")">
Dữ liệu <span class="admin-tab__badge">@_logs.Count(l => l.Category == "data")</span>
Dữ liệu <span class="admin-tab__badge">@_logs.Count(l => IsCategory(l, "data"))</span>
</button>
</div>
@* ═══ LOG TABLE ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:0;">
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<th>Thời gian</th>
<th>Người dùng</th>
<th>Hành động</th>
<th>Tài nguyên</th>
<th>IP</th>
<th>Trạng thái</th>
</tr>
</thead>
<tbody>
@foreach (var log in FilteredLogs)
{
@* ═══ CONTENT ═══ *@
@if (_loading)
{
<div class="admin-content" style="display:flex;align-items:center;justify-content:center;min-height:300px;">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
</div>
}
else if (!FilteredLogs.Any())
{
<div class="admin-content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:12px;">
<i data-lucide="file-text" style="width:48px;height:48px;color:var(--admin-text-tertiary);"></i>
<span style="color:var(--admin-text-tertiary);font-size:15px;">Chưa có log nào</span>
<span style="color:var(--admin-text-tertiary);font-size:13px;">Log sẽ xuất hiện khi có hoạt động trong hệ thống</span>
</div>
}
else
{
<div class="admin-content" style="display:flex;flex-direction:column;gap:0;">
<div class="admin-panel" style="flex:1;">
<div class="admin-panel__body" style="padding:0;">
<table class="admin-table">
<thead>
<tr>
<td style="font-size:12px;color:var(--admin-text-tertiary);white-space:nowrap;">@log.Timestamp</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:@log.AvatarColor;">@log.Initials</div>
<span style="font-weight:500;">@log.User</span>
</div>
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<i data-lucide="@log.ActionIcon" style="width:14px;height:14px;color:@log.ActionColor;"></i>
<span>@log.Action</span>
</div>
</td>
<td style="color:var(--admin-text-secondary);">@log.Resource</td>
<td style="font-size:12px;color:var(--admin-text-tertiary);font-family:monospace;">@log.IP</td>
<td>
<div class="admin-status-badge @(log.Status == "success" ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(log.Status == "success" ? "Thành công" : "Thất bại")
</div>
</td>
<th>Thời gian</th>
<th>Người dùng</th>
<th>Hành động</th>
<th>Tài nguyên</th>
<th>IP</th>
<th>Trạng thái</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var log in FilteredLogs)
{
<tr>
<td style="font-size:12px;color:var(--admin-text-tertiary);white-space:nowrap;">@FormatTime(log.Timestamp)</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div class="admin-user-avatar" style="width:28px;height:28px;font-size:10px;background-color:#8B5CF6;">
@GetInitials(log.ActorName ?? log.ActorId ?? "?")
</div>
<span style="font-weight:500;">@(log.ActorName ?? log.ActorId ?? "System")</span>
</div>
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<i data-lucide="@GetEventIcon(log.EventType)" style="width:14px;height:14px;color:@GetEventColor(log.EventType);"></i>
<span>@(log.EventType ?? "—")</span>
</div>
</td>
<td style="color:var(--admin-text-secondary);">@(log.ResourceType ?? "—") @(log.ResourceId != null ? $"#{log.ResourceId[..Math.Min(8, log.ResourceId.Length)]}" : "")</td>
<td style="font-size:12px;color:var(--admin-text-tertiary);font-family:monospace;">@(log.IpAddress ?? "—")</td>
<td>
<div class="admin-status-badge @(log.Status == "Success" ? "admin-status-badge--online" : "admin-status-badge--offline")">
<span class="admin-status-badge__dot"></span>
@(log.Status == "Success" ? "Thành công" : log.Status ?? "—")
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
@code {
private bool _loading = true;
private string _activeTab = "all";
private List<IamApiService.AuditLogDto> _logs = new();
private List<LogEntry> FilteredLogs => _activeTab == "all"
? _logs
: _logs.Where(l => l.Category == _activeTab).ToList();
private record LogEntry(string Timestamp, string User, string Initials, string AvatarColor, string Action,
string ActionIcon, string ActionColor, string Resource, string IP, string Status, string Category);
private readonly List<LogEntry> _logs = new()
private IEnumerable<IamApiService.AuditLogDto> FilteredLogs
{
new("12/02/2026 14:32:05", "Trần Minh Đức", "TM", "#FF5C00", "Đăng nhập", "log-in", "#22C55E", "Phiên làm việc", "192.168.1.45", "success", "auth"),
new("12/02/2026 14:28:11", "Admin System", "AS", "#8B5CF6", "Cập nhật giá", "edit", "#3B82F6", "Sản phẩm #142", "10.0.0.1", "success", "data"),
new("12/02/2026 14:15:30", "Lê Thị Thảo", "LT", "#3B82F6", "Tạo đơn hàng", "plus-circle", "#22C55E", "Đơn #2848", "192.168.1.50", "success", "data"),
new("12/02/2026 13:55:22", "Phạm Văn An", "PA", "#22C55E", "Thay đổi quyền", "shield", "#F59E0B", "Role: Barista", "192.168.1.12", "success", "config"),
new("12/02/2026 13:42:08", "Nguyễn Hà My", "NH", "#EC4899", "Đăng nhập thất bại", "log-in", "#EF4444", "Phiên làm việc", "192.168.1.88", "failed", "auth"),
new("12/02/2026 13:30:00", "Admin System", "AS", "#8B5CF6", "Backup dữ liệu", "database", "#3B82F6", "DB chính", "10.0.0.1", "success", "config"),
new("12/02/2026 12:18:45", "Võ Đình Khoa", "VK", "#F59E0B", "Nhập kho", "package", "#22C55E", "Kho Coffee Q1", "192.168.1.33", "success", "data"),
new("12/02/2026 11:55:12", "Trần Minh Đức", "TM", "#FF5C00", "Cập nhật cài đặt", "settings", "#F59E0B", "Cửa hàng Q1", "192.168.1.45", "success", "config"),
new("12/02/2026 10:30:00", "Lê Thị Thảo", "LT", "#3B82F6", "Hủy đơn hàng", "x-circle", "#EF4444", "Đơn #2845", "192.168.1.50", "success", "data"),
new("12/02/2026 09:15:33", "Phạm Văn An", "PA", "#22C55E", "Đăng nhập", "log-in", "#22C55E", "Phiên làm việc", "192.168.1.12", "success", "auth"),
get
{
var list = _activeTab == "all" ? _logs.AsEnumerable() : _logs.Where(l => IsCategory(l, _activeTab));
if (!string.IsNullOrEmpty(SearchQuery))
list = list.Where(l => (l.EventType ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|| (l.ActorName ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase)
|| (l.ResourceType ?? "").Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
return list;
}
}
protected override async Task OnInitializedAsync()
{
try { _logs = await IamService.GetAuditLogsAsync(); }
catch { _logs = new(); }
finally { _loading = false; }
}
private static bool IsCategory(IamApiService.AuditLogDto log, string cat)
{
var evt = (log.EventType ?? "").ToLower();
return cat switch
{
"auth" => evt.Contains("login") || evt.Contains("auth") || evt.Contains("token") || evt.Contains("password"),
"config" => evt.Contains("config") || evt.Contains("setting") || evt.Contains("role") || evt.Contains("permission"),
"data" => evt.Contains("create") || evt.Contains("update") || evt.Contains("delete") || evt.Contains("order"),
_ => true
};
}
private static string FormatTime(DateTime? dt) => dt?.ToString("dd/MM/yyyy HH:mm:ss") ?? "—";
private static string GetInitials(string v)
{
if (v.Contains('@')) v = v.Split('@')[0];
return v.Length >= 2 ? v[..2].ToUpper() : v.ToUpper();
}
private static string GetEventIcon(string? eventType) => (eventType ?? "").ToLower() switch
{
var e when e.Contains("login") => "log-in",
var e when e.Contains("create") => "plus-circle",
var e when e.Contains("update") => "edit",
var e when e.Contains("delete") => "trash-2",
var e when e.Contains("role") => "shield",
_ => "activity"
};
private static string GetEventColor(string? eventType) => (eventType ?? "").ToLower() switch
{
var e when e.Contains("login") => "#22C55E",
var e when e.Contains("create") => "#3B82F6",
var e when e.Contains("update") => "#F59E0B",
var e when e.Contains("delete") => "#EF4444",
_ => "#8B5CF6"
};
}