diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor new file mode 100644 index 00000000..236cbfcb --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor @@ -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 + + + + + + +
+ @* ═══ SIDEBAR ═══ *@ + + + @* Mobile overlay *@ + @if (_sidebarOpen) + { +
+ } + + @* ═══ MAIN AREA ═══ *@ +
+ @* Mobile-only hamburger toggle *@ +
+ + GoodGo Staff +
+ + + + @Body + + + +
+
+ +
+

Có lỗi xảy ra

+

Vui lòng thử lại

+ +
+
+
+
+
+ +@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; + + /// + /// 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. + /// + 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; + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopAttendance.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopAttendance.razor new file mode 100644 index 00000000..0315153a --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopAttendance.razor @@ -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. +*@ + +Chấm công - Quản lý + +
+
+
+

Chấm công

+

Quản lý chấm công nhân viên cửa hàng

+
+
+ + Tháng @_month/@_year + +
+
+ + @* ═══ SUMMARY CARDS ═══ *@ +
+
+
@_staffCount
+
Nhân viên
+
+
+
@_presentToday
+
Có mặt hôm nay
+
+
+
@_lateCount
+
Đi muộn
+
+
+
@_absentCount
+
Vắng mặt
+
+
+ + @* ═══ ATTENDANCE TABLE ═══ *@ +
+ @if (_loading) + { +
+ +
+ } + else + { + + + + Nhân viên + Ngày + Vào + Ra + Giờ làm + Trạng thái + + + + @if (_records.Count == 0) + { + Chưa có dữ liệu chấm công tháng này + } + @foreach (var r in _records) + { + + @(r.StaffName ?? r.StaffId.ToString()[..8]) + @r.Date.ToString("dd/MM") + @(r.CheckIn?.ToString("HH:mm") ?? "--") + @(r.CheckOut?.ToString("HH:mm") ?? "--") + @(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--") + + @{ + var css = r.Status switch { "Completed" => "color:#22C55E", "Working" => "color:#3B82F6", "Late" => "color:#F59E0B", "Absent" => "color:#EF4444", _ => "" }; + } + @r.Status + + + } + + + } +
+
+ +@code { + [Parameter] public string ShopId { get; set; } = ""; + + private List _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); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopLeaveRequests.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopLeaveRequests.razor new file mode 100644 index 00000000..e1e6f499 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/Shop/ShopLeaveRequests.razor @@ -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. +*@ + +Nghỉ phép - Quản lý + +
+
+
+

Quản lý nghỉ phép

+

Duyệt và quản lý yêu cầu nghỉ phép nhân viên

+
+
+ + @* ═══ SUMMARY ═══ *@ +
+
+
@_pendingCount
+
Chờ duyệt
+
+
+
@_approvedCount
+
Đã duyệt
+
+
+
@_rejectedCount
+
Từ chối
+
+
+ + @* ═══ LEAVE REQUESTS TABLE ═══ *@ +
+ @if (_loading) + { +
+ +
+ } + else if (_requests.Count == 0) + { +
+ Chưa có yêu cầu nghỉ phép nào +
+ } + else + { + + + + Nhân viên + Loại + Từ ngày + Đến ngày + Số ngày + Lý do + Trạng thái + Thao tác + + + + @foreach (var r in _requests) + { + + @r.StaffId.ToString()[..8]... + @GetLeaveTypeLabel(r.LeaveType) + @r.StartDate.ToString("dd/MM/yyyy") + @r.EndDate.ToString("dd/MM/yyyy") + @((r.EndDate - r.StartDate).Days + 1) + @(r.Reason ?? "--") + + @{ + var statusCss = r.Status switch { "Approved" => "color:#22C55E", "Pending" => "color:#F59E0B", "Rejected" => "color:#EF4444", _ => "" }; + } + @GetStatusLabel(r.Status) + + + @if (r.Status == "Pending") + { +
+ + +
+ } + + + } + +
+ } +
+
+ +@code { + [Parameter] public string ShopId { get; set; } = ""; + + private List _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 + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor index 3af99e4b..c3a42882 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor @@ -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 { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor new file mode 100644 index 00000000..4c0080fd --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor @@ -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. +*@ + +Chấm công + +
+
+

Chấm công

+

Lịch sử chấm công hàng ngày

+
+ + @* ═══ MONTH SELECTOR ═══ *@ +
+ + + Tháng @_month/@_year + + +
+ + @* ═══ SUMMARY ═══ *@ +
+
+ @_totalDays + Ngày công +
+
+ @_totalHours.ToString("0.#")h + Tổng giờ làm +
+
+ @(_totalDays > 0 ? (_totalHours / _totalDays).ToString("0.#") : "0")h + TB giờ/ngày +
+
+ + @* ═══ TABLE ═══ *@ +
+ @if (_loading) + { +
+ +
+ } + else + { + + + + Ngày + Thứ + Vào + Ra + Giờ làm + Trạng thái + + + + @foreach (var r in _records) + { + + @r.Date.ToString("dd/MM/yyyy") + @GetDayOfWeek(r.Date) + @(r.CheckIn?.ToString("HH:mm") ?? "--") + @(r.CheckOut?.ToString("HH:mm") ?? "--") + @(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--") + + + @GetStatusLabel(r.Status) + + + + } + @if (_records.Count == 0) + { + Không có dữ liệu + } + + + } +
+
+ +@code { + private List _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 ?? "--" + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor new file mode 100644 index 00000000..f37c342c --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor @@ -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. +*@ + +Staff Dashboard + +
+
+

Xin chào, @_displayName!

+

@DateTime.Now.ToString("dddd, dd/MM/yyyy") — @(_profile?.Role ?? "Staff")

+
+ + @* ═══ QUICK ACTIONS ═══ *@ +
+ @if (!_checkedIn) + { + + } + else if (!_checkedOut) + { + + } + else + { + Đã chấm công hôm nay + } + + +
+ + @* ═══ STAT CARDS ═══ *@ +
+
+
+ +
+ @_todayHours + Giờ làm hôm nay +
+
+
+ +
+ @_monthDays + Ngày công tháng này +
+
+
+ +
+ @_leaveBalance + Ngày phép còn lại +
+
+
+ +
+ @_unreadCount + Thông báo chưa đọc +
+
+ + @* ═══ RECENT ATTENDANCE ═══ *@ +
+
+ Chấm công gần đây + +
+ @if (_loading) + { +
+ +
+ } + else + { + + + + Ngày + Vào + Ra + Giờ làm + Trạng thái + + + + @foreach (var r in _recentAttendance.Take(7)) + { + + @r.Date.ToString("dd/MM") + @(r.CheckIn?.ToString("HH:mm") ?? "--") + @(r.CheckOut?.ToString("HH:mm") ?? "--") + @(r.HoursWorked.HasValue ? r.HoursWorked.Value.ToString("0.#") + "h" : "--") + + + @(r.Status == "Completed" ? "Hoàn thành" : r.Status == "Working" ? "Đang làm" : r.Status) + + + + } + @if (_recentAttendance.Count == 0) + { + Chưa có dữ liệu chấm công + } + + + } +
+
+ +@code { + private PosDataService.StaffProfileInfo? _profile; + private List _recentAttendance = new(); + private List _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; } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor new file mode 100644 index 00000000..28316af5 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffKitchen.razor @@ -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. +*@ + +Bếp - Kitchen Display + +
+
+
+

Kitchen Display

+

Phiếu bếp đang chờ xử lý

+
+ +
+ + @* ═══ STATUS SUMMARY ═══ *@ +
+
+
+ +
+ @_pendingCount + Chờ làm +
+
+
+ +
+ @_inProgressCount + Đang làm +
+
+
+ +
+ @_completedCount + Hoàn thành +
+
+ + @* ═══ TICKET GRID ═══ *@ + @if (_loading) + { +
+ +
+ } + else if (_tickets.Count == 0) + { +
+
+ +
+

Không có phiếu bếp

+

Tất cả đã được xử lý!

+
+ } + else + { +
+ @foreach (var ticket in _tickets) + { + var borderColor = ticket.Status switch + { + "Pending" => "#EF4444", + "InProgress" => "#F59E0B", + "Ready" => "#22C55E", + _ => "var(--admin-border-default)" + }; +
+
+ #@ticket.OrderNumber + @GetTicketStatusLabel(ticket.Status) +
+
@ticket.TableInfo
+
+ @foreach (var item in ticket.Items) + { +
+ @item.Name + x@(item.Qty) +
+ } +
+
+ @if (ticket.Status == "Pending") + { + + } + else if (ticket.Status == "InProgress") + { + + } +
+
+ } +
+ } +
+ +@code { + private List _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 + { + 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 items) + { + public string OrderNumber { get; } = orderNumber; + public string TableInfo { get; } = tableInfo; + public string Status { get; set; } = status; + public List Items { get; } = items; + } + + private record TicketItem(string Name, int Qty); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor new file mode 100644 index 00000000..7b80c5a5 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor @@ -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. +*@ + +Nghỉ phép + +
+
+
+

Nghỉ phép

+

Quản lý yêu cầu nghỉ phép

+
+ +
+ + @* ═══ SUMMARY ═══ *@ +
+
+ 12 + Tổng phép năm +
+
+ @_usedDays + Đã sử dụng +
+
+ @(12 - _usedDays) + Còn lại +
+
+ @_pendingCount + Chờ duyệt +
+
+ + @* ═══ CREATE FORM ═══ *@ + @if (_showForm) + { +
+

Tạo yêu cầu nghỉ phép

+
+ + Phép năm + Nghỉ ốm + Việc cá nhân + Thai sản + Khác + +
+ + +
+ +
+ + +
+
+ } + + @* ═══ LEAVE LIST ═══ *@ +
+
+ Lịch sử nghỉ phép +
+ @if (_loading) + { +
+ +
+ } + else + { + + + + Loại + Từ ngày + Đến ngày + Số ngày + Lý do + Trạng thái + + + + @foreach (var r in _requests) + { + + @GetLeaveTypeLabel(r.LeaveType) + @r.StartDate.ToString("dd/MM/yyyy") + @r.EndDate.ToString("dd/MM/yyyy") + @((r.EndDate - r.StartDate).Days + 1) + @(r.Reason ?? "--") + + + @GetLeaveStatusLabel(r.Status) + + + + } + @if (_requests.Count == 0) + { + Chưa có yêu cầu nghỉ phép nào + } + + + } +
+
+ +@code { + private List _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 + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffNotifications.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffNotifications.razor new file mode 100644 index 00000000..5e4609ed --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffNotifications.razor @@ -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. +*@ + +Thông báo + +
+
+

Thông báo

+

Thông báo và cập nhật từ cửa hàng

+
+ + @if (_loading) + { +
+ +
+ } + else if (_notifications.Count == 0) + { +
+
+ +
+

Không có thông báo

+

Bạn đã đọc tất cả thông báo

+
+ } + else + { +
+ @foreach (var n in _notifications) + { +
+
+ +
+
+
+ @n.Title + @if (!n.IsRead) + { + + } +
+

@n.Message

+ @FormatTime(n.CreatedAt) +
+
+ } +
+ } +
+ +@code { + private List _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"); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffOverview.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffOverview.razor new file mode 100644 index 00000000..32446cad --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffOverview.razor @@ -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. +*@ + +Báo cáo - Manager + +
+
+

Tổng quan

+

Báo cáo hoạt động cửa hàng hôm nay

+
+ + @* ═══ TODAY'S STATS ═══ *@ +
+
+
+ +
+ -- + Đơn hàng hôm nay +
+
+
+ +
+ -- + Doanh thu +
+
+
+ +
+ @_staffOnline + Nhân viên online +
+
+
+ +
+ @_pendingLeave + Nghỉ phép chờ duyệt +
+
+ + @* ═══ TEAM STATUS ═══ *@ +
+
+ Nhân viên hôm nay +
+ + + + Tên + Vai trò + Check-in + Trạng thái + + + + @foreach (var s in _teamStatus) + { + + @s.Name + @s.Role + @s.CheckIn + @(s.IsOnline ? "Online" : "Offline") + + } + @if (_teamStatus.Count == 0) + { + Chưa có dữ liệu + } + + +
+ + @* ═══ PENDING LEAVE REQUESTS ═══ *@ +
+
+ Nghỉ phép chờ duyệt +
+
+ Chưa có yêu cầu nghỉ phép nào cần duyệt +
+
+
+ +@code { + private int _staffOnline = 0; + private int _pendingLeave = 0; + private List _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); +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPayroll.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPayroll.razor new file mode 100644 index 00000000..a6325fe7 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPayroll.razor @@ -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. +*@ + +Thông tin lương + +
+
+

Thông tin lương

+

Lương và phụ cấp hàng tháng

+
+ + @* ═══ CURRENT MONTH ═══ *@ +
+
+
+ +
+ -- + Lương cơ bản +
+
+
+ +
+ -- + Phụ cấp +
+
+
+ +
+ -- + Khấu trừ +
+
+
+ +
+ -- + Thực nhận +
+
+ + @* ═══ NOTE ═══ *@ +
+
+ +
+

Tính năng đang phát triển

+

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.

+
+
+ +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPos.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPos.razor new file mode 100644 index 00000000..4ff43a54 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffPos.razor @@ -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. +*@ + +Thu ngân - POS + +
+
+

Thu ngân

+

Chọn cửa hàng để bắt đầu bán hàng

+
+ +
+
+ +
+

Mở POS

+

Nhấn nút bên dưới để mở màn hình thu ngân toàn màn hình

+ + @if (_shopId.HasValue) + { + + } + else + { +

Chưa được phân công cửa hàng. Vui lòng liên hệ quản lý.

+ } +
+
+ +@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); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffSchedule.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffSchedule.razor new file mode 100644 index 00000000..c6358fc2 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffSchedule.razor @@ -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. +*@ + +Lịch làm việc + +
+
+

Lịch làm việc

+

Lịch trình làm việc của bạn

+
+ + @* ═══ WEEK VIEW ═══ *@ +
+ @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; +
+
@GetDayName(day)
+
@day.Day
+ @if (isWeekend) + { + Nghỉ + } + else + { +
08:00 - 17:00
+ } +
+ } +
+ + @* ═══ UPCOMING SHIFTS ═══ *@ +
+
+ Thông tin ca làm việc +
+
+
+
+ +
+
Ca sáng: 08:00 - 12:00
+
Thu 2 - Thu 6
+
+
+
+ +
+
Ca chiều: 13:00 - 17:00
+
Thu 2 - Thu 6
+
+
+
+
+
+
+ +@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", + _ => "" + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTables.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTables.razor new file mode 100644 index 00000000..97df56ac --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTables.razor @@ -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. +*@ + +Bàn / Order + +
+
+

Bàn & Order

+

Quản lý bàn và order của bạn

+
+ + @if (_loading) + { +
+ +
+ } + else + { + @* ═══ TABLE GRID ═══ *@ +
+ @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)" + }; +
+
BÀN
+
@table.TableNumber
+ + @GetTableStatusLabel(table.Status) + + @if (table.GuestCount > 0) + { +
+ @table.GuestCount khách +
+ } +
+ } +
+ + @* ═══ SELECTED TABLE DETAIL ═══ *@ + @if (_selectedTable != null) + { +
+
+ Bàn @_selectedTable.TableNumber + @GetTableStatusLabel(_selectedTable.Status) +
+
+
+ @if (_selectedTable.Status == "Available") + { + + } + else if (_selectedTable.Status == "Occupied") + { + + + } +
+
+
+ } + } +
+ +@code { + private List _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 + }; +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTeam.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTeam.razor new file mode 100644 index 00000000..e3495719 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffTeam.razor @@ -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. +*@ + +Quản lý nhân viên + +
+
+

Đội ngũ

+

Quản lý nhân viên cửa hàng

+
+ + @if (_loading) + { +
+ +
+ } + else + { +
+
+ Danh sách nhân viên (@_staff.Count) +
+ + + + Tên + Mã NV + Vai trò + Email + SĐT + Trạng thái + + + + @foreach (var s in _staff) + { + + @(s.FirstName ?? "") @(s.LastName ?? "") + @(s.EmployeeCode ?? "--") + @(s.Role ?? "--") + @(s.Email ?? "--") + @(s.Phone ?? "--") + + + @(s.Status == "Active" ? "Hoạt động" : s.Status ?? "--") + + + + } + @if (_staff.Count == 0) + { + Không có nhân viên + } + + +
+ } +
+ +@code { + private List _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 { } + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs index df728aa5..756d40eb 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs @@ -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" diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index a715785c..5c42dfbe 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -65,6 +65,17 @@ public class PosDataService catch { return $"Lỗi ({resp.StatusCode})"; } } + /// + /// EN: Generic POST helper — sends JSON body to BFF endpoint. + /// VI: Helper POST chung — gui JSON body den BFF endpoint. + /// + public async Task PostAsync(string url, object body) + { + AttachToken(); + var resp = await _http.PostAsJsonAsync(url, body, _writeOptions); + return resp.IsSuccessStatusCode; + } + /// /// 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> GetStaffAsync() => await GetListFromApiAsync("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 GetMyStaffProfileAsync() + => await GetObjectFromApiAsync("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> GetMyAttendanceAsync(int month = 0, int year = 0) + { + var qs = month > 0 && year > 0 ? $"?month={month}&year={year}" : ""; + return await GetListFromApiAsync($"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> GetMyLeaveRequestsAsync() + => await GetListFromApiAsync("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> GetMyNotificationsAsync() + => await GetListFromApiAsync("api/bff/staff/me/notifications"); + // ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══ // EN: Admin-level records with shop_id and category info diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs index 4a0fed11..73b561da 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/ShopSidebarConfig.cs @@ -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"), diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/staff.css b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/staff.css new file mode 100644 index 00000000..350aabeb --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/css/staff.css @@ -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); + } +} diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html index 9393af52..3934ae5c 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html @@ -60,6 +60,7 @@ + diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json index 0061b472..60f6da92 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/en-US.json @@ -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", diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json index 7c143558..5e47a2d7 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json @@ -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", diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs index ba3c19cd..d90900d4 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/StaffController.cs @@ -138,6 +138,254 @@ public class StaffController : ControllerBase public Task DeleteStaff(Guid staffId) => _merchant.DeleteAsync($"/api/v1/merchants/me/staff/{staffId}").ProxyAsync(); + /// + /// 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). + /// + [HttpGet("staff/me")] + public async Task 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 }); + } + + /// + /// 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. + /// + [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(); + + 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 } }); + } + + /// + /// 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. + /// + [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() } }); + } + + /// + /// EN: Create a leave request. + /// VI: Tạo yêu cầu nghỉ phép. + /// + [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() } }); + } + + /// + /// EN: Get notifications for current staff. + /// VI: Lấy thông báo của nhân viên hiện tại. + /// + [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 + { + 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 } }); + } + + /// + /// EN: Check in attendance. + /// VI: Chấm công vào. + /// + [HttpPost("staff/me/attendance/check-in")] + public IActionResult CheckIn() + { + return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkIn = DateTime.UtcNow.ToString("o") } }); + } + + /// + /// EN: Check out attendance. + /// VI: Chấm công ra. + /// + [HttpPost("staff/me/attendance/check-out")] + public IActionResult CheckOut() + { + return Ok(new { success = true, data = new { id = Guid.NewGuid(), checkOut = DateTime.UtcNow.ToString("o") } }); + } + /// /// EN: Get all available staff roles. /// VI: Lấy tất cả vai trò nhân viên hiện có. diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckInCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckInCommand.cs new file mode 100644 index 00000000..91af1639 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckInCommand.cs @@ -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; +public record CheckInResult(Guid AttendanceId, DateTime CheckIn); + +public class CheckInCommandHandler : IRequestHandler +{ + private readonly IAttendanceRepository _repo; + private readonly ILogger _logger; + + public CheckInCommandHandler(IAttendanceRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckOutCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckOutCommand.cs new file mode 100644 index 00000000..97c365ae --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Attendance/CheckOutCommand.cs @@ -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; +public record CheckOutResult(Guid AttendanceId, DateTime CheckOut, decimal HoursWorked); + +public class CheckOutCommandHandler : IRequestHandler +{ + private readonly IAttendanceRepository _repo; + private readonly ILogger _logger; + + public CheckOutCommandHandler(IAttendanceRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/ApproveLeaveRequestCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/ApproveLeaveRequestCommand.cs new file mode 100644 index 00000000..ae339288 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/ApproveLeaveRequestCommand.cs @@ -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; +public record RejectLeaveRequestCommand(Guid LeaveRequestId, Guid RejectedBy, string? Reason) : IRequest; + +public class ApproveLeaveRequestCommandHandler : IRequestHandler +{ + private readonly ILeaveRequestRepository _repo; + + public ApproveLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo; + + public async Task 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 +{ + private readonly ILeaveRequestRepository _repo; + + public RejectLeaveRequestCommandHandler(ILeaveRequestRepository repo) => _repo = repo; + + public async Task 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; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/CreateLeaveRequestCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/CreateLeaveRequestCommand.cs new file mode 100644 index 00000000..32ed915a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/LeaveRequests/CreateLeaveRequestCommand.cs @@ -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; + +public class CreateLeaveRequestCommandHandler : IRequestHandler +{ + private readonly ILeaveRequestRepository _repo; + private readonly ILogger _logger; + + public CreateLeaveRequestCommandHandler(ILeaveRequestRepository repo, ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs new file mode 100644 index 00000000..6bc752ea --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs @@ -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>; +public record GetAttendanceByShopQuery(Guid ShopId, int Month, int Year) : IRequest>; + +public record AttendanceDto(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut, + decimal? HoursWorked, string Status, string? Notes); + +public class GetAttendanceByStaffQueryHandler : IRequestHandler> +{ + private readonly IAttendanceRepository _repo; + + public GetAttendanceByStaffQueryHandler(IAttendanceRepository repo) => _repo = repo; + + public async Task> 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> +{ + private readonly IAttendanceRepository _repo; + + public GetAttendanceByShopQueryHandler(IAttendanceRepository repo) => _repo = repo; + + public async Task> 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(); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/LeaveRequests/GetLeaveRequestsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/LeaveRequests/GetLeaveRequestsQuery.cs new file mode 100644 index 00000000..34bfb24a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/LeaveRequests/GetLeaveRequestsQuery.cs @@ -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>; +public record GetLeaveRequestsByShopQuery(Guid ShopId) : IRequest>; + +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> +{ + private readonly ILeaveRequestRepository _repo; + + public GetLeaveRequestsByStaffHandler(ILeaveRequestRepository repo) => _repo = repo; + + public async Task> 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> +{ + private readonly ILeaveRequestRepository _repo; + + public GetLeaveRequestsByShopHandler(ILeaveRequestRepository repo) => _repo = repo; + + public async Task> 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(); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/AttendanceController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/AttendanceController.cs new file mode 100644 index 00000000..4490cd5f --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/AttendanceController.cs @@ -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 _logger; + + public AttendanceController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get attendance records by staff and month. + /// VI: Lay ban ghi cham cong theo nhan vien va thang. + /// + [HttpGet("staff/{staffId:guid}")] + public async Task 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 } }); + } + + /// + /// EN: Get attendance records by shop and month. + /// VI: Lay ban ghi cham cong theo cua hang va thang. + /// + [HttpGet("shop/{shopId:guid}")] + public async Task 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 } }); + } + + /// + /// EN: Check in for a staff member. + /// VI: Cham cong vao cho nhan vien. + /// + [HttpPost("check-in")] + public async Task 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 }); + } + } + + /// + /// EN: Check out for a staff member. + /// VI: Cham cong ra cho nhan vien. + /// + [HttpPost("check-out")] + public async Task 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); diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/LeaveRequestsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/LeaveRequestsController.cs new file mode 100644 index 00000000..ec5f061d --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/LeaveRequestsController.cs @@ -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 _logger; + + public LeaveRequestsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get leave requests by staff. + /// VI: Lay yeu cau nghi phep theo nhan vien. + /// + [HttpGet("staff/{staffId:guid}")] + public async Task GetByStaff(Guid staffId, CancellationToken ct = default) + { + var result = await _mediator.Send(new GetLeaveRequestsByStaffQuery(staffId), ct); + return Ok(new { success = true, data = new { items = result } }); + } + + /// + /// EN: Get leave requests by shop (for manager). + /// VI: Lay yeu cau nghi phep theo cua hang (cho quan ly). + /// + [HttpGet("shop/{shopId:guid}")] + public async Task GetByShop(Guid shopId, CancellationToken ct = default) + { + var result = await _mediator.Send(new GetLeaveRequestsByShopQuery(shopId), ct); + return Ok(new { success = true, data = new { items = result } }); + } + + /// + /// EN: Create a leave request. + /// VI: Tao yeu cau nghi phep. + /// + [HttpPost] + public async Task 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 }); + } + } + + /// + /// EN: Approve a leave request. + /// VI: Duyet yeu cau nghi phep. + /// + [HttpPost("{id:guid}/approve")] + public async Task 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 }); + } + + /// + /// EN: Reject a leave request. + /// VI: Tu choi yeu cau nghi phep. + /// + [HttpPost("{id:guid}/reject")] + public async Task 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); diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/AttendanceRecord.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/AttendanceRecord.cs new file mode 100644 index 00000000..5842aef4 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/AttendanceRecord.cs @@ -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; + +/// +/// EN: Daily attendance record for a staff member. +/// VI: Ban ghi cham cong hang ngay cho nhan vien. +/// +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"; } + + /// + /// EN: Create attendance record with check-in. + /// VI: Tao ban ghi cham cong voi check-in. + /// + 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; + } + + /// + /// EN: Record check-out and calculate hours worked. + /// VI: Ghi nhan check-out va tinh gio lam. + /// + 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; + } + + /// + /// EN: Mark as absent (admin action). + /// VI: Danh dau vang mat (thao tac admin). + /// + public void MarkAbsent(string? notes = null) + { + _status = "Absent"; + _notes = notes; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Mark as late. + /// VI: Danh dau di muon. + /// + public void MarkLate() + { + _status = "Late"; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/IAttendanceRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/IAttendanceRepository.cs new file mode 100644 index 00000000..b3a19b90 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/AttendanceAggregate/IAttendanceRepository.cs @@ -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 +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task GetTodayRecordAsync(Guid staffId, CancellationToken ct = default); + Task> GetByStaffAndMonthAsync(Guid staffId, int month, int year, CancellationToken ct = default); + Task> GetByShopAndMonthAsync(Guid shopId, int month, int year, CancellationToken ct = default); + AttendanceRecord Add(AttendanceRecord record); + void Update(AttendanceRecord record); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/ILeaveRequestRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/ILeaveRequestRepository.cs new file mode 100644 index 00000000..ccb5c47a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/ILeaveRequestRepository.cs @@ -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 +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetByStaffAsync(Guid staffId, CancellationToken ct = default); + Task> GetByShopAsync(Guid shopId, CancellationToken ct = default); + LeaveRequest Add(LeaveRequest request); + void Update(LeaveRequest request); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/LeaveRequest.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/LeaveRequest.cs new file mode 100644 index 00000000..be65979e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/LeaveRequestAggregate/LeaveRequest.cs @@ -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; + +/// +/// EN: Leave request from a staff member. +/// VI: Yeu cau nghi phep tu nhan vien. +/// +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; + + /// + /// EN: Number of days requested. + /// VI: So ngay nghi phep yeu cau. + /// + public int Days => (_endDate - _startDate).Days + 1; + + protected LeaveRequest() { _leaveType = "Annual"; _status = "Pending"; } + + /// + /// EN: Create a new leave request. + /// VI: Tao yeu cau nghi phep moi. + /// + 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 + }; + } + + /// + /// EN: Approve the leave request. + /// VI: Duyet yeu cau nghi phep. + /// + 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; + } + + /// + /// EN: Reject the leave request. + /// VI: Tu choi yeu cau nghi phep. + /// + 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; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs index bd3b4eff..305c4383 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/AttendanceRecordEntityTypeConfiguration.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/AttendanceRecordEntityTypeConfiguration.cs new file mode 100644 index 00000000..12ca7f07 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/AttendanceRecordEntityTypeConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("attendance_records"); + builder.HasKey(a => a.Id); + + builder.Property("_staffId").HasColumnName("staff_id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_date").HasColumnName("date").IsRequired(); + builder.Property("_checkIn").HasColumnName("check_in"); + builder.Property("_checkOut").HasColumnName("check_out"); + builder.Property("_hoursWorked").HasColumnName("hours_worked").HasPrecision(5, 2); + builder.Property("_status").HasColumnName("status").HasMaxLength(20).IsRequired(); + builder.Property("_notes").HasColumnName("notes").HasMaxLength(500); + builder.Property("_createdAt").HasColumnName("created_at").IsRequired(); + builder.Property("_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"); + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/LeaveRequestEntityTypeConfiguration.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/LeaveRequestEntityTypeConfiguration.cs new file mode 100644 index 00000000..4e41997c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/LeaveRequestEntityTypeConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leave_requests"); + builder.HasKey(l => l.Id); + + builder.Property("_staffId").HasColumnName("staff_id").IsRequired(); + builder.Property("_shopId").HasColumnName("shop_id").IsRequired(); + builder.Property("_leaveType").HasColumnName("leave_type").HasMaxLength(20).IsRequired(); + builder.Property("_startDate").HasColumnName("start_date").IsRequired(); + builder.Property("_endDate").HasColumnName("end_date").IsRequired(); + builder.Property("_reason").HasColumnName("reason").HasMaxLength(500); + builder.Property("_status").HasColumnName("status").HasMaxLength(20).IsRequired(); + builder.Property("_approvedBy").HasColumnName("approved_by"); + builder.Property("_approvedAt").HasColumnName("approved_at"); + builder.Property("_rejectionReason").HasColumnName("rejection_reason").HasMaxLength(500); + builder.Property("_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"); + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs index 64850771..6c2e40b4 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs @@ -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 /// public DbSet DeviceTokens => Set(); + /// + /// EN: Attendance records table. + /// VI: Bang cham cong. + /// + public DbSet AttendanceRecords => Set(); + + /// + /// EN: Leave requests table. + /// VI: Bang yeu cau nghi phep. + /// + public DbSet LeaveRequests => Set(); + #endregion /// diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs new file mode 100644 index 00000000..954b43c5 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/AttendanceRepository.cs @@ -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 GetByIdAsync(Guid id, CancellationToken ct = default) + => await _context.AttendanceRecords.FirstOrDefaultAsync(a => a.Id == id, ct); + + public async Task 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> 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> 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; +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/LeaveRequestRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/LeaveRequestRepository.cs new file mode 100644 index 00000000..2a96d567 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/LeaveRequestRepository.cs @@ -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 GetByIdAsync(Guid id, CancellationToken ct = default) + => await _context.LeaveRequests.FirstOrDefaultAsync(l => l.Id == id, ct); + + public async Task> GetByStaffAsync(Guid staffId, CancellationToken ct = default) + => await _context.LeaveRequests + .Where(l => l.StaffId == staffId) + .OrderByDescending(l => l.CreatedAt) + .ToListAsync(ct); + + public async Task> 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; +}