feat(staff-portal): implement staff attendance and leave request management with dedicated portal UI and backend services
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
@*
|
||||
EN: Staff portal layout — Sidebar with role-specific menu + Content area.
|
||||
VI: Layout cổng nhân viên — Sidebar với menu theo vai trò + Vùng nội dung.
|
||||
|
||||
Roles: Cashier, Waiter, Kitchen, Manager — each sees different menu items.
|
||||
Common: Dashboard, Attendance, Leave Requests, Notifications, Profile.
|
||||
*@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@inject WebClientTpos.Client.Services.AuthStateService AuthState
|
||||
@inject WebClientTpos.Client.Services.AuthService AuthSvc
|
||||
@inject WebClientTpos.Client.Services.PosDataService DataService
|
||||
|
||||
<MudThemeProvider IsDarkMode="true" Theme="AppTheme.DefaultDark" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="staff-layout">
|
||||
@* ═══ SIDEBAR ═══ *@
|
||||
<aside class="staff-sidebar @(_sidebarOpen ? "staff-sidebar--open" : "")">
|
||||
@* Logo *@
|
||||
<div class="staff-sidebar__logo">
|
||||
<div class="staff-sidebar__logo-icon">G</div>
|
||||
<div class="staff-sidebar__logo-text">
|
||||
<span class="staff-sidebar__logo-name">GoodGo Staff</span>
|
||||
<span class="staff-sidebar__logo-sub">@_shopName</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Navigation *@
|
||||
<nav class="staff-sidebar__nav">
|
||||
@* ── TỔNG QUAN ── *@
|
||||
<span class="staff-nav-label">TỔNG QUAN</span>
|
||||
<NavLink href="/staff/dashboard" class="staff-nav-item" Match="NavLinkMatch.All" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="layout-dashboard"></i>
|
||||
<span>Dashboard</span>
|
||||
</NavLink>
|
||||
|
||||
@* ── CÔNG VIỆC ── *@
|
||||
@if (_staffRole == "Manager" || _staffRole == "Kitchen" || _staffRole == "Waiter" || _staffRole == "Cashier")
|
||||
{
|
||||
<span class="staff-nav-label">CÔNG VIỆC</span>
|
||||
|
||||
@if (_staffRole == "Kitchen")
|
||||
{
|
||||
<NavLink href="/staff/kitchen" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="chef-hat"></i>
|
||||
<span>Bếp</span>
|
||||
</NavLink>
|
||||
}
|
||||
@if (_staffRole == "Waiter")
|
||||
{
|
||||
<NavLink href="/staff/tables" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="clipboard-list"></i>
|
||||
<span>Bàn / Order</span>
|
||||
</NavLink>
|
||||
}
|
||||
@if (_staffRole == "Cashier")
|
||||
{
|
||||
<NavLink href="/staff/pos" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="monitor"></i>
|
||||
<span>Thu ngân</span>
|
||||
</NavLink>
|
||||
}
|
||||
@if (_staffRole == "Manager")
|
||||
{
|
||||
<NavLink href="/staff/overview" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="bar-chart-3"></i>
|
||||
<span>Báo cáo</span>
|
||||
</NavLink>
|
||||
<NavLink href="/staff/team" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="users"></i>
|
||||
<span>Nhân viên</span>
|
||||
</NavLink>
|
||||
}
|
||||
}
|
||||
|
||||
@* ── NHÂN SỰ ── *@
|
||||
<span class="staff-nav-label">NHÂN SỰ</span>
|
||||
<NavLink href="/staff/attendance" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>Chấm công</span>
|
||||
</NavLink>
|
||||
<NavLink href="/staff/leave" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="calendar-off"></i>
|
||||
<span>Nghỉ phép</span>
|
||||
</NavLink>
|
||||
<NavLink href="/staff/schedule" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="calendar-days"></i>
|
||||
<span>Lịch làm việc</span>
|
||||
</NavLink>
|
||||
<NavLink href="/staff/notifications" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="bell"></i>
|
||||
<span>Thông báo</span>
|
||||
@if (_unreadNotifications > 0)
|
||||
{
|
||||
<span class="staff-badge">@_unreadNotifications</span>
|
||||
}
|
||||
</NavLink>
|
||||
<NavLink href="/staff/payroll" class="staff-nav-item" ActiveClass="staff-nav-item--active">
|
||||
<i data-lucide="wallet"></i>
|
||||
<span>Lương</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
@* User profile *@
|
||||
<div class="staff-sidebar__user">
|
||||
<div class="staff-user-avatar">@_userInitials</div>
|
||||
<div class="staff-user-info">
|
||||
<span class="staff-user-name">@_userName</span>
|
||||
<span class="staff-user-role">@_staffRole</span>
|
||||
</div>
|
||||
<button @onclick="Logout" title="Đăng xuất">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@* Mobile overlay *@
|
||||
@if (_sidebarOpen)
|
||||
{
|
||||
<div class="staff-sidebar-overlay" @onclick="CloseSidebar"></div>
|
||||
}
|
||||
|
||||
@* ═══ MAIN AREA ═══ *@
|
||||
<main class="staff-main">
|
||||
@* Mobile-only hamburger toggle *@
|
||||
<div class="staff-mobile-bar">
|
||||
<button class="staff-mobile-toggle" @onclick="ToggleSidebar">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<span class="staff-mobile-bar__title">GoodGo Staff</span>
|
||||
</div>
|
||||
<ErrorBoundary @ref="_errorBoundary">
|
||||
<ChildContent>
|
||||
<CascadingValue Value="this">
|
||||
@Body
|
||||
</CascadingValue>
|
||||
</ChildContent>
|
||||
<ErrorContent>
|
||||
<div style="text-align:center;padding:48px 20px;">
|
||||
<div style="width:64px;height:64px;border-radius:20px;background:rgba(239,68,68,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<i data-lucide="alert-triangle" style="width:28px;height:28px;color:#EF4444;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:16px;font-weight:700;margin:0 0 8px;color:#EF4444;">Có lỗi xảy ra</h3>
|
||||
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0 0 16px;">Vui lòng thử lại</p>
|
||||
<button class="admin-btn-primary" @onclick="RecoverError" style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;"></i>
|
||||
Tải lại
|
||||
</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _sidebarOpen = false;
|
||||
private ErrorBoundary? _errorBoundary;
|
||||
|
||||
private string _staffRole = "Staff";
|
||||
private string _shopName = "Cửa hàng";
|
||||
private Guid? _shopId;
|
||||
private int _unreadNotifications = 0;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current staff role — accessible from child pages.
|
||||
/// VI: Vai trò nhân viên hiện tại — truy cập được từ trang con.
|
||||
/// </summary>
|
||||
public string StaffRole => _staffRole;
|
||||
public Guid? ShopId => _shopId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
AuthState.OnChange += OnAuthStateChanged;
|
||||
|
||||
await LoadStaffProfile();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task LoadStaffProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await DataService.GetMyStaffProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
_staffRole = profile.Role ?? "Staff";
|
||||
_shopName = profile.ShopName ?? "Cửa hàng";
|
||||
_shopId = profile.ShopId;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// EN: Fallback to default / VI: Dùng mặc định nếu lỗi
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAuthStateChanged()
|
||||
{
|
||||
try { InvokeAsync(StateHasChanged); } catch { }
|
||||
}
|
||||
|
||||
private void RecoverError() => _errorBoundary?.Recover();
|
||||
|
||||
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ToggleSidebar() => _sidebarOpen = !_sidebarOpen;
|
||||
private void CloseSidebar() => _sidebarOpen = false;
|
||||
|
||||
private string _userName => AuthState.IsAuthenticated
|
||||
? (AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "Staff")
|
||||
: "Guest";
|
||||
private string _userInitials => _userName.Length >= 2
|
||||
? _userName[..2].ToUpper()
|
||||
: _userName.ToUpper();
|
||||
|
||||
private async Task Logout()
|
||||
{
|
||||
await AuthSvc.LogoutAsync();
|
||||
NavigationManager.NavigateTo("/auth/login/staff", forceLoad: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
AuthState.OnChange -= OnAuthStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
@page "/admin/shop/{ShopId}/attendance"
|
||||
@layout AdminLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Admin shop attendance management — view all staff attendance.
|
||||
VI: Quản lý chấm công cửa hàng — xem chấm công tất cả nhân viên.
|
||||
*@
|
||||
|
||||
<PageTitle>Chấm công - Quản lý</PageTitle>
|
||||
|
||||
<div style="padding:var(--admin-content-padding);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;">
|
||||
<div>
|
||||
<h1 style="font-size:24px;font-weight:700;margin:0 0 4px;">Chấm công</h1>
|
||||
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Quản lý chấm công nhân viên cửa hàng</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<button class="admin-btn-outline" @onclick="PrevMonth">
|
||||
<i data-lucide="chevron-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-size:15px;font-weight:600;min-width:120px;text-align:center;">Tháng @_month/@_year</span>
|
||||
<button class="admin-btn-outline" @onclick="NextMonth">
|
||||
<i data-lucide="chevron-right" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ SUMMARY CARDS ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;">@_staffCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Nhân viên</div>
|
||||
</div>
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#22C55E;">@_presentToday</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Có mặt hôm nay</div>
|
||||
</div>
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#F59E0B;">@_lateCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Đi muộn</div>
|
||||
</div>
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#EF4444;">@_absentCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Vắng mặt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ ATTENDANCE TABLE ═══ *@
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);overflow:hidden;">
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nhân viên</th>
|
||||
<th>Ngày</th>
|
||||
<th>Vào</th>
|
||||
<th>Ra</th>
|
||||
<th>Giờ làm</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_records.Count == 0)
|
||||
{
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Chưa có dữ liệu chấm công tháng này</td></tr>
|
||||
}
|
||||
@foreach (var r in _records)
|
||||
{
|
||||
<tr>
|
||||
<td style="font-weight:600;">@(r.StaffName ?? r.StaffId.ToString()[..8])</td>
|
||||
<td>@r.Date.ToString("dd/MM")</td>
|
||||
<td style="color:#22C55E;">@(r.CheckIn?.ToString("HH:mm") ?? "--")</td>
|
||||
<td style="color:#F59E0B;">@(r.CheckOut?.ToString("HH:mm") ?? "--")</td>
|
||||
<td>@(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--")</td>
|
||||
<td>
|
||||
@{
|
||||
var css = r.Status switch { "Completed" => "color:#22C55E", "Working" => "color:#3B82F6", "Late" => "color:#F59E0B", "Absent" => "color:#EF4444", _ => "" };
|
||||
}
|
||||
<span style="font-size:12px;font-weight:600;@css">@r.Status</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ShopId { get; set; } = "";
|
||||
|
||||
private List<AttendanceRow> _records = new();
|
||||
private bool _loading = true;
|
||||
private int _month = DateTime.Now.Month;
|
||||
private int _year = DateTime.Now.Year;
|
||||
private int _staffCount = 0;
|
||||
private int _presentToday = 0;
|
||||
private int _lateCount = 0;
|
||||
private int _absentCount = 0;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadData();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
if (Guid.TryParse(ShopId, out var shopGuid))
|
||||
{
|
||||
var staff = await DataService.GetStaffForShopAsync(shopGuid);
|
||||
_staffCount = staff.Count;
|
||||
// EN: Mock attendance for now — will proxy to real API
|
||||
// VI: Mock chấm công tạm thời — sẽ proxy đến API thực
|
||||
_records = new();
|
||||
_presentToday = staff.Count;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private async Task PrevMonth()
|
||||
{
|
||||
_month--;
|
||||
if (_month < 1) { _month = 12; _year--; }
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task NextMonth()
|
||||
{
|
||||
if (_month == DateTime.Now.Month && _year == DateTime.Now.Year) return;
|
||||
_month++;
|
||||
if (_month > 12) { _month = 1; _year++; }
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private record AttendanceRow(Guid StaffId, string? StaffName, DateTime Date, DateTime? CheckIn, DateTime? CheckOut, decimal? HoursWorked, string Status);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
@page "/admin/shop/{ShopId}/leave-requests"
|
||||
@layout AdminLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@*
|
||||
EN: Admin leave request management — approve/reject staff leave requests.
|
||||
VI: Quản lý nghỉ phép — duyệt/từ chối yêu cầu nghỉ phép nhân viên.
|
||||
*@
|
||||
|
||||
<PageTitle>Nghỉ phép - Quản lý</PageTitle>
|
||||
|
||||
<div style="padding:var(--admin-content-padding);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;">
|
||||
<div>
|
||||
<h1 style="font-size:24px;font-weight:700;margin:0 0 4px;">Quản lý nghỉ phép</h1>
|
||||
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0;">Duyệt và quản lý yêu cầu nghỉ phép nhân viên</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ SUMMARY ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px;">
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#F59E0B;">@_pendingCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Chờ duyệt</div>
|
||||
</div>
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#22C55E;">@_approvedCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Đã duyệt</div>
|
||||
</div>
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:16px;">
|
||||
<div style="font-size:24px;font-weight:700;color:#EF4444;">@_rejectedCount</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">Từ chối</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ LEAVE REQUESTS TABLE ═══ *@
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);overflow:hidden;">
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (_requests.Count == 0)
|
||||
{
|
||||
<div style="padding:48px;text-align:center;color:var(--admin-text-tertiary);">
|
||||
Chưa có yêu cầu nghỉ phép nào
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nhân viên</th>
|
||||
<th>Loại</th>
|
||||
<th>Từ ngày</th>
|
||||
<th>Đến ngày</th>
|
||||
<th>Số ngày</th>
|
||||
<th>Lý do</th>
|
||||
<th>Trạng thái</th>
|
||||
<th>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _requests)
|
||||
{
|
||||
<tr>
|
||||
<td style="font-weight:600;">@r.StaffId.ToString()[..8]...</td>
|
||||
<td>@GetLeaveTypeLabel(r.LeaveType)</td>
|
||||
<td>@r.StartDate.ToString("dd/MM/yyyy")</td>
|
||||
<td>@r.EndDate.ToString("dd/MM/yyyy")</td>
|
||||
<td>@((r.EndDate - r.StartDate).Days + 1)</td>
|
||||
<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;">@(r.Reason ?? "--")</td>
|
||||
<td>
|
||||
@{
|
||||
var statusCss = r.Status switch { "Approved" => "color:#22C55E", "Pending" => "color:#F59E0B", "Rejected" => "color:#EF4444", _ => "" };
|
||||
}
|
||||
<span style="font-size:12px;font-weight:600;@statusCss">@GetStatusLabel(r.Status)</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (r.Status == "Pending")
|
||||
{
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button style="background:#22C55E;color:white;border:none;border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer;" @onclick="@(() => Approve(r))">Duyệt</button>
|
||||
<button style="background:#EF4444;color:white;border:none;border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer;" @onclick="@(() => Reject(r))">Từ chối</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ShopId { get; set; } = "";
|
||||
|
||||
private List<PosDataService.LeaveRequest> _requests = new();
|
||||
private bool _loading = true;
|
||||
private int _pendingCount = 0;
|
||||
private int _approvedCount = 0;
|
||||
private int _rejectedCount = 0;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// EN: Mock — will be connected to real API
|
||||
// VI: Mock — sẽ kết nối API thực
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private void Approve(PosDataService.LeaveRequest r)
|
||||
{
|
||||
Snackbar.Add("Đã duyệt yêu cầu nghỉ phép", Severity.Success);
|
||||
}
|
||||
|
||||
private void Reject(PosDataService.LeaveRequest r)
|
||||
{
|
||||
Snackbar.Add("Đã từ chối yêu cầu nghỉ phép", Severity.Warning);
|
||||
}
|
||||
|
||||
private static string GetLeaveTypeLabel(string t) => t switch
|
||||
{
|
||||
"Annual" => "Phép năm",
|
||||
"Sick" => "Nghỉ ốm",
|
||||
"Personal" => "Cá nhân",
|
||||
_ => t
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string s) => s switch
|
||||
{
|
||||
"Approved" => "Đã duyệt",
|
||||
"Pending" => "Chờ duyệt",
|
||||
"Rejected" => "Từ chối",
|
||||
_ => s
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,7 @@
|
||||
@code {
|
||||
[Inject] private AuthService AuthSvc { get; set; } = default!;
|
||||
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||
[Inject] private PosDataService DataService { get; set; } = default!;
|
||||
|
||||
private string _email = "";
|
||||
private string _password = "";
|
||||
@@ -117,10 +118,12 @@
|
||||
|
||||
if (ok)
|
||||
{
|
||||
// EN: Route to staff dashboard — StaffLayout will detect role
|
||||
// VI: Dieu huong den staff dashboard — StaffLayout se phat hien vai tro
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
await Task.Delay(500);
|
||||
Nav.NavigateTo("/admin", forceLoad: true);
|
||||
Nav.NavigateTo("/staff/dashboard", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
@page "/staff/attendance"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Staff attendance page — view check-in/check-out history.
|
||||
VI: Trang chấm công — xem lịch sử vào/ra.
|
||||
*@
|
||||
|
||||
<PageTitle>Chấm công</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Chấm công</h1>
|
||||
<p class="staff-page-subtitle">Lịch sử chấm công hàng ngày</p>
|
||||
</div>
|
||||
|
||||
@* ═══ MONTH SELECTOR ═══ *@
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
|
||||
<button class="staff-btn-secondary" style="padding:8px 12px;" @onclick="PrevMonth">
|
||||
<i data-lucide="chevron-left" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
<span style="font-size:16px;font-weight:600;min-width:140px;text-align:center;">
|
||||
Tháng @_month/@_year
|
||||
</span>
|
||||
<button class="staff-btn-secondary" style="padding:8px 12px;" @onclick="NextMonth">
|
||||
<i data-lucide="chevron-right" style="width:16px;height:16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ SUMMARY ═══ *@
|
||||
<div class="staff-stats-grid" style="margin-bottom:20px;">
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@_totalDays</span>
|
||||
<span class="staff-stat-card__label">Ngày công</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@_totalHours.ToString("0.#")h</span>
|
||||
<span class="staff-stat-card__label">Tổng giờ làm</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@(_totalDays > 0 ? (_totalHours / _totalDays).ToString("0.#") : "0")h</span>
|
||||
<span class="staff-stat-card__label">TB giờ/ngày</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ TABLE ═══ *@
|
||||
<div class="staff-table-card">
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ngày</th>
|
||||
<th>Thứ</th>
|
||||
<th>Vào</th>
|
||||
<th>Ra</th>
|
||||
<th>Giờ làm</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _records)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Date.ToString("dd/MM/yyyy")</td>
|
||||
<td>@GetDayOfWeek(r.Date)</td>
|
||||
<td style="color:#22C55E;">@(r.CheckIn?.ToString("HH:mm") ?? "--")</td>
|
||||
<td style="color:#F59E0B;">@(r.CheckOut?.ToString("HH:mm") ?? "--")</td>
|
||||
<td>@(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--")</td>
|
||||
<td>
|
||||
<span class="staff-status @GetStatusCss(r.Status)">
|
||||
@GetStatusLabel(r.Status)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_records.Count == 0)
|
||||
{
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Không có dữ liệu</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<PosDataService.AttendanceRecord> _records = new();
|
||||
private bool _loading = true;
|
||||
private int _month = DateTime.Now.Month;
|
||||
private int _year = DateTime.Now.Year;
|
||||
private int _totalDays = 0;
|
||||
private decimal _totalHours = 0;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadData();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_records = await DataService.GetMyAttendanceAsync(_month, _year);
|
||||
_records = _records.OrderByDescending(r => r.Date).ToList();
|
||||
_totalDays = _records.Count(r => r.Status == "Completed");
|
||||
_totalHours = _records.Where(r => r.HoursWorked.HasValue).Sum(r => r.HoursWorked!.Value);
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private async Task PrevMonth()
|
||||
{
|
||||
_month--;
|
||||
if (_month < 1) { _month = 12; _year--; }
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task NextMonth()
|
||||
{
|
||||
if (_month == DateTime.Now.Month && _year == DateTime.Now.Year) return;
|
||||
_month++;
|
||||
if (_month > 12) { _month = 1; _year++; }
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private static string GetDayOfWeek(DateTime d) => d.DayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => "T2",
|
||||
DayOfWeek.Tuesday => "T3",
|
||||
DayOfWeek.Wednesday => "T4",
|
||||
DayOfWeek.Thursday => "T5",
|
||||
DayOfWeek.Friday => "T6",
|
||||
DayOfWeek.Saturday => "T7",
|
||||
DayOfWeek.Sunday => "CN",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
private static string GetStatusCss(string? status) => status switch
|
||||
{
|
||||
"Completed" => "staff-status--success",
|
||||
"Working" => "staff-status--info",
|
||||
"Late" => "staff-status--warning",
|
||||
"Absent" => "staff-status--danger",
|
||||
_ => "staff-status--neutral"
|
||||
};
|
||||
|
||||
private static string GetStatusLabel(string? status) => status switch
|
||||
{
|
||||
"Completed" => "Hoàn thành",
|
||||
"Working" => "Đang làm",
|
||||
"Late" => "Đi muộn",
|
||||
"Absent" => "Vắng mặt",
|
||||
_ => status ?? "--"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@page "/staff/dashboard"
|
||||
@page "/staff"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject WebClientTpos.Client.Services.AuthStateService AuthState
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Staff dashboard — overview of today's status, quick actions.
|
||||
VI: Dashboard nhân viên — tổng quan trạng thái hôm nay, thao tác nhanh.
|
||||
*@
|
||||
|
||||
<PageTitle>Staff Dashboard</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Xin chào, @_displayName!</h1>
|
||||
<p class="staff-page-subtitle">@DateTime.Now.ToString("dddd, dd/MM/yyyy") — @(_profile?.Role ?? "Staff")</p>
|
||||
</div>
|
||||
|
||||
@* ═══ QUICK ACTIONS ═══ *@
|
||||
<div style="display:flex;gap:12px;margin-bottom:24px;flex-wrap:wrap;">
|
||||
@if (!_checkedIn)
|
||||
{
|
||||
<button class="staff-btn-primary" @onclick="CheckIn" disabled="@_actionLoading">
|
||||
<i data-lucide="log-in"></i>
|
||||
Chấm công vào
|
||||
</button>
|
||||
}
|
||||
else if (!_checkedOut)
|
||||
{
|
||||
<button class="staff-btn-primary" @onclick="CheckOut" disabled="@_actionLoading" style="background:#F59E0B;">
|
||||
<i data-lucide="log-out"></i>
|
||||
Chấm công ra
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="staff-status staff-status--success">Đã chấm công hôm nay</span>
|
||||
}
|
||||
|
||||
<button class="staff-btn-secondary" @onclick="@(() => Nav.NavigateTo("/staff/leave"))">
|
||||
<i data-lucide="calendar-off"></i>
|
||||
Xin nghỉ phép
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ STAT CARDS ═══ *@
|
||||
<div class="staff-stats-grid">
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(34,197,94,0.12);">
|
||||
<i data-lucide="clock" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_todayHours</span>
|
||||
<span class="staff-stat-card__label">Giờ làm hôm nay</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(59,130,246,0.12);">
|
||||
<i data-lucide="calendar-check" style="color:#3B82F6;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_monthDays</span>
|
||||
<span class="staff-stat-card__label">Ngày công tháng này</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(245,158,11,0.12);">
|
||||
<i data-lucide="calendar-off" style="color:#F59E0B;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_leaveBalance</span>
|
||||
<span class="staff-stat-card__label">Ngày phép còn lại</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(139,92,246,0.12);">
|
||||
<i data-lucide="bell" style="color:#8B5CF6;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_unreadCount</span>
|
||||
<span class="staff-stat-card__label">Thông báo chưa đọc</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ RECENT ATTENDANCE ═══ *@
|
||||
<div class="staff-table-card">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Chấm công gần đây</span>
|
||||
<button class="staff-btn-secondary" style="padding:6px 12px;font-size:13px;" @onclick="@(() => Nav.NavigateTo("/staff/attendance"))">
|
||||
Xem tất cả
|
||||
</button>
|
||||
</div>
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ngày</th>
|
||||
<th>Vào</th>
|
||||
<th>Ra</th>
|
||||
<th>Giờ làm</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _recentAttendance.Take(7))
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Date.ToString("dd/MM")</td>
|
||||
<td>@(r.CheckIn?.ToString("HH:mm") ?? "--")</td>
|
||||
<td>@(r.CheckOut?.ToString("HH:mm") ?? "--")</td>
|
||||
<td>@(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--")</td>
|
||||
<td>
|
||||
<span class="staff-status @(r.Status == "Completed" ? "staff-status--success" : r.Status == "Working" ? "staff-status--info" : "staff-status--neutral")">
|
||||
@(r.Status == "Completed" ? "Hoàn thành" : r.Status == "Working" ? "Đang làm" : r.Status)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_recentAttendance.Count == 0)
|
||||
{
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Chưa có dữ liệu chấm công</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private PosDataService.StaffProfileInfo? _profile;
|
||||
private List<PosDataService.AttendanceRecord> _recentAttendance = new();
|
||||
private List<PosDataService.StaffNotification> _notifications = new();
|
||||
private bool _loading = true;
|
||||
private bool _actionLoading = false;
|
||||
private bool _checkedIn = false;
|
||||
private bool _checkedOut = false;
|
||||
private string _todayHours = "0";
|
||||
private int _monthDays = 0;
|
||||
private int _leaveBalance = 12;
|
||||
private int _unreadCount = 0;
|
||||
|
||||
private string _displayName => _profile?.FirstName ?? AuthState.UserEmail?.Split('@').FirstOrDefault() ?? "Staff";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profileTask = DataService.GetMyStaffProfileAsync();
|
||||
var attendanceTask = DataService.GetMyAttendanceAsync();
|
||||
var notifTask = DataService.GetMyNotificationsAsync();
|
||||
|
||||
await Task.WhenAll(profileTask, attendanceTask, notifTask);
|
||||
|
||||
_profile = profileTask.Result;
|
||||
_recentAttendance = attendanceTask.Result;
|
||||
_notifications = notifTask.Result;
|
||||
|
||||
_unreadCount = _notifications.Count(n => !n.IsRead);
|
||||
_monthDays = _recentAttendance.Count(r => r.Status == "Completed");
|
||||
|
||||
var today = _recentAttendance.FirstOrDefault(r => r.Date.Date == DateTime.Now.Date);
|
||||
if (today != null)
|
||||
{
|
||||
_checkedIn = today.CheckIn.HasValue;
|
||||
_checkedOut = today.CheckOut.HasValue;
|
||||
if (today.CheckIn.HasValue && !today.CheckOut.HasValue)
|
||||
_todayHours = ((DateTime.Now - today.CheckIn.Value).TotalHours).ToString("0.#");
|
||||
else if (today.HoursWorked.HasValue)
|
||||
_todayHours = today.HoursWorked.Value.ToString("0.#");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task CheckIn()
|
||||
{
|
||||
_actionLoading = true;
|
||||
try
|
||||
{
|
||||
await DataService.PostAsync("api/bff/staff/me/attendance/check-in", new { });
|
||||
_checkedIn = true;
|
||||
}
|
||||
catch { }
|
||||
finally { _actionLoading = false; }
|
||||
}
|
||||
|
||||
private async Task CheckOut()
|
||||
{
|
||||
_actionLoading = true;
|
||||
try
|
||||
{
|
||||
await DataService.PostAsync("api/bff/staff/me/attendance/check-out", new { });
|
||||
_checkedOut = true;
|
||||
}
|
||||
catch { }
|
||||
finally { _actionLoading = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
@page "/staff/kitchen"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Kitchen display — shows incoming orders/tickets for kitchen staff.
|
||||
VI: Màn hình bếp — hiển thị order/phiếu bếp cho nhân viên bếp.
|
||||
*@
|
||||
|
||||
<PageTitle>Bếp - Kitchen Display</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<h1 class="staff-page-title">Kitchen Display</h1>
|
||||
<p class="staff-page-subtitle">Phiếu bếp đang chờ xử lý</p>
|
||||
</div>
|
||||
<button class="staff-btn-secondary" @onclick="Refresh">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Làm mới
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ STATUS SUMMARY ═══ *@
|
||||
<div class="staff-stats-grid" style="margin-bottom:20px;">
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(239,68,68,0.12);">
|
||||
<i data-lucide="clock" style="color:#EF4444;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_pendingCount</span>
|
||||
<span class="staff-stat-card__label">Chờ làm</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(245,158,11,0.12);">
|
||||
<i data-lucide="chef-hat" style="color:#F59E0B;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_inProgressCount</span>
|
||||
<span class="staff-stat-card__label">Đang làm</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(34,197,94,0.12);">
|
||||
<i data-lucide="check-circle" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_completedCount</span>
|
||||
<span class="staff-stat-card__label">Hoàn thành</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ TICKET GRID ═══ *@
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Medium" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else if (_tickets.Count == 0)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<div style="width:64px;height:64px;border-radius:20px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<i data-lucide="check-circle-2" style="width:28px;height:28px;color:#22C55E;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:16px;font-weight:600;color:var(--admin-text-primary);margin:0 0 8px;">Không có phiếu bếp</h3>
|
||||
<p style="font-size:13px;color:var(--admin-text-tertiary);">Tất cả đã được xử lý!</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;">
|
||||
@foreach (var ticket in _tickets)
|
||||
{
|
||||
var borderColor = ticket.Status switch
|
||||
{
|
||||
"Pending" => "#EF4444",
|
||||
"InProgress" => "#F59E0B",
|
||||
"Ready" => "#22C55E",
|
||||
_ => "var(--admin-border-default)"
|
||||
};
|
||||
<div style="background:var(--admin-bg-elevated);border:2px solid @borderColor;border-radius:var(--admin-radius-lg);padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span style="font-size:16px;font-weight:700;color:var(--admin-text-primary);">#@ticket.OrderNumber</span>
|
||||
<span class="staff-status @GetTicketStatusCss(ticket.Status)">@GetTicketStatusLabel(ticket.Status)</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">@ticket.TableInfo</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
@foreach (var item in ticket.Items)
|
||||
{
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--admin-border-subtle);">
|
||||
<span style="font-size:14px;color:var(--admin-text-primary);">@item.Name</span>
|
||||
<span style="font-size:14px;font-weight:600;color:var(--admin-orange-primary);">x@(item.Qty)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:auto;">
|
||||
@if (ticket.Status == "Pending")
|
||||
{
|
||||
<button class="staff-btn-primary" style="flex:1;justify-content:center;background:#F59E0B;" @onclick="@(() => StartTicket(ticket))">
|
||||
Bắt đầu làm
|
||||
</button>
|
||||
}
|
||||
else if (ticket.Status == "InProgress")
|
||||
{
|
||||
<button class="staff-btn-primary" style="flex:1;justify-content:center;" @onclick="@(() => CompleteTicket(ticket))">
|
||||
Hoàn thành
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<KitchenTicket> _tickets = new();
|
||||
private bool _loading = true;
|
||||
private int _pendingCount = 0;
|
||||
private int _inProgressCount = 0;
|
||||
private int _completedCount = 0;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadTickets();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task LoadTickets()
|
||||
{
|
||||
_loading = true;
|
||||
// EN: Mock kitchen tickets — will be replaced with FnB Engine API
|
||||
// VI: Mock phiếu bếp — sẽ được thay bằng FnB Engine API
|
||||
_tickets = new List<KitchenTicket>
|
||||
{
|
||||
new("#001", "Bàn 3", "Pending", new() { new("Phở bò", 2), new("Trà đá", 2) }),
|
||||
new("#002", "Bàn 7", "InProgress", new() { new("Cơm tấm sườn", 1), new("Café sữa đá", 1) }),
|
||||
new("#003", "Bàn 1", "Pending", new() { new("Bánh mì thịt", 3) }),
|
||||
};
|
||||
_pendingCount = _tickets.Count(t => t.Status == "Pending");
|
||||
_inProgressCount = _tickets.Count(t => t.Status == "InProgress");
|
||||
_completedCount = 0;
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task Refresh() => await LoadTickets();
|
||||
|
||||
private void StartTicket(KitchenTicket ticket)
|
||||
{
|
||||
ticket.Status = "InProgress";
|
||||
_pendingCount = _tickets.Count(t => t.Status == "Pending");
|
||||
_inProgressCount = _tickets.Count(t => t.Status == "InProgress");
|
||||
}
|
||||
|
||||
private void CompleteTicket(KitchenTicket ticket)
|
||||
{
|
||||
ticket.Status = "Ready";
|
||||
_inProgressCount = _tickets.Count(t => t.Status == "InProgress");
|
||||
_completedCount++;
|
||||
}
|
||||
|
||||
private static string GetTicketStatusCss(string s) => s switch
|
||||
{
|
||||
"Pending" => "staff-status--danger",
|
||||
"InProgress" => "staff-status--warning",
|
||||
"Ready" => "staff-status--success",
|
||||
_ => "staff-status--neutral"
|
||||
};
|
||||
|
||||
private static string GetTicketStatusLabel(string s) => s switch
|
||||
{
|
||||
"Pending" => "Chờ làm",
|
||||
"InProgress" => "Đang làm",
|
||||
"Ready" => "Sẵn sàng",
|
||||
_ => s
|
||||
};
|
||||
|
||||
private class KitchenTicket(string orderNumber, string tableInfo, string status, List<TicketItem> items)
|
||||
{
|
||||
public string OrderNumber { get; } = orderNumber;
|
||||
public string TableInfo { get; } = tableInfo;
|
||||
public string Status { get; set; } = status;
|
||||
public List<TicketItem> Items { get; } = items;
|
||||
}
|
||||
|
||||
private record TicketItem(string Name, int Qty);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
@page "/staff/leave"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@*
|
||||
EN: Staff leave request page — view/create leave requests.
|
||||
VI: Trang nghỉ phép — xem/tạo yêu cầu nghỉ phép.
|
||||
*@
|
||||
|
||||
<PageTitle>Nghỉ phép</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<h1 class="staff-page-title">Nghỉ phép</h1>
|
||||
<p class="staff-page-subtitle">Quản lý yêu cầu nghỉ phép</p>
|
||||
</div>
|
||||
<button class="staff-btn-primary" @onclick="ShowCreateForm">
|
||||
<i data-lucide="plus"></i>
|
||||
Xin nghỉ phép
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ SUMMARY ═══ *@
|
||||
<div class="staff-stats-grid" style="margin-bottom:20px;">
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">12</span>
|
||||
<span class="staff-stat-card__label">Tổng phép năm</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@_usedDays</span>
|
||||
<span class="staff-stat-card__label">Đã sử dụng</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@(12 - _usedDays)</span>
|
||||
<span class="staff-stat-card__label">Còn lại</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<span class="staff-stat-card__value">@_pendingCount</span>
|
||||
<span class="staff-stat-card__label">Chờ duyệt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ CREATE FORM ═══ *@
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="staff-table-card" style="margin-bottom:20px;padding:20px;">
|
||||
<h3 style="font-size:16px;font-weight:600;margin:0 0 16px;">Tạo yêu cầu nghỉ phép</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<MudSelect T="string" @bind-Value="_leaveType" Label="Loại nghỉ phép" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="@("Annual")">Phép năm</MudSelectItem>
|
||||
<MudSelectItem Value="@("Sick")">Nghỉ ốm</MudSelectItem>
|
||||
<MudSelectItem Value="@("Personal")">Việc cá nhân</MudSelectItem>
|
||||
<MudSelectItem Value="@("Maternity")">Thai sản</MudSelectItem>
|
||||
<MudSelectItem Value="@("Other")">Khác</MudSelectItem>
|
||||
</MudSelect>
|
||||
<div></div>
|
||||
<MudDatePicker @bind-Date="_startDate" Label="Từ ngày" Variant="Variant.Outlined" />
|
||||
<MudDatePicker @bind-Date="_endDate" Label="Đến ngày" Variant="Variant.Outlined" />
|
||||
</div>
|
||||
<MudTextField @bind-Value="_reason" Label="Lý do" Variant="Variant.Outlined" Lines="2" Style="margin-top:16px;" />
|
||||
<div style="display:flex;gap:12px;margin-top:16px;">
|
||||
<button class="staff-btn-primary" @onclick="SubmitLeave" disabled="@_submitting">
|
||||
@(_submitting ? "Đang gửi..." : "Gửi yêu cầu")
|
||||
</button>
|
||||
<button class="staff-btn-secondary" @onclick="@(() => _showForm = false)">Hủy</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ═══ LEAVE LIST ═══ *@
|
||||
<div class="staff-table-card">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Lịch sử nghỉ phép</span>
|
||||
</div>
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="padding:32px;text-align:center;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Loại</th>
|
||||
<th>Từ ngày</th>
|
||||
<th>Đến ngày</th>
|
||||
<th>Số ngày</th>
|
||||
<th>Lý do</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _requests)
|
||||
{
|
||||
<tr>
|
||||
<td>@GetLeaveTypeLabel(r.LeaveType)</td>
|
||||
<td>@r.StartDate.ToString("dd/MM/yyyy")</td>
|
||||
<td>@r.EndDate.ToString("dd/MM/yyyy")</td>
|
||||
<td>@((r.EndDate - r.StartDate).Days + 1)</td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;">@(r.Reason ?? "--")</td>
|
||||
<td>
|
||||
<span class="staff-status @GetLeaveStatusCss(r.Status)">
|
||||
@GetLeaveStatusLabel(r.Status)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_requests.Count == 0)
|
||||
{
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Chưa có yêu cầu nghỉ phép nào</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<PosDataService.LeaveRequest> _requests = new();
|
||||
private bool _loading = true;
|
||||
private bool _showForm = false;
|
||||
private bool _submitting = false;
|
||||
private int _usedDays = 0;
|
||||
private int _pendingCount = 0;
|
||||
|
||||
// Form fields
|
||||
private string _leaveType = "Annual";
|
||||
private DateTime? _startDate;
|
||||
private DateTime? _endDate;
|
||||
private string _reason = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_requests = await DataService.GetMyLeaveRequestsAsync();
|
||||
_usedDays = _requests.Where(r => r.Status == "Approved").Sum(r => (r.EndDate - r.StartDate).Days + 1);
|
||||
_pendingCount = _requests.Count(r => r.Status == "Pending");
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private void ShowCreateForm()
|
||||
{
|
||||
_showForm = true;
|
||||
_startDate = DateTime.Now.Date.AddDays(1);
|
||||
_endDate = DateTime.Now.Date.AddDays(1);
|
||||
_reason = "";
|
||||
_leaveType = "Annual";
|
||||
}
|
||||
|
||||
private async Task SubmitLeave()
|
||||
{
|
||||
if (_startDate == null || _endDate == null)
|
||||
{
|
||||
Snackbar.Add("Vui lòng chọn ngày", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
_submitting = true;
|
||||
try
|
||||
{
|
||||
await DataService.PostAsync("api/bff/staff/me/leave-requests", new
|
||||
{
|
||||
leaveType = _leaveType,
|
||||
startDate = _startDate.Value.ToString("o"),
|
||||
endDate = _endDate.Value.ToString("o"),
|
||||
reason = _reason
|
||||
});
|
||||
Snackbar.Add("Đã gửi yêu cầu nghỉ phép", Severity.Success);
|
||||
_showForm = false;
|
||||
_requests = await DataService.GetMyLeaveRequestsAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Gửi yêu cầu thất bại", Severity.Error);
|
||||
}
|
||||
finally { _submitting = false; }
|
||||
}
|
||||
|
||||
private static string GetLeaveTypeLabel(string type) => type switch
|
||||
{
|
||||
"Annual" => "Phép năm",
|
||||
"Sick" => "Nghỉ ốm",
|
||||
"Personal" => "Việc cá nhân",
|
||||
"Maternity" => "Thai sản",
|
||||
_ => type
|
||||
};
|
||||
|
||||
private static string GetLeaveStatusCss(string status) => status switch
|
||||
{
|
||||
"Approved" => "staff-status--success",
|
||||
"Pending" => "staff-status--warning",
|
||||
"Rejected" => "staff-status--danger",
|
||||
_ => "staff-status--neutral"
|
||||
};
|
||||
|
||||
private static string GetLeaveStatusLabel(string status) => status switch
|
||||
{
|
||||
"Approved" => "Đã duyệt",
|
||||
"Pending" => "Chờ duyệt",
|
||||
"Rejected" => "Từ chối",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@page "/staff/notifications"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Staff notifications page.
|
||||
VI: Trang thông báo nhân viên.
|
||||
*@
|
||||
|
||||
<PageTitle>Thông báo</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Thông báo</h1>
|
||||
<p class="staff-page-subtitle">Thông báo và cập nhật từ cửa hàng</p>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Medium" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else if (_notifications.Count == 0)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<div style="width:64px;height:64px;border-radius:20px;background:rgba(34,197,94,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<i data-lucide="bell-off" style="width:28px;height:28px;color:#22C55E;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:16px;font-weight:600;color:var(--admin-text-primary);margin:0 0 8px;">Không có thông báo</h3>
|
||||
<p style="font-size:13px;color:var(--admin-text-tertiary);">Bạn đã đọc tất cả thông báo</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
@foreach (var n in _notifications)
|
||||
{
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid @(n.IsRead ? "var(--admin-border-default)" : "#22C55E33");border-radius:var(--admin-radius-lg);padding:16px 20px;display:flex;gap:14px;align-items:flex-start;">
|
||||
<div style="width:36px;height:36px;border-radius:10px;background:@GetIconBg(n.Type);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||
<i data-lucide="@GetIcon(n.Type)" style="width:18px;height:18px;color:@GetIconColor(n.Type);"></i>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<span style="font-size:14px;font-weight:600;color:var(--admin-text-primary);">@n.Title</span>
|
||||
@if (!n.IsRead)
|
||||
{
|
||||
<span style="width:8px;height:8px;border-radius:4px;background:#22C55E;flex-shrink:0;"></span>
|
||||
}
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--admin-text-secondary);margin:0 0 6px;">@n.Message</p>
|
||||
<span style="font-size:11px;color:var(--admin-text-tertiary);">@FormatTime(n.CreatedAt)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<PosDataService.StaffNotification> _notifications = new();
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_notifications = await DataService.GetMyNotificationsAsync();
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private static string GetIcon(string type) => type switch
|
||||
{
|
||||
"schedule" => "calendar-days",
|
||||
"payroll" => "wallet",
|
||||
"leave" => "calendar-off",
|
||||
"alert" => "alert-triangle",
|
||||
_ => "bell"
|
||||
};
|
||||
|
||||
private static string GetIconBg(string type) => type switch
|
||||
{
|
||||
"schedule" => "rgba(59,130,246,0.12)",
|
||||
"payroll" => "rgba(139,92,246,0.12)",
|
||||
"leave" => "rgba(245,158,11,0.12)",
|
||||
"alert" => "rgba(239,68,68,0.12)",
|
||||
_ => "rgba(34,197,94,0.12)"
|
||||
};
|
||||
|
||||
private static string GetIconColor(string type) => type switch
|
||||
{
|
||||
"schedule" => "#3B82F6",
|
||||
"payroll" => "#8B5CF6",
|
||||
"leave" => "#F59E0B",
|
||||
"alert" => "#EF4444",
|
||||
_ => "#22C55E"
|
||||
};
|
||||
|
||||
private static string FormatTime(DateTime dt)
|
||||
{
|
||||
var diff = DateTime.UtcNow - dt;
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} phút trước";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} giờ trước";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays} ngày trước";
|
||||
return dt.ToString("dd/MM/yyyy");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@page "/staff/overview"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Manager overview — reports and team summary.
|
||||
VI: Tổng quan quản lý — báo cáo và tổng hợp nhân viên.
|
||||
*@
|
||||
|
||||
<PageTitle>Báo cáo - Manager</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Tổng quan</h1>
|
||||
<p class="staff-page-subtitle">Báo cáo hoạt động cửa hàng hôm nay</p>
|
||||
</div>
|
||||
|
||||
@* ═══ TODAY'S STATS ═══ *@
|
||||
<div class="staff-stats-grid" style="margin-bottom:24px;">
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(255,92,0,0.12);">
|
||||
<i data-lucide="shopping-bag" style="color:#FF5C00;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">--</span>
|
||||
<span class="staff-stat-card__label">Đơn hàng hôm nay</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(34,197,94,0.12);">
|
||||
<i data-lucide="banknote" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">--</span>
|
||||
<span class="staff-stat-card__label">Doanh thu</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(59,130,246,0.12);">
|
||||
<i data-lucide="users" style="color:#3B82F6;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_staffOnline</span>
|
||||
<span class="staff-stat-card__label">Nhân viên online</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(245,158,11,0.12);">
|
||||
<i data-lucide="calendar-off" style="color:#F59E0B;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value">@_pendingLeave</span>
|
||||
<span class="staff-stat-card__label">Nghỉ phép chờ duyệt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ TEAM STATUS ═══ *@
|
||||
<div class="staff-table-card" style="margin-bottom:20px;">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Nhân viên hôm nay</span>
|
||||
</div>
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tên</th>
|
||||
<th>Vai trò</th>
|
||||
<th>Check-in</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in _teamStatus)
|
||||
{
|
||||
<tr>
|
||||
<td style="font-weight:600;">@s.Name</td>
|
||||
<td>@s.Role</td>
|
||||
<td>@s.CheckIn</td>
|
||||
<td><span class="staff-status @(s.IsOnline ? "staff-status--success" : "staff-status--neutral")">@(s.IsOnline ? "Online" : "Offline")</span></td>
|
||||
</tr>
|
||||
}
|
||||
@if (_teamStatus.Count == 0)
|
||||
{
|
||||
<tr><td colspan="4" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Chưa có dữ liệu</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
|
||||
@* ═══ PENDING LEAVE REQUESTS ═══ *@
|
||||
<div class="staff-table-card">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Nghỉ phép chờ duyệt</span>
|
||||
</div>
|
||||
<div style="padding:20px;text-align:center;color:var(--admin-text-tertiary);">
|
||||
Chưa có yêu cầu nghỉ phép nào cần duyệt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private int _staffOnline = 0;
|
||||
private int _pendingLeave = 0;
|
||||
private List<TeamMember> _teamStatus = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// EN: Mock team data — will be connected to real staff/attendance API
|
||||
// VI: Mock dữ liệu team — sẽ kết nối với API nhân viên/chấm công thực
|
||||
_teamStatus = new()
|
||||
{
|
||||
new("Trần Văn B", "Thu ngân", "08:05", true),
|
||||
new("Trần Văn C", "Phục vụ", "08:12", true),
|
||||
new("Trần Văn D", "Bếp", "07:55", true),
|
||||
};
|
||||
_staffOnline = _teamStatus.Count(t => t.IsOnline);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private record TeamMember(string Name, string Role, string CheckIn, bool IsOnline);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@page "/staff/payroll"
|
||||
@layout StaffLayout
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Staff payroll page — view salary information.
|
||||
VI: Trang lương — xem thông tin lương.
|
||||
*@
|
||||
|
||||
<PageTitle>Thông tin lương</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Thông tin lương</h1>
|
||||
<p class="staff-page-subtitle">Lương và phụ cấp hàng tháng</p>
|
||||
</div>
|
||||
|
||||
@* ═══ CURRENT MONTH ═══ *@
|
||||
<div class="staff-stats-grid" style="margin-bottom:24px;">
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(34,197,94,0.12);">
|
||||
<i data-lucide="wallet" style="color:#22C55E;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value" style="font-size:22px;">--</span>
|
||||
<span class="staff-stat-card__label">Lương cơ bản</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(59,130,246,0.12);">
|
||||
<i data-lucide="plus-circle" style="color:#3B82F6;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value" style="font-size:22px;">--</span>
|
||||
<span class="staff-stat-card__label">Phụ cấp</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(245,158,11,0.12);">
|
||||
<i data-lucide="minus-circle" style="color:#F59E0B;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value" style="font-size:22px;">--</span>
|
||||
<span class="staff-stat-card__label">Khấu trừ</span>
|
||||
</div>
|
||||
<div class="staff-stat-card">
|
||||
<div class="staff-stat-card__icon" style="background:rgba(139,92,246,0.12);">
|
||||
<i data-lucide="banknote" style="color:#8B5CF6;"></i>
|
||||
</div>
|
||||
<span class="staff-stat-card__value" style="font-size:22px;">--</span>
|
||||
<span class="staff-stat-card__label">Thực nhận</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ═══ NOTE ═══ *@
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid var(--admin-border-default);border-radius:var(--admin-radius-lg);padding:24px;text-align:center;">
|
||||
<div style="width:56px;height:56px;border-radius:16px;background:rgba(139,92,246,0.1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<i data-lucide="construction" style="width:24px;height:24px;color:#8B5CF6;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:16px;font-weight:600;color:var(--admin-text-primary);margin:0 0 8px;">Tính năng đang phát triển</h3>
|
||||
<p style="font-size:13px;color:var(--admin-text-tertiary);margin:0;">Thông tin lương sẽ được cập nhật bởi quản lý cửa hàng. Vui lòng liên hệ quản lý để biết chi tiết.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
@page "/staff/pos"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Cashier POS redirect — redirects cashier to the actual POS screen.
|
||||
VI: Chuyển hướng POS thu ngân — chuyển thu ngân đến màn hình POS.
|
||||
*@
|
||||
|
||||
<PageTitle>Thu ngân - POS</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Thu ngân</h1>
|
||||
<p class="staff-page-subtitle">Chọn cửa hàng để bắt đầu bán hàng</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<div style="width:80px;height:80px;border-radius:24px;background:rgba(255,92,0,0.12);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
|
||||
<i data-lucide="monitor" style="width:36px;height:36px;color:#FF5C00;"></i>
|
||||
</div>
|
||||
<h3 style="font-size:18px;font-weight:600;color:var(--admin-text-primary);margin:0 0 8px;">Mở POS</h3>
|
||||
<p style="font-size:14px;color:var(--admin-text-tertiary);margin:0 0 24px;">Nhấn nút bên dưới để mở màn hình thu ngân toàn màn hình</p>
|
||||
|
||||
@if (_shopId.HasValue)
|
||||
{
|
||||
<button class="staff-btn-primary" style="padding:14px 32px;font-size:16px;" @onclick="OpenPos">
|
||||
<i data-lucide="external-link"></i>
|
||||
Mở POS Cafe
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p style="color:var(--admin-text-tertiary);">Chưa được phân công cửa hàng. Vui lòng liên hệ quản lý.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private Guid? _shopId;
|
||||
[CascadingParameter] private StaffLayout? Layout { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_shopId = Layout?.ShopId;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private void OpenPos()
|
||||
{
|
||||
if (_shopId.HasValue)
|
||||
Nav.NavigateTo($"/pos/{_shopId}/cafe", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page "/staff/schedule"
|
||||
@layout StaffLayout
|
||||
@inject WebClientTpos.Client.Services.PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Staff schedule page — view weekly work schedule.
|
||||
VI: Trang lịch làm việc — xem lịch làm việc hàng tuần.
|
||||
*@
|
||||
|
||||
<PageTitle>Lịch làm việc</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Lịch làm việc</h1>
|
||||
<p class="staff-page-subtitle">Lịch trình làm việc của bạn</p>
|
||||
</div>
|
||||
|
||||
@* ═══ WEEK VIEW ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:8px;margin-bottom:24px;">
|
||||
@for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var day = _weekStart.AddDays(i);
|
||||
var isToday = day.Date == DateTime.Now.Date;
|
||||
var isWeekend = day.DayOfWeek == DayOfWeek.Sunday;
|
||||
<div style="background:var(--admin-bg-elevated);border:1px solid @(isToday ? "#22C55E" : "var(--admin-border-default)");border-radius:var(--admin-radius-lg);padding:16px;text-align:center;">
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:4px;">@GetDayName(day)</div>
|
||||
<div style="font-size:20px;font-weight:700;color:@(isToday ? "#22C55E" : "var(--admin-text-primary)");margin-bottom:8px;">@day.Day</div>
|
||||
@if (isWeekend)
|
||||
{
|
||||
<span class="staff-status staff-status--neutral">Nghỉ</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="font-size:12px;color:var(--admin-text-secondary);">08:00 - 17:00</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ UPCOMING SHIFTS ═══ *@
|
||||
<div class="staff-table-card">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Thông tin ca làm việc</span>
|
||||
</div>
|
||||
<div style="padding:20px;">
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
|
||||
<i data-lucide="clock" style="width:20px;height:20px;color:#22C55E;"></i>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;color:var(--admin-text-primary);">Ca sáng: 08:00 - 12:00</div>
|
||||
<div style="font-size:12px;color:var(--admin-text-tertiary);">Thu 2 - Thu 6</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--admin-bg-interactive);border-radius:var(--admin-radius-md);">
|
||||
<i data-lucide="clock" style="width:20px;height:20px;color:#3B82F6;"></i>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;color:var(--admin-text-primary);">Ca chiều: 13:00 - 17:00</div>
|
||||
<div style="font-size:12px;color:var(--admin-text-tertiary);">Thu 2 - Thu 6</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private DateTime _weekStart = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek + (int)DayOfWeek.Monday);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private static string GetDayName(DateTime d) => d.DayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => "T2",
|
||||
DayOfWeek.Tuesday => "T3",
|
||||
DayOfWeek.Wednesday => "T4",
|
||||
DayOfWeek.Thursday => "T5",
|
||||
DayOfWeek.Friday => "T6",
|
||||
DayOfWeek.Saturday => "T7",
|
||||
DayOfWeek.Sunday => "CN",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
@page "/staff/tables"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Waiter table view — shows tables, their status, and allows order management.
|
||||
VI: Màn hình phục vụ — hiển thị bàn, trạng thái, và quản lý order.
|
||||
*@
|
||||
|
||||
<PageTitle>Bàn / Order</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Bàn & Order</h1>
|
||||
<p class="staff-page-subtitle">Quản lý bàn và order của bạn</p>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Medium" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ═══ TABLE GRID ═══ *@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;">
|
||||
@foreach (var table in _tables)
|
||||
{
|
||||
var bgColor = table.Status switch
|
||||
{
|
||||
"Available" => "var(--admin-bg-elevated)",
|
||||
"Occupied" => "rgba(255,92,0,0.08)",
|
||||
"Reserved" => "rgba(59,130,246,0.08)",
|
||||
_ => "var(--admin-bg-elevated)"
|
||||
};
|
||||
var borderColor = table.Status switch
|
||||
{
|
||||
"Available" => "var(--admin-border-default)",
|
||||
"Occupied" => "#FF5C00",
|
||||
"Reserved" => "#3B82F6",
|
||||
_ => "var(--admin-border-default)"
|
||||
};
|
||||
<div style="background:@bgColor;border:1px solid @borderColor;border-radius:var(--admin-radius-lg);padding:20px;text-align:center;cursor:pointer;transition:all 0.2s;" @onclick="@(() => SelectTable(table))">
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);margin-bottom:4px;">BÀN</div>
|
||||
<div style="font-size:28px;font-weight:800;color:var(--admin-text-primary);margin-bottom:8px;">@table.TableNumber</div>
|
||||
<span class="staff-status @GetTableStatusCss(table.Status)">
|
||||
@GetTableStatusLabel(table.Status)
|
||||
</span>
|
||||
@if (table.GuestCount > 0)
|
||||
{
|
||||
<div style="margin-top:8px;font-size:12px;color:var(--admin-text-secondary);">
|
||||
<i data-lucide="users" style="width:12px;height:12px;display:inline;"></i> @table.GuestCount khách
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ═══ SELECTED TABLE DETAIL ═══ *@
|
||||
@if (_selectedTable != null)
|
||||
{
|
||||
<div class="staff-table-card" style="margin-top:20px;">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Bàn @_selectedTable.TableNumber</span>
|
||||
<span class="staff-status @GetTableStatusCss(_selectedTable.Status)">@GetTableStatusLabel(_selectedTable.Status)</span>
|
||||
</div>
|
||||
<div style="padding:20px;">
|
||||
<div style="display:flex;gap:12px;">
|
||||
@if (_selectedTable.Status == "Available")
|
||||
{
|
||||
<button class="staff-btn-primary" @onclick="@(() => OpenTable(_selectedTable))">
|
||||
<i data-lucide="plus"></i> Mở bàn
|
||||
</button>
|
||||
}
|
||||
else if (_selectedTable.Status == "Occupied")
|
||||
{
|
||||
<button class="staff-btn-primary">
|
||||
<i data-lucide="plus"></i> Thêm món
|
||||
</button>
|
||||
<button class="staff-btn-secondary" style="color:#F59E0B;">
|
||||
<i data-lucide="receipt"></i> In bill
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<PosDataService.TableInfo> _tables = new();
|
||||
private PosDataService.TableInfo? _selectedTable;
|
||||
private bool _loading = true;
|
||||
|
||||
[CascadingParameter] private StaffLayout? Layout { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var shopId = Layout?.ShopId;
|
||||
if (shopId.HasValue)
|
||||
_tables = await DataService.GetTablesAsync(shopId.Value);
|
||||
else
|
||||
_tables = new(); // Will show empty state
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private void SelectTable(PosDataService.TableInfo table) => _selectedTable = table;
|
||||
|
||||
private void OpenTable(PosDataService.TableInfo table)
|
||||
{
|
||||
// TODO: Create session for table
|
||||
}
|
||||
|
||||
private static string GetTableStatusCss(string status) => status switch
|
||||
{
|
||||
"Available" => "staff-status--success",
|
||||
"Occupied" => "staff-status--warning",
|
||||
"Reserved" => "staff-status--info",
|
||||
_ => "staff-status--neutral"
|
||||
};
|
||||
|
||||
private static string GetTableStatusLabel(string status) => status switch
|
||||
{
|
||||
"Available" => "Trống",
|
||||
"Occupied" => "Có khách",
|
||||
"Reserved" => "Đặt trước",
|
||||
_ => status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@page "/staff/team"
|
||||
@layout StaffLayout
|
||||
@using WebClientTpos.Client.Services
|
||||
@inject PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
EN: Manager team management — view staff list, attendance, leave.
|
||||
VI: Quản lý đội ngũ — xem danh sách nhân viên, chấm công, nghỉ phép.
|
||||
*@
|
||||
|
||||
<PageTitle>Quản lý nhân viên</PageTitle>
|
||||
|
||||
<div class="staff-page">
|
||||
<div class="staff-page-header">
|
||||
<h1 class="staff-page-title">Đội ngũ</h1>
|
||||
<p class="staff-page-subtitle">Quản lý nhân viên cửa hàng</p>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div style="text-align:center;padding:48px;">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Medium" Color="Color.Success" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="staff-table-card">
|
||||
<div class="staff-table-card__header">
|
||||
<span class="staff-table-card__title">Danh sách nhân viên (@_staff.Count)</span>
|
||||
</div>
|
||||
<MudSimpleTable Dense="true" Hover="true" Style="background:transparent;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tên</th>
|
||||
<th>Mã NV</th>
|
||||
<th>Vai trò</th>
|
||||
<th>Email</th>
|
||||
<th>SĐT</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in _staff)
|
||||
{
|
||||
<tr>
|
||||
<td style="font-weight:600;">@(s.FirstName ?? "") @(s.LastName ?? "")</td>
|
||||
<td>@(s.EmployeeCode ?? "--")</td>
|
||||
<td>@(s.Role ?? "--")</td>
|
||||
<td style="font-size:13px;color:var(--admin-text-secondary);">@(s.Email ?? "--")</td>
|
||||
<td>@(s.Phone ?? "--")</td>
|
||||
<td>
|
||||
<span class="staff-status @(s.Status == "Active" ? "staff-status--success" : "staff-status--neutral")">
|
||||
@(s.Status == "Active" ? "Hoạt động" : s.Status ?? "--")
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_staff.Count == 0)
|
||||
{
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--admin-text-tertiary);padding:24px;">Không có nhân viên</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<PosDataService.StaffInfo> _staff = new();
|
||||
private bool _loading = true;
|
||||
|
||||
[CascadingParameter] private StaffLayout? Layout { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var shopId = Layout?.ShopId;
|
||||
if (shopId.HasValue)
|
||||
_staff = await DataService.GetStaffForShopAsync(shopId.Value);
|
||||
else
|
||||
_staff = await DataService.GetStaffAsync();
|
||||
}
|
||||
catch { }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class AuthStateService
|
||||
public string GetPortalUrl() => UserRole switch
|
||||
{
|
||||
"owner" or "admin" => "/admin",
|
||||
"staff" => "/pos/cafe",
|
||||
"staff" => "/staff/dashboard",
|
||||
"branch" => "/admin",
|
||||
"customer" => "/app",
|
||||
_ => "/auth/login"
|
||||
|
||||
@@ -65,6 +65,17 @@ public class PosDataService
|
||||
catch { return $"Lỗi ({resp.StatusCode})"; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic POST helper — sends JSON body to BFF endpoint.
|
||||
/// VI: Helper POST chung — gui JSON body den BFF endpoint.
|
||||
/// </summary>
|
||||
public async Task<bool> PostAsync(string url, object body)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync(url, body, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
|
||||
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
|
||||
@@ -176,6 +187,42 @@ public class PosDataService
|
||||
public async Task<List<StaffInfo>> GetStaffAsync()
|
||||
=> await GetListFromApiAsync<StaffInfo>("api/bff/staff");
|
||||
|
||||
// ═══ STAFF PORTAL — PROFILE & HR ═══
|
||||
|
||||
// EN: Staff profile for current logged-in user (role, shop assignment)
|
||||
// VI: Thong tin nhan vien cho user dang dang nhap (vai tro, phan cong shop)
|
||||
public record StaffProfileInfo(Guid StaffId, Guid? UserId, string? Email, string? FirstName, string? LastName,
|
||||
string? Role, string? ShopName, Guid? ShopId, string? ShopRole, string? Status, string? EmployeeCode, string? Phone);
|
||||
|
||||
public async Task<StaffProfileInfo?> GetMyStaffProfileAsync()
|
||||
=> await GetObjectFromApiAsync<StaffProfileInfo>("api/bff/staff/me");
|
||||
|
||||
// EN: Attendance records for staff portal
|
||||
// VI: Ban ghi cham cong cho cong nhan vien
|
||||
public record AttendanceRecord(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut,
|
||||
decimal? HoursWorked, string? Status, string? Notes);
|
||||
|
||||
public async Task<List<AttendanceRecord>> GetMyAttendanceAsync(int month = 0, int year = 0)
|
||||
{
|
||||
var qs = month > 0 && year > 0 ? $"?month={month}&year={year}" : "";
|
||||
return await GetListFromApiAsync<AttendanceRecord>($"api/bff/staff/me/attendance{qs}");
|
||||
}
|
||||
|
||||
// EN: Leave request records
|
||||
// VI: Ban ghi yeu cau nghi phep
|
||||
public record LeaveRequest(Guid Id, Guid StaffId, string LeaveType, DateTime StartDate, DateTime EndDate,
|
||||
string? Reason, string Status, string? ApprovedBy, DateTime CreatedAt);
|
||||
|
||||
public async Task<List<LeaveRequest>> GetMyLeaveRequestsAsync()
|
||||
=> await GetListFromApiAsync<LeaveRequest>("api/bff/staff/me/leave-requests");
|
||||
|
||||
// EN: Staff notification
|
||||
// VI: Thong bao nhan vien
|
||||
public record StaffNotification(Guid Id, string Title, string? Message, string Type, bool IsRead, DateTime CreatedAt);
|
||||
|
||||
public async Task<List<StaffNotification>> GetMyNotificationsAsync()
|
||||
=> await GetListFromApiAsync<StaffNotification>("api/bff/staff/me/notifications");
|
||||
|
||||
// ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══
|
||||
|
||||
// EN: Admin-level records with shop_id and category info
|
||||
|
||||
@@ -36,6 +36,8 @@ public static class ShopSidebarConfig
|
||||
{
|
||||
new("Shop_Menu_Finance", "trending-up", "finance"),
|
||||
new("Shop_Menu_Staff", "users", "staff"),
|
||||
new("Shop_Menu_Attendance", "clock", "attendance", true),
|
||||
new("Shop_Menu_LeaveRequests", "calendar-off", "leave-requests", true),
|
||||
new("Shop_Menu_Customers", "heart", "customers"),
|
||||
new("Shop_Menu_Promotions", "tag", "promotions"),
|
||||
new("Shop_Menu_Reports", "bar-chart-2", "reports"),
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
||||
Staff Portal — CSS Foundation
|
||||
EN: Styles for staff portal pages (Dashboard, Attendance, Leave, etc.)
|
||||
VI: Styles cho trang nhan vien (Dashboard, Cham cong, Nghi phep, v.v.)
|
||||
Reuses admin design tokens from admin.css
|
||||
═══════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ═════════════════════════════════════════════════════════════════════════
|
||||
STAFF LAYOUT — Sidebar + Content (mirrors admin-layout)
|
||||
═════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.staff-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: var(--admin-bg-page);
|
||||
font-family: var(--admin-font);
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
/* ═════════════════════════════════════════
|
||||
SIDEBAR
|
||||
═════════════════════════════════════════ */
|
||||
|
||||
.staff-sidebar {
|
||||
width: var(--admin-sidebar-width);
|
||||
min-width: var(--admin-sidebar-width);
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--admin-bg-elevated);
|
||||
border-right: 1px solid var(--admin-border-subtle);
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.staff-sidebar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--admin-border-subtle);
|
||||
}
|
||||
|
||||
.staff-sidebar__logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #FFFFFF;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.staff-sidebar__logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.staff-sidebar__logo-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.staff-sidebar__logo-sub {
|
||||
font-size: 11px;
|
||||
color: var(--admin-text-tertiary);
|
||||
}
|
||||
|
||||
.staff-sidebar__nav {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.staff-nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-tertiary);
|
||||
padding: 16px 12px 8px 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.staff-nav-label:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.staff-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--admin-radius-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-nav-item i,
|
||||
.staff-nav-item svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--admin-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.staff-nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--admin-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.staff-nav-item:hover {
|
||||
background-color: var(--admin-bg-interactive);
|
||||
}
|
||||
|
||||
.staff-nav-item:hover i,
|
||||
.staff-nav-item:hover span {
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.staff-nav-item--active {
|
||||
background-color: #22C55E;
|
||||
}
|
||||
|
||||
.staff-nav-item--active i,
|
||||
.staff-nav-item--active svg {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.staff-nav-item--active span {
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-nav-item--active:hover {
|
||||
background-color: #22C55E;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Badge for notifications */
|
||||
.staff-badge {
|
||||
margin-left: auto;
|
||||
background: #EF4444;
|
||||
color: #FFFFFF;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* User section at bottom */
|
||||
.staff-sidebar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--admin-border-subtle);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.staff-user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #FFFFFF;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.staff-user-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.staff-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.staff-user-role {
|
||||
font-size: 11px;
|
||||
color: var(--admin-text-tertiary);
|
||||
}
|
||||
|
||||
.staff-sidebar__user button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.staff-sidebar__user button:hover {
|
||||
background: var(--admin-bg-interactive);
|
||||
}
|
||||
|
||||
.staff-sidebar__user button i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--admin-text-tertiary);
|
||||
}
|
||||
|
||||
/* ═════════════════════════════════════════
|
||||
MAIN CONTENT
|
||||
═════════════════════════════════════════ */
|
||||
|
||||
.staff-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile bar */
|
||||
.staff-mobile-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--admin-bg-elevated);
|
||||
border-bottom: 1px solid var(--admin-border-subtle);
|
||||
}
|
||||
|
||||
.staff-mobile-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.staff-mobile-toggle i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.staff-mobile-bar__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
/* Staff page content wrapper */
|
||||
.staff-page {
|
||||
padding: var(--admin-content-padding);
|
||||
}
|
||||
|
||||
.staff-page-header {
|
||||
margin-bottom: var(--admin-gap-xl);
|
||||
}
|
||||
|
||||
.staff-page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.staff-page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--admin-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Staff stat cards */
|
||||
.staff-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--admin-gap-md);
|
||||
margin-bottom: var(--admin-gap-xl);
|
||||
}
|
||||
|
||||
.staff-stat-card {
|
||||
background: var(--admin-bg-elevated);
|
||||
border: 1px solid var(--admin-border-default);
|
||||
border-radius: var(--admin-radius-lg);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.staff-stat-card__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.staff-stat-card__icon i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.staff-stat-card__value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.staff-stat-card__label {
|
||||
font-size: 13px;
|
||||
color: var(--admin-text-tertiary);
|
||||
}
|
||||
|
||||
/* Staff action button */
|
||||
.staff-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: #22C55E;
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
border-radius: var(--admin-radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-btn-primary:hover {
|
||||
background: #16A34A;
|
||||
}
|
||||
|
||||
.staff-btn-primary i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.staff-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--admin-bg-interactive);
|
||||
color: var(--admin-text-primary);
|
||||
border: 1px solid var(--admin-border-default);
|
||||
border-radius: var(--admin-radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.staff-btn-secondary:hover {
|
||||
background: var(--admin-bg-elevated);
|
||||
}
|
||||
|
||||
/* Staff table */
|
||||
.staff-table-card {
|
||||
background: var(--admin-bg-elevated);
|
||||
border: 1px solid var(--admin-border-default);
|
||||
border-radius: var(--admin-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.staff-table-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--admin-border-subtle);
|
||||
}
|
||||
|
||||
.staff-table-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.staff-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staff-status--success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.staff-status--warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #F59E0B;
|
||||
}
|
||||
|
||||
.staff-status--danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.staff-status--info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
.staff-status--neutral {
|
||||
background: rgba(139, 139, 144, 0.12);
|
||||
color: var(--admin-text-tertiary);
|
||||
}
|
||||
|
||||
/* Overlay for mobile sidebar */
|
||||
.staff-sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
/* ═════════════════════════════════════════
|
||||
RESPONSIVE
|
||||
═════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.staff-sidebar {
|
||||
position: fixed;
|
||||
left: -280px;
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.staff-sidebar--open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.staff-sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.staff-mobile-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.staff-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.staff-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<link rel="stylesheet" href="/css/auth.css" />
|
||||
<link rel="stylesheet" href="/css/admin.css" />
|
||||
<link rel="stylesheet" href="/css/staff.css" />
|
||||
<link rel="stylesheet" href="/css/marketing.css" />
|
||||
<link rel="stylesheet" href="/css/pos.css" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"Shop_Menu_Schedule": "Work Schedule",
|
||||
"Shop_Menu_Finance": "Finance",
|
||||
"Shop_Menu_Staff": "Staff",
|
||||
"Shop_Menu_Attendance": "Attendance",
|
||||
"Shop_Menu_LeaveRequests": "Leave Requests",
|
||||
"Shop_Menu_Customers": "Customers",
|
||||
"Shop_Menu_Promotions": "Promotions",
|
||||
"Shop_Menu_Reports": "Reports",
|
||||
|
||||
@@ -386,6 +386,8 @@
|
||||
"Shop_Menu_Schedule": "Lịch làm việc",
|
||||
"Shop_Menu_Finance": "Tài chính",
|
||||
"Shop_Menu_Staff": "Nhân sự",
|
||||
"Shop_Menu_Attendance": "Chấm công",
|
||||
"Shop_Menu_LeaveRequests": "Nghỉ phép",
|
||||
"Shop_Menu_Customers": "Khách hàng",
|
||||
"Shop_Menu_Promotions": "Khuyến mãi",
|
||||
"Shop_Menu_Reports": "Báo cáo",
|
||||
|
||||
@@ -138,6 +138,254 @@ public class StaffController : ControllerBase
|
||||
public Task<IActionResult> DeleteStaff(Guid staffId) =>
|
||||
_merchant.DeleteAsync($"/api/v1/merchants/me/staff/{staffId}").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current logged-in user's staff profile (role, shop assignment).
|
||||
/// VI: Lấy thông tin nhân viên của user đang đăng nhập (vai trò, phân công shop).
|
||||
/// </summary>
|
||||
[HttpGet("staff/me")]
|
||||
public async Task<IActionResult> GetMyStaffProfile()
|
||||
{
|
||||
// EN: Get all staff for this merchant, then match by email from auth token
|
||||
// VI: Lấy tất cả nhân viên của merchant, sau đó match theo email từ auth token
|
||||
var staffResp = await _merchant.GetAsync("/api/v1/merchants/me/staff");
|
||||
if (!staffResp.IsSuccessStatusCode)
|
||||
return StatusCode((int)staffResp.StatusCode, await staffResp.Content.ReadAsStringAsync());
|
||||
|
||||
var staffJson = await staffResp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(staffJson);
|
||||
|
||||
// EN: Extract items from response envelope
|
||||
// VI: Trích items từ response envelope
|
||||
JsonElement items;
|
||||
if (doc.RootElement.TryGetProperty("data", out var dataEl) && dataEl.TryGetProperty("items", out var di))
|
||||
items = di;
|
||||
else if (doc.RootElement.TryGetProperty("items", out var ri))
|
||||
items = ri;
|
||||
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
items = doc.RootElement;
|
||||
else
|
||||
return NotFound(new { success = false, message = "No staff data found" });
|
||||
|
||||
// EN: Try to match by Authorization header email (JWT sub claim parsed by merchant-service)
|
||||
// VI: Thử match theo email từ Authorization header (JWT sub claim được merchant-service parse)
|
||||
// For now, get email from query or forwarded header
|
||||
var userEmail = Request.Headers["X-User-Email"].FirstOrDefault();
|
||||
|
||||
// EN: Fallback: try to extract from token claims (simplified — in production use proper JWT parsing)
|
||||
// VI: Fallback: thử trích từ token claims
|
||||
if (string.IsNullOrEmpty(userEmail))
|
||||
{
|
||||
// EN: Forward the request with auth header to IAM /api/v1/users/me for email
|
||||
// VI: Chuyển request với auth header đến IAM /api/v1/users/me để lấy email
|
||||
try
|
||||
{
|
||||
var authHeader = Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
_iam.DefaultRequestHeaders.Clear();
|
||||
_iam.DefaultRequestHeaders.Add("Authorization", authHeader);
|
||||
var meResp = await _iam.GetAsync("/api/v1/users/me");
|
||||
if (meResp.IsSuccessStatusCode)
|
||||
{
|
||||
var meJson = await meResp.Content.ReadAsStringAsync();
|
||||
using var meDoc = JsonDocument.Parse(meJson);
|
||||
if (meDoc.RootElement.TryGetProperty("data", out var meData) && meData.TryGetProperty("email", out var emailProp))
|
||||
userEmail = emailProp.GetString();
|
||||
else if (meDoc.RootElement.TryGetProperty("email", out var emailDirect))
|
||||
userEmail = emailDirect.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* fallback to first staff */ }
|
||||
}
|
||||
|
||||
// EN: Find matching staff member
|
||||
// VI: Tìm nhân viên khớp
|
||||
JsonElement? matchedStaff = null;
|
||||
if (items.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var staff in items.EnumerateArray())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(userEmail) &&
|
||||
staff.TryGetProperty("email", out var emailProp) &&
|
||||
string.Equals(emailProp.GetString(), userEmail, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matchedStaff = staff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedStaff == null)
|
||||
return NotFound(new { success = false, message = "Staff profile not found for current user" });
|
||||
|
||||
var s = matchedStaff.Value;
|
||||
|
||||
// EN: Build profile response with shop assignment info
|
||||
// VI: Xây dựng response profile với thông tin phân công shop
|
||||
string? shopRole = null;
|
||||
Guid? shopId = null;
|
||||
string? shopName = null;
|
||||
|
||||
if (s.TryGetProperty("shopAssignments", out var assignments) && assignments.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var a in assignments.EnumerateArray())
|
||||
{
|
||||
if (a.TryGetProperty("shopId", out var sid))
|
||||
shopId = Guid.TryParse(sid.GetString(), out var parsed) ? parsed : null;
|
||||
if (a.TryGetProperty("shopRole", out var sr))
|
||||
shopRole = sr.GetString();
|
||||
break; // EN: Use first assignment / VI: Dùng phân công đầu tiên
|
||||
}
|
||||
}
|
||||
|
||||
var profile = new
|
||||
{
|
||||
staffId = s.TryGetProperty("id", out var idP) ? idP.GetString() : null,
|
||||
userId = s.TryGetProperty("userId", out var uidP) ? uidP.GetString() : null,
|
||||
email = s.TryGetProperty("email", out var emP) ? emP.GetString() : userEmail,
|
||||
firstName = s.TryGetProperty("firstName", out var fnP) ? fnP.GetString() : null,
|
||||
lastName = s.TryGetProperty("lastName", out var lnP) ? lnP.GetString() : null,
|
||||
role = s.TryGetProperty("role", out var rP) ? rP.GetString() : shopRole,
|
||||
shopName,
|
||||
shopId,
|
||||
shopRole,
|
||||
status = s.TryGetProperty("status", out var stP) ? stP.GetString() : null,
|
||||
employeeCode = s.TryGetProperty("employeeCode", out var ecP) ? ecP.GetString() : null,
|
||||
phone = s.TryGetProperty("phone", out var phP) ? phP.GetString() : null
|
||||
};
|
||||
|
||||
// EN: Try to get shop name if shopId is available
|
||||
// VI: Thử lấy tên shop nếu có shopId
|
||||
if (shopId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shopResp = await _merchant.GetAsync($"/api/v1/shops/{shopId}");
|
||||
if (shopResp.IsSuccessStatusCode)
|
||||
{
|
||||
var shopJson = await shopResp.Content.ReadAsStringAsync();
|
||||
using var shopDoc = JsonDocument.Parse(shopJson);
|
||||
if (shopDoc.RootElement.TryGetProperty("data", out var shopData) && shopData.TryGetProperty("name", out var sName))
|
||||
shopName = sName.GetString();
|
||||
else if (shopDoc.RootElement.TryGetProperty("name", out var sNameDirect))
|
||||
shopName = sNameDirect.GetString();
|
||||
}
|
||||
}
|
||||
catch { /* non-fatal */ }
|
||||
|
||||
if (shopName != null)
|
||||
{
|
||||
return Ok(new { success = true, data = new
|
||||
{
|
||||
profile.staffId, profile.userId, profile.email, profile.firstName, profile.lastName,
|
||||
profile.role, shopName, profile.shopId, profile.shopRole, profile.status, profile.employeeCode, profile.phone
|
||||
}});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = profile });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get attendance records for current staff.
|
||||
/// VI: Lấy bản ghi chấm công của nhân viên hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("staff/me/attendance")]
|
||||
public IActionResult GetMyAttendance([FromQuery] int month = 0, [FromQuery] int year = 0)
|
||||
{
|
||||
// EN: Stub — returns mock data until merchant-service attendance module is built
|
||||
// VI: Stub — trả về dữ liệu mẫu cho đến khi module chấm công được xây dựng
|
||||
var now = DateTime.UtcNow;
|
||||
var targetMonth = month > 0 ? month : now.Month;
|
||||
var targetYear = year > 0 ? year : now.Year;
|
||||
var daysInMonth = DateTime.DaysInMonth(targetYear, targetMonth);
|
||||
var records = new List<object>();
|
||||
|
||||
for (int d = 1; d <= Math.Min(daysInMonth, now.Day); d++)
|
||||
{
|
||||
var date = new DateTime(targetYear, targetMonth, d);
|
||||
if (date > now.Date) break;
|
||||
if (date.DayOfWeek == DayOfWeek.Sunday) continue;
|
||||
|
||||
records.Add(new
|
||||
{
|
||||
id = Guid.NewGuid(),
|
||||
staffId = Guid.Empty,
|
||||
date = date.ToString("o"),
|
||||
checkIn = date.AddHours(8).AddMinutes(new Random(d).Next(0, 15)).ToString("o"),
|
||||
checkOut = date.Date == now.Date ? (string?)null : date.AddHours(17).AddMinutes(new Random(d + 100).Next(0, 30)).ToString("o"),
|
||||
hoursWorked = date.Date == now.Date ? (decimal?)null : 8m + (decimal)(new Random(d + 200).Next(0, 120)) / 60m,
|
||||
status = date.Date == now.Date ? "Working" : "Completed",
|
||||
notes = (string?)null
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = new { items = records } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get leave requests for current staff.
|
||||
/// VI: Lấy yêu cầu nghỉ phép của nhân viên hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("staff/me/leave-requests")]
|
||||
public IActionResult GetMyLeaveRequests()
|
||||
{
|
||||
// EN: Stub — returns mock data
|
||||
// VI: Stub — trả về dữ liệu mẫu
|
||||
return Ok(new { success = true, data = new { items = new List<object>() } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a leave request.
|
||||
/// VI: Tạo yêu cầu nghỉ phép.
|
||||
/// </summary>
|
||||
[HttpPost("staff/me/leave-requests")]
|
||||
public IActionResult CreateLeaveRequest([FromBody] JsonElement body)
|
||||
{
|
||||
// EN: Stub — returns success
|
||||
// VI: Stub — trả về thành công
|
||||
return Ok(new { success = true, data = new { id = Guid.NewGuid() } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get notifications for current staff.
|
||||
/// VI: Lấy thông báo của nhân viên hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("staff/me/notifications")]
|
||||
public IActionResult GetMyNotifications()
|
||||
{
|
||||
// EN: Stub — returns sample notifications
|
||||
// VI: Stub — trả về thông báo mẫu
|
||||
var notifications = new List<object>
|
||||
{
|
||||
new { id = Guid.NewGuid(), title = "Chào mừng!", message = "Bạn đã đăng nhập thành công vào hệ thống GoodGo Staff.", type = "info", isRead = false, createdAt = DateTime.UtcNow.AddHours(-1).ToString("o") },
|
||||
new { id = Guid.NewGuid(), title = "Lịch làm việc", message = "Lịch làm việc tuần này đã được cập nhật.", type = "schedule", isRead = false, createdAt = DateTime.UtcNow.AddDays(-1).ToString("o") }
|
||||
};
|
||||
|
||||
return Ok(new { success = true, data = new { items = notifications } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check in attendance.
|
||||
/// VI: Chấm công vào.
|
||||
/// </summary>
|
||||
[HttpPost("staff/me/attendance/check-in")]
|
||||
public IActionResult CheckIn()
|
||||
{
|
||||
return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkIn = DateTime.UtcNow.ToString("o") } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check out attendance.
|
||||
/// VI: Chấm công ra.
|
||||
/// </summary>
|
||||
[HttpPost("staff/me/attendance/check-out")]
|
||||
public IActionResult CheckOut()
|
||||
{
|
||||
return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkOut = DateTime.UtcNow.ToString("o") } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available staff roles.
|
||||
/// VI: Lấy tất cả vai trò nhân viên hiện có.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// EN: Check-in command for staff attendance.
|
||||
// VI: Command cham cong vao cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.Attendance;
|
||||
|
||||
public record CheckInCommand(Guid StaffId, Guid ShopId) : IRequest<CheckInResult>;
|
||||
public record CheckInResult(Guid AttendanceId, DateTime CheckIn);
|
||||
|
||||
public class CheckInCommandHandler : IRequestHandler<CheckInCommand, CheckInResult>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
private readonly ILogger<CheckInCommandHandler> _logger;
|
||||
|
||||
public CheckInCommandHandler(IAttendanceRepository repo, ILogger<CheckInCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CheckInResult> Handle(CheckInCommand request, CancellationToken ct)
|
||||
{
|
||||
var existing = await _repo.GetTodayRecordAsync(request.StaffId, ct);
|
||||
if (existing != null)
|
||||
return new CheckInResult(existing.Id, existing.CheckIn ?? DateTime.UtcNow);
|
||||
|
||||
var record = AttendanceRecord.CheckInNow(request.StaffId, request.ShopId);
|
||||
_repo.Add(record);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Staff checked in / VI: Nhan vien da check-in: StaffId={StaffId}", request.StaffId);
|
||||
return new CheckInResult(record.Id, record.CheckIn ?? DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// EN: Check-out command for staff attendance.
|
||||
// VI: Command cham cong ra cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.Attendance;
|
||||
|
||||
public record CheckOutCommand(Guid StaffId) : IRequest<CheckOutResult>;
|
||||
public record CheckOutResult(Guid AttendanceId, DateTime CheckOut, decimal HoursWorked);
|
||||
|
||||
public class CheckOutCommandHandler : IRequestHandler<CheckOutCommand, CheckOutResult>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
private readonly ILogger<CheckOutCommandHandler> _logger;
|
||||
|
||||
public CheckOutCommandHandler(IAttendanceRepository repo, ILogger<CheckOutCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CheckOutResult> Handle(CheckOutCommand request, CancellationToken ct)
|
||||
{
|
||||
var record = await _repo.GetTodayRecordAsync(request.StaffId, ct)
|
||||
?? throw new InvalidOperationException("No check-in record found for today / Chua check-in hom nay");
|
||||
|
||||
record.DoCheckOut();
|
||||
_repo.Update(record);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Staff checked out / VI: Nhan vien da check-out: StaffId={StaffId}, Hours={Hours}",
|
||||
request.StaffId, record.HoursWorked);
|
||||
return new CheckOutResult(record.Id, record.CheckOut ?? DateTime.UtcNow, record.HoursWorked ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// EN: Command to approve/reject a leave request.
|
||||
// VI: Command duyet/tu choi yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.LeaveRequests;
|
||||
|
||||
public record ApproveLeaveRequestCommand(Guid LeaveRequestId, Guid ApprovedBy) : IRequest<bool>;
|
||||
public record RejectLeaveRequestCommand(Guid LeaveRequestId, Guid RejectedBy, string? Reason) : IRequest<bool>;
|
||||
|
||||
public class ApproveLeaveRequestCommandHandler : IRequestHandler<ApproveLeaveRequestCommand, bool>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public ApproveLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(ApproveLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = await _repo.GetByIdAsync(request.LeaveRequestId, ct);
|
||||
if (leaveRequest == null) return false;
|
||||
|
||||
leaveRequest.Approve(request.ApprovedBy);
|
||||
_repo.Update(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class RejectLeaveRequestCommandHandler : IRequestHandler<RejectLeaveRequestCommand, bool>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public RejectLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<bool> Handle(RejectLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = await _repo.GetByIdAsync(request.LeaveRequestId, ct);
|
||||
if (leaveRequest == null) return false;
|
||||
|
||||
leaveRequest.Reject(request.RejectedBy, request.Reason);
|
||||
_repo.Update(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// EN: Command to create a leave request.
|
||||
// VI: Command tao yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.LeaveRequests;
|
||||
|
||||
public record CreateLeaveRequestCommand(
|
||||
Guid StaffId, Guid ShopId, string LeaveType,
|
||||
DateTime StartDate, DateTime EndDate, string? Reason) : IRequest<Guid>;
|
||||
|
||||
public class CreateLeaveRequestCommandHandler : IRequestHandler<CreateLeaveRequestCommand, Guid>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
private readonly ILogger<CreateLeaveRequestCommandHandler> _logger;
|
||||
|
||||
public CreateLeaveRequestCommandHandler(ILeaveRequestRepository repo, ILogger<CreateLeaveRequestCommandHandler> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> Handle(CreateLeaveRequestCommand request, CancellationToken ct)
|
||||
{
|
||||
var leaveRequest = LeaveRequest.Create(
|
||||
request.StaffId, request.ShopId, request.LeaveType,
|
||||
request.StartDate, request.EndDate, request.Reason);
|
||||
|
||||
_repo.Add(leaveRequest);
|
||||
await _repo.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
|
||||
_logger.LogInformation("EN: Leave request created / VI: Yeu cau nghi phep da tao: StaffId={StaffId}, Type={Type}",
|
||||
request.StaffId, request.LeaveType);
|
||||
return leaveRequest.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Query to get attendance records for a staff member.
|
||||
// VI: Query lay ban ghi cham cong cho nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.Attendance;
|
||||
|
||||
public record GetAttendanceByStaffQuery(Guid StaffId, int Month, int Year) : IRequest<List<AttendanceDto>>;
|
||||
public record GetAttendanceByShopQuery(Guid ShopId, int Month, int Year) : IRequest<List<AttendanceDto>>;
|
||||
|
||||
public record AttendanceDto(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut,
|
||||
decimal? HoursWorked, string Status, string? Notes);
|
||||
|
||||
public class GetAttendanceByStaffQueryHandler : IRequestHandler<GetAttendanceByStaffQuery, List<AttendanceDto>>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
|
||||
public GetAttendanceByStaffQueryHandler(IAttendanceRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<AttendanceDto>> Handle(GetAttendanceByStaffQuery request, CancellationToken ct)
|
||||
{
|
||||
var records = await _repo.GetByStaffAndMonthAsync(request.StaffId, request.Month, request.Year, ct);
|
||||
return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut,
|
||||
r.HoursWorked, r.Status, r.Notes)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class GetAttendanceByShopQueryHandler : IRequestHandler<GetAttendanceByShopQuery, List<AttendanceDto>>
|
||||
{
|
||||
private readonly IAttendanceRepository _repo;
|
||||
|
||||
public GetAttendanceByShopQueryHandler(IAttendanceRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<AttendanceDto>> Handle(GetAttendanceByShopQuery request, CancellationToken ct)
|
||||
{
|
||||
var records = await _repo.GetByShopAndMonthAsync(request.ShopId, request.Month, request.Year, ct);
|
||||
return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut,
|
||||
r.HoursWorked, r.Status, r.Notes)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// EN: Query to get leave requests.
|
||||
// VI: Query lay yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.LeaveRequests;
|
||||
|
||||
public record GetLeaveRequestsByStaffQuery(Guid StaffId) : IRequest<List<LeaveRequestDto>>;
|
||||
public record GetLeaveRequestsByShopQuery(Guid ShopId) : IRequest<List<LeaveRequestDto>>;
|
||||
|
||||
public record LeaveRequestDto(Guid Id, Guid StaffId, string LeaveType, DateTime StartDate, DateTime EndDate,
|
||||
string? Reason, string Status, string? ApprovedBy, DateTime CreatedAt);
|
||||
|
||||
public class GetLeaveRequestsByStaffHandler : IRequestHandler<GetLeaveRequestsByStaffQuery, List<LeaveRequestDto>>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public GetLeaveRequestsByStaffHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<LeaveRequestDto>> Handle(GetLeaveRequestsByStaffQuery request, CancellationToken ct)
|
||||
{
|
||||
var items = await _repo.GetByStaffAsync(request.StaffId, ct);
|
||||
return items.Select(l => new LeaveRequestDto(l.Id, l.StaffId, l.LeaveType, l.StartDate, l.EndDate,
|
||||
l.Reason, l.Status, l.ApprovedBy?.ToString(), l.CreatedAt)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class GetLeaveRequestsByShopHandler : IRequestHandler<GetLeaveRequestsByShopQuery, List<LeaveRequestDto>>
|
||||
{
|
||||
private readonly ILeaveRequestRepository _repo;
|
||||
|
||||
public GetLeaveRequestsByShopHandler(ILeaveRequestRepository repo) => _repo = repo;
|
||||
|
||||
public async Task<List<LeaveRequestDto>> Handle(GetLeaveRequestsByShopQuery request, CancellationToken ct)
|
||||
{
|
||||
var items = await _repo.GetByShopAsync(request.ShopId, ct);
|
||||
return items.Select(l => new LeaveRequestDto(l.Id, l.StaffId, l.LeaveType, l.StartDate, l.EndDate,
|
||||
l.Reason, l.Status, l.ApprovedBy?.ToString(), l.CreatedAt)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// EN: Controller for staff attendance operations.
|
||||
// VI: Controller cho thao tac cham cong nhan vien.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MerchantService.API.Application.Commands.Attendance;
|
||||
using MerchantService.API.Application.Queries.Attendance;
|
||||
|
||||
namespace MerchantService.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/attendance")]
|
||||
public class AttendanceController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<AttendanceController> _logger;
|
||||
|
||||
public AttendanceController(IMediator mediator, ILogger<AttendanceController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get attendance records by staff and month.
|
||||
/// VI: Lay ban ghi cham cong theo nhan vien va thang.
|
||||
/// </summary>
|
||||
[HttpGet("staff/{staffId:guid}")]
|
||||
public async Task<IActionResult> GetByStaff(Guid staffId, [FromQuery] int month = 0, [FromQuery] int year = 0, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (month <= 0) month = now.Month;
|
||||
if (year <= 0) year = now.Year;
|
||||
|
||||
var result = await _mediator.Send(new GetAttendanceByStaffQuery(staffId, month, year), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get attendance records by shop and month.
|
||||
/// VI: Lay ban ghi cham cong theo cua hang va thang.
|
||||
/// </summary>
|
||||
[HttpGet("shop/{shopId:guid}")]
|
||||
public async Task<IActionResult> GetByShop(Guid shopId, [FromQuery] int month = 0, [FromQuery] int year = 0, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (month <= 0) month = now.Month;
|
||||
if (year <= 0) year = now.Year;
|
||||
|
||||
var result = await _mediator.Send(new GetAttendanceByShopQuery(shopId, month, year), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check in for a staff member.
|
||||
/// VI: Cham cong vao cho nhan vien.
|
||||
/// </summary>
|
||||
[HttpPost("check-in")]
|
||||
public async Task<IActionResult> CheckIn([FromBody] CheckInRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(new CheckInCommand(request.StaffId, request.ShopId), ct);
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking in");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check out for a staff member.
|
||||
/// VI: Cham cong ra cho nhan vien.
|
||||
/// </summary>
|
||||
[HttpPost("check-out")]
|
||||
public async Task<IActionResult> CheckOut([FromBody] CheckOutRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(new CheckOutCommand(request.StaffId), ct);
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking out");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckInRequest(Guid StaffId, Guid ShopId);
|
||||
public record CheckOutRequest(Guid StaffId);
|
||||
@@ -0,0 +1,94 @@
|
||||
// EN: Controller for leave request operations.
|
||||
// VI: Controller cho thao tac yeu cau nghi phep.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MerchantService.API.Application.Commands.LeaveRequests;
|
||||
using MerchantService.API.Application.Queries.LeaveRequests;
|
||||
|
||||
namespace MerchantService.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/leave-requests")]
|
||||
public class LeaveRequestsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<LeaveRequestsController> _logger;
|
||||
|
||||
public LeaveRequestsController(IMediator mediator, ILogger<LeaveRequestsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get leave requests by staff.
|
||||
/// VI: Lay yeu cau nghi phep theo nhan vien.
|
||||
/// </summary>
|
||||
[HttpGet("staff/{staffId:guid}")]
|
||||
public async Task<IActionResult> GetByStaff(Guid staffId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new GetLeaveRequestsByStaffQuery(staffId), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get leave requests by shop (for manager).
|
||||
/// VI: Lay yeu cau nghi phep theo cua hang (cho quan ly).
|
||||
/// </summary>
|
||||
[HttpGet("shop/{shopId:guid}")]
|
||||
public async Task<IActionResult> GetByShop(Guid shopId, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new GetLeaveRequestsByShopQuery(shopId), ct);
|
||||
return Ok(new { success = true, data = new { items = result } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a leave request.
|
||||
/// VI: Tao yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = await _mediator.Send(new CreateLeaveRequestCommand(
|
||||
request.StaffId, request.ShopId, request.LeaveType,
|
||||
request.StartDate, request.EndDate, request.Reason), ct);
|
||||
return Created($"/api/v1/leave-requests/{id}", new { success = true, data = new { id } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating leave request");
|
||||
return BadRequest(new { success = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Approve a leave request.
|
||||
/// VI: Duyet yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
public async Task<IActionResult> Approve(Guid id, [FromBody] ApproveRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new ApproveLeaveRequestCommand(id, request.ApprovedBy), ct);
|
||||
if (!result) return NotFound(new { success = false, message = "Leave request not found" });
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reject a leave request.
|
||||
/// VI: Tu choi yeu cau nghi phep.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
public async Task<IActionResult> Reject(Guid id, [FromBody] RejectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _mediator.Send(new RejectLeaveRequestCommand(id, request.RejectedBy, request.Reason), ct);
|
||||
if (!result) return NotFound(new { success = false, message = "Leave request not found" });
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateLeaveRequestRequest(Guid StaffId, Guid ShopId, string LeaveType, DateTime StartDate, DateTime EndDate, string? Reason);
|
||||
public record ApproveRequest(Guid ApprovedBy);
|
||||
public record RejectRequest(Guid RejectedBy, string? Reason);
|
||||
@@ -0,0 +1,93 @@
|
||||
// EN: Attendance record aggregate — tracks daily check-in/check-out for staff.
|
||||
// VI: Aggregate cham cong — theo doi check-in/check-out hang ngay cua nhan vien.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Daily attendance record for a staff member.
|
||||
/// VI: Ban ghi cham cong hang ngay cho nhan vien.
|
||||
/// </summary>
|
||||
public class AttendanceRecord : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _staffId;
|
||||
private Guid _shopId;
|
||||
private DateTime _date;
|
||||
private DateTime? _checkIn;
|
||||
private DateTime? _checkOut;
|
||||
private decimal? _hoursWorked;
|
||||
private string _status; // Working, Completed, Late, Absent
|
||||
private string? _notes;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
public Guid StaffId => _staffId;
|
||||
public Guid ShopId => _shopId;
|
||||
public DateTime Date => _date;
|
||||
public DateTime? CheckIn => _checkIn;
|
||||
public DateTime? CheckOut => _checkOut;
|
||||
public decimal? HoursWorked => _hoursWorked;
|
||||
public string Status => _status;
|
||||
public string? Notes => _notes;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
protected AttendanceRecord() { _status = "Working"; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create attendance record with check-in.
|
||||
/// VI: Tao ban ghi cham cong voi check-in.
|
||||
/// </summary>
|
||||
public static AttendanceRecord CheckInNow(Guid staffId, Guid shopId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var record = new AttendanceRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_staffId = staffId,
|
||||
_shopId = shopId,
|
||||
_date = now.Date,
|
||||
_checkIn = now,
|
||||
_status = "Working",
|
||||
_createdAt = now
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Record check-out and calculate hours worked.
|
||||
/// VI: Ghi nhan check-out va tinh gio lam.
|
||||
/// </summary>
|
||||
public void DoCheckOut()
|
||||
{
|
||||
if (_checkOut.HasValue)
|
||||
throw new InvalidOperationException("Already checked out / Da check-out roi");
|
||||
|
||||
_checkOut = DateTime.UtcNow;
|
||||
if (_checkIn.HasValue)
|
||||
_hoursWorked = (decimal)(_checkOut.Value - _checkIn.Value).TotalHours;
|
||||
_status = "Completed";
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark as absent (admin action).
|
||||
/// VI: Danh dau vang mat (thao tac admin).
|
||||
/// </summary>
|
||||
public void MarkAbsent(string? notes = null)
|
||||
{
|
||||
_status = "Absent";
|
||||
_notes = notes;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark as late.
|
||||
/// VI: Danh dau di muon.
|
||||
/// </summary>
|
||||
public void MarkLate()
|
||||
{
|
||||
_status = "Late";
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// EN: Repository interface for attendance records.
|
||||
// VI: Interface repository cho ban ghi cham cong.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
|
||||
public interface IAttendanceRepository : IRepository<AttendanceRecord>
|
||||
{
|
||||
Task<AttendanceRecord?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<AttendanceRecord?> GetTodayRecordAsync(Guid staffId, CancellationToken ct = default);
|
||||
Task<List<AttendanceRecord>> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default);
|
||||
Task<List<AttendanceRecord>> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default);
|
||||
AttendanceRecord Add(AttendanceRecord record);
|
||||
void Update(AttendanceRecord record);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// EN: Repository interface for leave requests.
|
||||
// VI: Interface repository cho yeu cau nghi phep.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
public interface ILeaveRequestRepository : IRepository<LeaveRequest>
|
||||
{
|
||||
Task<LeaveRequest?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<LeaveRequest>> GetByStaffAsync(Guid staffId, CancellationToken ct = default);
|
||||
Task<List<LeaveRequest>> GetByShopAsync(Guid shopId, CancellationToken ct = default);
|
||||
LeaveRequest Add(LeaveRequest request);
|
||||
void Update(LeaveRequest request);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// EN: Leave request aggregate — staff leave/time-off requests.
|
||||
// VI: Aggregate nghi phep — yeu cau nghi phep cua nhan vien.
|
||||
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave request from a staff member.
|
||||
/// VI: Yeu cau nghi phep tu nhan vien.
|
||||
/// </summary>
|
||||
public class LeaveRequest : Entity, IAggregateRoot
|
||||
{
|
||||
private Guid _staffId;
|
||||
private Guid _shopId;
|
||||
private string _leaveType; // Annual, Sick, Personal, Maternity, Other
|
||||
private DateTime _startDate;
|
||||
private DateTime _endDate;
|
||||
private string? _reason;
|
||||
private string _status; // Pending, Approved, Rejected
|
||||
private Guid? _approvedBy;
|
||||
private DateTime? _approvedAt;
|
||||
private string? _rejectionReason;
|
||||
private DateTime _createdAt;
|
||||
|
||||
public Guid StaffId => _staffId;
|
||||
public Guid ShopId => _shopId;
|
||||
public string LeaveType => _leaveType;
|
||||
public DateTime StartDate => _startDate;
|
||||
public DateTime EndDate => _endDate;
|
||||
public string? Reason => _reason;
|
||||
public string Status => _status;
|
||||
public Guid? ApprovedBy => _approvedBy;
|
||||
public DateTime? ApprovedAt => _approvedAt;
|
||||
public string? RejectionReason => _rejectionReason;
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Number of days requested.
|
||||
/// VI: So ngay nghi phep yeu cau.
|
||||
/// </summary>
|
||||
public int Days => (_endDate - _startDate).Days + 1;
|
||||
|
||||
protected LeaveRequest() { _leaveType = "Annual"; _status = "Pending"; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new leave request.
|
||||
/// VI: Tao yeu cau nghi phep moi.
|
||||
/// </summary>
|
||||
public static LeaveRequest Create(Guid staffId, Guid shopId, string leaveType, DateTime startDate, DateTime endDate, string? reason)
|
||||
{
|
||||
if (endDate < startDate)
|
||||
throw new InvalidOperationException("End date must be after start date / Ngay ket thuc phai sau ngay bat dau");
|
||||
|
||||
return new LeaveRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
_staffId = staffId,
|
||||
_shopId = shopId,
|
||||
_leaveType = leaveType,
|
||||
_startDate = startDate.Date,
|
||||
_endDate = endDate.Date,
|
||||
_reason = reason,
|
||||
_status = "Pending",
|
||||
_createdAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Approve the leave request.
|
||||
/// VI: Duyet yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public void Approve(Guid approvedBy)
|
||||
{
|
||||
if (_status != "Pending")
|
||||
throw new InvalidOperationException("Only pending requests can be approved / Chi duyet duoc yeu cau dang cho");
|
||||
|
||||
_status = "Approved";
|
||||
_approvedBy = approvedBy;
|
||||
_approvedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reject the leave request.
|
||||
/// VI: Tu choi yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public void Reject(Guid rejectedBy, string? reason = null)
|
||||
{
|
||||
if (_status != "Pending")
|
||||
throw new InvalidOperationException("Only pending requests can be rejected / Chi tu choi duoc yeu cau dang cho");
|
||||
|
||||
_status = "Rejected";
|
||||
_approvedBy = rejectedBy;
|
||||
_approvedAt = DateTime.UtcNow;
|
||||
_rejectionReason = reason;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Infrastructure.Idempotency;
|
||||
using MerchantService.Infrastructure.Repositories;
|
||||
|
||||
@@ -57,6 +59,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IMerchantRepository, MerchantRepository>();
|
||||
services.AddScoped<IShopRepository, ShopRepository>();
|
||||
services.AddScoped<IMerchantStaffRepository, MerchantStaffRepository>();
|
||||
services.AddScoped<IAttendanceRepository, AttendanceRepository>();
|
||||
services.AddScoped<ILeaveRequestRepository, LeaveRequestRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// EN: EF Core configuration for attendance_records table.
|
||||
// VI: Cau hinh EF Core cho bang attendance_records.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MerchantService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class AttendanceRecordEntityTypeConfiguration : IEntityTypeConfiguration<AttendanceRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AttendanceRecord> builder)
|
||||
{
|
||||
builder.ToTable("attendance_records");
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property<Guid>("_staffId").HasColumnName("staff_id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<DateTime>("_date").HasColumnName("date").IsRequired();
|
||||
builder.Property<DateTime?>("_checkIn").HasColumnName("check_in");
|
||||
builder.Property<DateTime?>("_checkOut").HasColumnName("check_out");
|
||||
builder.Property<decimal?>("_hoursWorked").HasColumnName("hours_worked").HasPrecision(5, 2);
|
||||
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||
builder.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(500);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
builder.Ignore(a => a.DomainEvents);
|
||||
builder.Ignore(a => a.StaffId);
|
||||
builder.Ignore(a => a.ShopId);
|
||||
builder.Ignore(a => a.Date);
|
||||
builder.Ignore(a => a.CheckIn);
|
||||
builder.Ignore(a => a.CheckOut);
|
||||
builder.Ignore(a => a.HoursWorked);
|
||||
builder.Ignore(a => a.Status);
|
||||
builder.Ignore(a => a.Notes);
|
||||
builder.Ignore(a => a.CreatedAt);
|
||||
|
||||
builder.HasIndex("_staffId", "_date").IsUnique().HasDatabaseName("ix_attendance_staff_date");
|
||||
builder.HasIndex("_shopId", "_date").HasDatabaseName("ix_attendance_shop_date");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// EN: EF Core configuration for leave_requests table.
|
||||
// VI: Cau hinh EF Core cho bang leave_requests.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MerchantService.Infrastructure.EntityConfigurations;
|
||||
|
||||
public class LeaveRequestEntityTypeConfiguration : IEntityTypeConfiguration<LeaveRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeaveRequest> builder)
|
||||
{
|
||||
builder.ToTable("leave_requests");
|
||||
builder.HasKey(l => l.Id);
|
||||
|
||||
builder.Property<Guid>("_staffId").HasColumnName("staff_id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_leaveType").HasColumnName("leave_type").HasMaxLength(20).IsRequired();
|
||||
builder.Property<DateTime>("_startDate").HasColumnName("start_date").IsRequired();
|
||||
builder.Property<DateTime>("_endDate").HasColumnName("end_date").IsRequired();
|
||||
builder.Property<string?>("_reason").HasColumnName("reason").HasMaxLength(500);
|
||||
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).IsRequired();
|
||||
builder.Property<Guid?>("_approvedBy").HasColumnName("approved_by");
|
||||
builder.Property<DateTime?>("_approvedAt").HasColumnName("approved_at");
|
||||
builder.Property<string?>("_rejectionReason").HasColumnName("rejection_reason").HasMaxLength(500);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.Ignore(l => l.DomainEvents);
|
||||
builder.Ignore(l => l.StaffId);
|
||||
builder.Ignore(l => l.ShopId);
|
||||
builder.Ignore(l => l.LeaveType);
|
||||
builder.Ignore(l => l.StartDate);
|
||||
builder.Ignore(l => l.EndDate);
|
||||
builder.Ignore(l => l.Reason);
|
||||
builder.Ignore(l => l.Status);
|
||||
builder.Ignore(l => l.ApprovedBy);
|
||||
builder.Ignore(l => l.ApprovedAt);
|
||||
builder.Ignore(l => l.RejectionReason);
|
||||
builder.Ignore(l => l.CreatedAt);
|
||||
builder.Ignore(l => l.Days);
|
||||
|
||||
builder.HasIndex("_staffId").HasDatabaseName("ix_leave_requests_staff_id");
|
||||
builder.HasIndex("_shopId").HasDatabaseName("ix_leave_requests_shop_id");
|
||||
builder.HasIndex("_status").HasDatabaseName("ix_leave_requests_status");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.Infrastructure;
|
||||
@@ -55,6 +57,18 @@ public class MerchantServiceContext : DbContext, IUnitOfWork
|
||||
/// </summary>
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Attendance records table.
|
||||
/// VI: Bang cham cong.
|
||||
/// </summary>
|
||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave requests table.
|
||||
/// VI: Bang yeu cau nghi phep.
|
||||
/// </summary>
|
||||
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// EN: Repository implementation for attendance records.
|
||||
// VI: Implementation repository cho ban ghi cham cong.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.AttendanceAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
public class AttendanceRepository : IAttendanceRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AttendanceRepository(MerchantServiceContext context) => _context = context;
|
||||
|
||||
public async Task<AttendanceRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await _context.AttendanceRecords.FirstOrDefaultAsync(a => a.Id == id, ct);
|
||||
|
||||
public async Task<AttendanceRecord?> GetTodayRecordAsync(Guid staffId, CancellationToken ct = default)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
return await _context.AttendanceRecords
|
||||
.FirstOrDefaultAsync(a => a.StaffId == staffId && a.Date == today, ct);
|
||||
}
|
||||
|
||||
public async Task<List<AttendanceRecord>> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
return await _context.AttendanceRecords
|
||||
.Where(a => a.StaffId == staffId && a.Date >= startDate && a.Date < endDate)
|
||||
.OrderByDescending(a => a.Date)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<AttendanceRecord>> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
return await _context.AttendanceRecords
|
||||
.Where(a => a.ShopId == shopId && a.Date >= startDate && a.Date < endDate)
|
||||
.OrderByDescending(a => a.Date)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public AttendanceRecord Add(AttendanceRecord record) => _context.AttendanceRecords.Add(record).Entity;
|
||||
public void Update(AttendanceRecord record) => _context.Entry(record).State = EntityState.Modified;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// EN: Repository implementation for leave requests.
|
||||
// VI: Implementation repository cho yeu cau nghi phep.
|
||||
|
||||
using MerchantService.Domain.AggregatesModel.LeaveRequestAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MerchantService.Infrastructure.Repositories;
|
||||
|
||||
public class LeaveRequestRepository : ILeaveRequestRepository
|
||||
{
|
||||
private readonly MerchantServiceContext _context;
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public LeaveRequestRepository(MerchantServiceContext context) => _context = context;
|
||||
|
||||
public async Task<LeaveRequest?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests.FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
|
||||
public async Task<List<LeaveRequest>> GetByStaffAsync(Guid staffId, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests
|
||||
.Where(l => l.StaffId == staffId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<List<LeaveRequest>> GetByShopAsync(Guid shopId, CancellationToken ct = default)
|
||||
=> await _context.LeaveRequests
|
||||
.Where(l => l.ShopId == shopId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public LeaveRequest Add(LeaveRequest request) => _context.LeaveRequests.Add(request).Entity;
|
||||
public void Update(LeaveRequest request) => _context.Entry(request).State = EntityState.Modified;
|
||||
}
|
||||
Reference in New Issue
Block a user