262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { X, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
|
|
import { getCompanyAuditLog, getGlobalAuditLog } from "@/lib/api/provisioning";
|
|
|
|
/**
|
|
* 회사 관리 감사 로그 드로어. 오른쪽에서 슬라이드.
|
|
* companyCode 를 주면 특정 회사, 안 주면 전체.
|
|
*/
|
|
const ACTION_META: Record<string, { label: string; color: string }> = {
|
|
COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" },
|
|
COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" },
|
|
COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" },
|
|
COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" },
|
|
COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" },
|
|
ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" },
|
|
TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" },
|
|
};
|
|
|
|
export default function AuditLogDrawer({
|
|
companyCode,
|
|
title,
|
|
onClose,
|
|
}: {
|
|
companyCode?: string;
|
|
title?: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const [mounted, setMounted] = useState(false);
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: companyCode ? ["company-audit-log", companyCode] : ["global-audit-log"],
|
|
queryFn: () => (companyCode ? getCompanyAuditLog(companyCode, 1, 100) : getGlobalAuditLog(1, 100)),
|
|
staleTime: 5_000,
|
|
});
|
|
|
|
const rows: Record<string, any>[] = data?.data || [];
|
|
|
|
if (!mounted) return null;
|
|
|
|
const drawer = (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 95,
|
|
background: "rgba(6, 5, 14, 0.45)",
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
animation: "drawerOverlayIn 0.16s ease-out",
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes drawerOverlayIn { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes drawerSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
`}</style>
|
|
<div
|
|
style={{
|
|
width: "min(620px, 100%)",
|
|
height: "100%",
|
|
background: "var(--v5-bg)",
|
|
borderLeft: "1px solid var(--v5-border)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
animation: "drawerSlideIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
{/* header */}
|
|
<div style={{
|
|
padding: "0.95rem 1.2rem",
|
|
background: "var(--v5-surface-solid)",
|
|
borderBottom: "1px solid var(--v5-border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
}}>
|
|
<FileText size={18} strokeWidth={1.75} color="var(--v5-primary)" />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: "1rem", fontWeight: 800, letterSpacing: "-0.01em" }}>
|
|
{title || (companyCode ? `감사 로그 · ${companyCode}` : "전체 감사 로그")}
|
|
</div>
|
|
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", marginTop: 2, fontFamily: "var(--v5-font-mono)" }}>
|
|
최근 {rows.length} 건 · 전체 {data?.total ?? 0}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
width: 30, height: 30, borderRadius: 7,
|
|
border: "1px solid var(--v5-border)",
|
|
background: "transparent",
|
|
color: "var(--v5-text-sec)",
|
|
cursor: "pointer",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
}}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* body */}
|
|
<div style={{ flex: 1, overflowY: "auto", padding: "0.8rem 1rem" }}>
|
|
{isLoading && (
|
|
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.85rem" }}>
|
|
조회 중...
|
|
</div>
|
|
)}
|
|
{!isLoading && rows.length === 0 && (
|
|
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-muted)", fontSize: "0.85rem" }}>
|
|
감사 로그가 없습니다.
|
|
</div>
|
|
)}
|
|
{!isLoading && rows.length > 0 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
|
{rows.map((r) => {
|
|
const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" };
|
|
const ok = r.success !== false && r.success !== "false";
|
|
return (
|
|
<div
|
|
key={r.id}
|
|
style={{
|
|
border: "1px solid var(--v5-border)",
|
|
borderLeft: `3px solid ${meta.color}`,
|
|
borderRadius: 7,
|
|
background: "var(--v5-surface-solid)",
|
|
padding: "0.55rem 0.8rem",
|
|
display: "grid",
|
|
gridTemplateColumns: "16px minmax(0, 1fr) auto",
|
|
gap: 10,
|
|
alignItems: "flex-start",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div style={{ paddingTop: 3 }}>
|
|
{ok ? (
|
|
<CheckCircle2 size={13} color="rgb(var(--v5-green-rgb))" strokeWidth={2} />
|
|
) : (
|
|
<AlertTriangle size={13} color="var(--v5-red)" strokeWidth={1.75} />
|
|
)}
|
|
</div>
|
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: meta.color }}>{meta.label}</span>
|
|
{!companyCode && (
|
|
<span style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)" }}>
|
|
{r.company_code}
|
|
</span>
|
|
)}
|
|
{r.target && (
|
|
<span style={{
|
|
fontSize: "0.7rem",
|
|
padding: "1px 6px",
|
|
borderRadius: 3,
|
|
background: "var(--v5-bg-subtle)",
|
|
color: "var(--v5-text-sec)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
maxWidth: "100%",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}>
|
|
{r.target}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{r.details && Object.keys(r.details).length > 0 && (
|
|
<div style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-sec)",
|
|
marginTop: 4,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
lineHeight: 1.5,
|
|
wordBreak: "break-all",
|
|
overflowWrap: "anywhere",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
}}>
|
|
{formatDetails(r.details).map((line, i) => (
|
|
<div key={i}>{line}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{r.error_message && (
|
|
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", marginTop: 3, wordBreak: "break-all", overflowWrap: "anywhere" }}>
|
|
{r.error_message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-muted)", fontFamily: "var(--v5-font-mono)", whiteSpace: "nowrap" }}>
|
|
<div>{formatTime(r.created_at)}</div>
|
|
<div style={{ textAlign: "right", marginTop: 2 }}>{r.actor_user_id || "—"}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
return createPortal(drawer, document.body);
|
|
}
|
|
|
|
/**
|
|
* 감사 로그 details JSONB 를 사람이 읽기 좋은 key-line 배열로 변환.
|
|
* - 긴 배열은 "N개 (처음2개 외 X개)" 로 요약
|
|
* - 객체는 짧은 JSON preview
|
|
* - 값 자체가 매우 긴 문자열은 80자로 자르고 … 추가
|
|
*/
|
|
function formatDetails(details: any): string[] {
|
|
if (!details || typeof details !== "object") return [String(details)];
|
|
const lines: string[] = [];
|
|
try {
|
|
for (const [k, v] of Object.entries(details)) {
|
|
if (v === null || v === undefined || v === "") continue;
|
|
if (Array.isArray(v)) {
|
|
if (v.length === 0) continue;
|
|
if (v.length <= 3) {
|
|
lines.push(`${k}: [${v.join(", ")}]`);
|
|
} else {
|
|
const head = v.slice(0, 2).join(", ");
|
|
lines.push(`${k}: ${v.length}개 (${head} 외 ${v.length - 2}개)`);
|
|
}
|
|
} else if (typeof v === "object") {
|
|
const s = JSON.stringify(v);
|
|
lines.push(`${k}: ${s.length > 80 ? s.slice(0, 80) + "…" : s}`);
|
|
} else {
|
|
const s = String(v);
|
|
lines.push(`${k}: ${s.length > 120 ? s.slice(0, 120) + "…" : s}`);
|
|
}
|
|
}
|
|
} catch {
|
|
return [String(details)];
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function formatTime(v: any): string {
|
|
if (v == null) return "—";
|
|
try {
|
|
let d: Date;
|
|
if (typeof v === "number") d = new Date(v);
|
|
else {
|
|
const s = String(v);
|
|
d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
|
|
}
|
|
if (isNaN(d.getTime())) return String(v);
|
|
return d.toISOString().replace("T", " ").slice(0, 19);
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|