183 lines
8.9 KiB
TypeScript
183 lines
8.9 KiB
TypeScript
import { Clock3, Globe2, User2, X } from "lucide-react";
|
|
|
|
import type { WorkspaceLogDetail } from "../../api/logs";
|
|
import { API_BASE_URL } from "../../config/constants";
|
|
import { useTranslation } from "../../hooks/useTranslation";
|
|
import { Button } from "../ui/button";
|
|
|
|
const resolveImageUrl = (value?: string | null) => {
|
|
if (!value) return null;
|
|
if (/^https?:\/\//i.test(value)) return value;
|
|
return `${API_BASE_URL.replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
|
|
};
|
|
|
|
export function LogDetailsPanel({
|
|
open,
|
|
log,
|
|
isLoading,
|
|
onClose,
|
|
}: {
|
|
open: boolean;
|
|
log: WorkspaceLogDetail | null;
|
|
isLoading: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t, lang } = useTranslation();
|
|
|
|
if (!open) {
|
|
return null;
|
|
}
|
|
|
|
const formatTimestamp = (value?: string | null) => {
|
|
if (!value) return "-";
|
|
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(new Date(value));
|
|
};
|
|
|
|
const actorName = log?.actor?.full_name || t.logs?.unknownActor || "Unknown actor";
|
|
const actorAvatar = resolveImageUrl(log?.actor?.profile_picture);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[80] flex items-end bg-slate-950/40 backdrop-blur-[2px] lg:items-stretch lg:justify-end">
|
|
<button type="button" className="absolute inset-0 cursor-pointer" onClick={onClose} aria-label="Close log details" />
|
|
<aside className="relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none lg:border-l lg:border-slate-800">
|
|
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
|
{t.logs?.detailsTitle || "Activity details"}
|
|
</h2>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
{t.logs?.detailsHint || "Review the exact values that changed in this action."}
|
|
</p>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-5">
|
|
{isLoading ? (
|
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
|
|
{t.logs?.loadingDetails || "Loading details..."}
|
|
</div>
|
|
) : !log ? (
|
|
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
|
{t.logs?.selectLogHint || "Select a log entry to see its details."}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-5">
|
|
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-slate-200 text-base font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
|
{actorAvatar ? (
|
|
<img src={actorAvatar} alt={actorName} className="h-full w-full object-cover" />
|
|
) : (
|
|
<span>{actorName.trim().charAt(0).toUpperCase()}</span>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-base font-semibold text-slate-900 dark:text-white">{actorName}</p>
|
|
<div className="mt-2 grid gap-2 text-sm text-slate-500 dark:text-slate-400">
|
|
<div className="flex items-center gap-2">
|
|
<User2 className="h-4 w-4" />
|
|
<span>{log.actor?.mobile || "-"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Clock3 className="h-4 w-4" />
|
|
<span>{formatTimestamp(log.timestamp)}</span>
|
|
</div>
|
|
{log.remote_addr ? (
|
|
<div className="flex items-center gap-2">
|
|
<Globe2 className="h-4 w-4" />
|
|
<span>{log.remote_addr}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
|
{t.logs?.target || "Target"}
|
|
</p>
|
|
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">{log.target.name}</p>
|
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
{t.logs?.sections?.[log.section] || log.section}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
|
{t.logs?.event || "Event"}
|
|
</p>
|
|
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
|
|
{t.logs?.events?.[log.event] || log.event}
|
|
</p>
|
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{log.audit_action}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
|
{t.logs?.changesTitle || "Changes"}
|
|
</h3>
|
|
</div>
|
|
{log.changes.length ? (
|
|
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
|
{log.changes.map((change, index) => (
|
|
<div key={`${change.field}-${index}`} className="grid gap-3 px-5 py-4 md:grid-cols-[1.2fr_1fr_1fr]">
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">{change.label}</p>
|
|
{/* <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{change.summary}</p> */}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
|
{t.logs?.previousValue || "Previous"}
|
|
</p>
|
|
<p className="mt-1 break-words text-sm text-slate-700 dark:text-slate-300">
|
|
{change.old_value || "-"}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
|
{t.logs?.currentValue || "Current"}
|
|
</p>
|
|
<p className="mt-1 break-words text-sm text-slate-700 dark:text-slate-300">
|
|
{change.new_value || "-"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
|
|
{t.logs?.noDetails || "No field-level details are available for this activity."}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{log.serialized_snapshot ? (
|
|
<details className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
<summary className="cursor-pointer text-sm font-semibold text-slate-900 dark:text-white">
|
|
{t.logs?.snapshot || "Serialized snapshot"}
|
|
</summary>
|
|
<pre className="mt-3 overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs text-slate-100" dir="ltr">
|
|
{JSON.stringify(log.serialized_snapshot, null, 2)}
|
|
</pre>
|
|
</details>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|