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
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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "التالي"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user