diff --git a/src/Meezi.API/Controllers/AuditController.cs b/src/Meezi.API/Controllers/AuditController.cs index da05c02..3909a20 100644 --- a/src/Meezi.API/Controllers/AuditController.cs +++ b/src/Meezi.API/Controllers/AuditController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Audit; using Meezi.Core.Authorization; +using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Meezi.Shared; @@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase var total = await query.CountAsync(ct); - var items = await query + var rows = await query .OrderByDescending(x => x.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) - .Select(x => new AuditLogDto( - x.Id, - x.Category, - x.Action, - x.EntityType, - x.EntityId, - x.BranchId, - x.ActorId, - x.ActorName, - x.ActorRole, - x.Summary, - x.DetailsJson, - x.CreatedAt)) + .Select(x => new + { + x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId, + x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt + }) .ToListAsync(ct); + // Resolve the actor's CURRENT full name + role from the employee record. + // This fixes historical rows (where ActorName was never stored) and keeps + // names current. IgnoreQueryFilters so we still name soft-deleted staff. + var actorIds = rows + .Where(r => !string.IsNullOrEmpty(r.ActorId)) + .Select(r => r.ActorId!) + .Distinct() + .ToList(); + + var employees = actorIds.Count == 0 + ? new Dictionary() + : (await _db.Employees + .IgnoreQueryFilters() + .AsNoTracking() + .Where(e => e.CafeId == cafeId && actorIds.Contains(e.Id)) + .Select(e => new { e.Id, e.Name, e.Role }) + .ToListAsync(ct)) + .ToDictionary(e => e.Id, e => (e.Name, e.Role)); + + var items = rows.Select(r => + { + string? name = r.ActorName; + string? role = r.ActorRole; + if (!string.IsNullOrEmpty(r.ActorId) && employees.TryGetValue(r.ActorId, out var emp)) + { + name = emp.Name; // prefer the live employee name + role ??= emp.Role.ToString(); + } + return new AuditLogDto( + r.Id, r.Category, r.Action, r.EntityType, r.EntityId, r.BranchId, + r.ActorId, name, role, r.Summary, r.DetailsJson, r.CreatedAt); + }).ToList(); + return Ok(new PagedApiResponse(true, items, new PagedMeta(total, page, pageSize))); } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 59147fb..00463a1 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -1,6 +1,7 @@ { "common": { "save": "حفظ", + "close": "إغلاق", "cancel": "إلغاء", "confirm": "تأكيد", "delete": "حذف", @@ -462,6 +463,9 @@ "addEmployee": "إضافة موظف", "noEmployees": "لا يوجد موظفون بعد.", "employeeCreated": "تمت إضافة الموظف", + "employeeDetails": "تفاصيل الموظف", + "employeeNotFound": "هذا المستخدم لم يعد نشطًا.", + "openInHr": "فتح في الموارد البشرية", "save": "حفظ", "cancel": "إلغاء", "fields": { @@ -648,6 +652,7 @@ "colSummary": "الوصف", "details": "التفاصيل", "systemActor": "النظام", + "unknownActor": "مستخدم غير معروف", "prevPage": "السابق", "nextPage": "التالي" } diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 26ade1a..cf5bb9c 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -1,6 +1,7 @@ { "common": { "save": "Save", + "close": "Close", "cancel": "Cancel", "confirm": "Confirm", "delete": "Delete", @@ -481,6 +482,9 @@ "addEmployee": "Add employee", "noEmployees": "No employees yet.", "employeeCreated": "Employee added", + "employeeDetails": "Employee details", + "employeeNotFound": "This user is no longer active.", + "openInHr": "Open in HR", "save": "Save", "cancel": "Cancel", "fields": { @@ -667,6 +671,7 @@ "colSummary": "Summary", "details": "Details", "systemActor": "System", + "unknownActor": "Unknown user", "prevPage": "Previous", "nextPage": "Next" } diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 36f0fe1..d103f22 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -1,6 +1,7 @@ { "common": { "save": "ذخیره", + "close": "بستن", "cancel": "انصراف", "confirm": "تأیید", "delete": "حذف", @@ -481,6 +482,9 @@ "addEmployee": "افزودن کارمند", "noEmployees": "هنوز کارمندی ثبت نشده است.", "employeeCreated": "کارمند اضافه شد", + "employeeDetails": "مشخصات کارمند", + "employeeNotFound": "این کاربر دیگر فعال نیست.", + "openInHr": "مشاهده در منابع انسانی", "save": "ذخیره", "cancel": "انصراف", "fields": { @@ -667,6 +671,7 @@ "colSummary": "شرح", "details": "جزئیات", "systemActor": "سیستم", + "unknownActor": "کاربر نامشخص", "prevPage": "قبلی", "nextPage": "بعدی" } diff --git a/web/dashboard/src/components/hr/employee-details-dialog.tsx b/web/dashboard/src/components/hr/employee-details-dialog.tsx new file mode 100644 index 0000000..906206a --- /dev/null +++ b/web/dashboard/src/components/hr/employee-details-dialog.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { X, Loader2, Phone, BadgeCheck, Wallet } from "lucide-react"; +import { useRouter } from "@/i18n/routing"; +import { apiGet } from "@/lib/api/client"; +import { formatCurrency } from "@/lib/format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +type EmployeeSummary = { + id: string; + name: string; + phone: string; + role: string; + baseSalary: number; +}; + +const KNOWN_ROLES = ["Owner", "Manager", "Cashier", "Waiter", "Chef", "Delivery"]; + +/** + * Lightweight modal showing one employee's details, opened by clicking an actor + * in the audit log. Fetches GET /employees/{id}; if the staff member was removed + * it shows a "no longer active" note with the name we already have. + */ +export function EmployeeDetailsDialog({ + cafeId, + employeeId, + fallbackName, + onClose, +}: { + cafeId: string; + employeeId: string | null; + fallbackName?: string | null; + onClose: () => void; +}) { + const t = useTranslations("hr"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const open = !!employeeId; + + // Close on Escape. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["employee", cafeId, employeeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/employees/${employeeId}`), + enabled: open, + }); + + if (!open) return null; + + const roleLabel = (r: string) => (KNOWN_ROLES.includes(r) ? t(`roles.${r}`) : r); + const notFound = isError || (!isLoading && !data); + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+

{t("employeeDetails")}

+ +
+ + {isLoading ? ( +
+ +
+ ) : notFound ? ( +
+

{fallbackName ?? employeeId}

+

{t("employeeNotFound")}

+
+ ) : ( +
+
+

{data!.name}

+ + + {roleLabel(data!.role)} + +
+
+

+ + {data!.phone} +

+ {data!.baseSalary > 0 ? ( +

+ + {formatCurrency(data!.baseSalary)} +

+ ) : null} +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/web/dashboard/src/components/reports/audit-logs-tab.tsx b/web/dashboard/src/components/reports/audit-logs-tab.tsx index 717439a..418e2a1 100644 --- a/web/dashboard/src/components/reports/audit-logs-tab.tsx +++ b/web/dashboard/src/components/reports/audit-logs-tab.tsx @@ -12,8 +12,11 @@ import { Button } from "@/components/ui/button"; import { JalaliDateField } from "@/components/ui/jalali-date-field"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent } from "@/components/ui/card"; +import { EmployeeDetailsDialog } from "@/components/hr/employee-details-dialog"; import { cn } from "@/lib/utils"; +const KNOWN_ROLES = ["Owner", "Manager", "Cashier", "Waiter", "Chef", "Delivery"]; + type Branch = { id: string; name: string }; type AuditLogRow = { @@ -43,10 +46,15 @@ function isoDaysAgoTehran(days: number): string { export function AuditLogsTab() { const t = useTranslations("reports.auditLog"); + const tHr = useTranslations("hr"); const locale = useLocale(); const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const cafeId = useAuthStore((s) => s.user?.cafeId); + // The actor whose details dialog is open. + const [actor, setActor] = useState<{ id: string; name?: string | null } | null>(null); + const roleLabel = (r: string) => (KNOWN_ROLES.includes(r) ? tHr(`roles.${r}`) : r); + const [category, setCategory] = useState(""); const [from, setFrom] = useState(() => isoDaysAgoTehran(7)); const [to, setTo] = useState(() => isoTodayTehran()); @@ -215,10 +223,22 @@ export function AuditLogsTab() { - {row.actorName ?? row.actorId ?? t("systemActor")} + {row.actorId ? ( + + ) : ( + {t("systemActor")} + )} {row.actorRole ? ( - ({row.actorRole}) + ({roleLabel(row.actorRole)}) ) : null} @@ -299,6 +319,13 @@ export function AuditLogsTab() { ) : null} + + setActor(null)} + /> ); }