feat(audit): show actor full name + role in logs, click to view details
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m39s

Logs showed the raw User ID (ActorName was almost never stored) and an English
role enum. Now:

- AuditController resolves each entry's actor to the employee's CURRENT full name
  and localized role at read time (joins Employees with IgnoreQueryFilters, so it
  also names soft-deleted staff and fixes all historical rows — no migration).
- The audit table renders "Full name (Role)" with the role localized (fa/en/ar);
  the name is a button that opens an employee-details dialog.
- New EmployeeDetailsDialog: fetches the employee and shows name, role, phone,
  base salary, and an "Open in HR" link; handles removed staff gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 11:24:06 +03:30
parent 6d71770f2e
commit 2a24798a59
6 changed files with 219 additions and 16 deletions
+40 -14
View File
@@ -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<string, (string Name, EmployeeRole Role)>()
: (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<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
}
}
+5
View File
@@ -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": "التالي"
}
+5
View File
@@ -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"
}
+5
View File
@@ -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": "بعدی"
}
@@ -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<EmployeeSummary>(`/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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-lg"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="mb-3 flex items-start justify-between gap-2">
<h2 className="text-base font-semibold">{t("employeeDetails")}</h2>
<button
type="button"
onClick={onClose}
aria-label={tCommon("close")}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : notFound ? (
<div className="space-y-2 py-2">
<p className="text-sm font-medium">{fallbackName ?? employeeId}</p>
<p className="text-sm text-muted-foreground">{t("employeeNotFound")}</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-lg font-semibold">{data!.name}</p>
<Badge variant="outline" className="gap-1">
<BadgeCheck className="size-3.5" />
{roleLabel(data!.role)}
</Badge>
</div>
<div className="space-y-1.5 text-sm">
<p className="flex items-center gap-2 text-muted-foreground">
<Phone className="size-3.5 shrink-0" />
<span dir="ltr" className="font-mono">{data!.phone}</span>
</p>
{data!.baseSalary > 0 ? (
<p className="flex items-center gap-2 text-muted-foreground">
<Wallet className="size-3.5 shrink-0" />
<span>{formatCurrency(data!.baseSalary)}</span>
</p>
) : null}
</div>
<div className="border-t border-border/80 pt-3">
<Button
variant="outline"
size="sm"
onClick={() => {
onClose();
router.push("/hr");
}}
>
{t("openInHr")}
</Button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -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<string>("");
const [from, setFrom] = useState<string>(() => isoDaysAgoTehran(7));
const [to, setTo] = useState<string>(() => isoTodayTehran());
@@ -215,10 +223,22 @@ export function AuditLogsTab() {
</span>
</td>
<td className="py-2.5">
{row.actorName ?? row.actorId ?? t("systemActor")}
{row.actorId ? (
<button
type="button"
onClick={() =>
setActor({ id: row.actorId!, name: row.actorName })
}
className="text-start font-medium text-[#0F6E56] hover:underline"
>
{row.actorName ?? t("unknownActor")}
</button>
) : (
<span>{t("systemActor")}</span>
)}
{row.actorRole ? (
<span className="ms-1.5 text-xs text-muted-foreground">
({row.actorRole})
({roleLabel(row.actorRole)})
</span>
) : null}
</td>
@@ -299,6 +319,13 @@ export function AuditLogsTab() {
) : null}
</CardContent>
</Card>
<EmployeeDetailsDialog
cafeId={cafeId}
employeeId={actor?.id ?? null}
fallbackName={actor?.name}
onClose={() => setActor(null)}
/>
</div>
);
}