"use client"; import React, { useState, useEffect, useCallback } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { Layout, Monitor, GitBranch, User, Database, Shield, Search, ChevronLeft, ChevronRight, Clock, Filter, Building2, Hash, FileText, RefreshCw, Check, ChevronsUpDown, } from "lucide-react"; import { getAuditLogs, getAuditLogStats, getAuditLogUsers, AuditLogEntry, AuditLogFilters, AuditLogStats, AuditLogUser, } from "@/lib/api/auditLog"; import { getCompanyList } from "@/lib/api/company"; import { useAuth } from "@/hooks/useAuth"; import { Company } from "@/types/company"; const RESOURCE_TYPE_CONFIG: Record< string, { label: string; icon: React.ElementType; color: string } > = { MENU: { label: "메뉴", icon: Layout, color: "bg-blue-100 text-blue-700" }, SCREEN: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" }, SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" }, FLOW: { label: "플로우", icon: GitBranch, color: "bg-green-100 text-green-700" }, FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-green-100 text-green-700" }, USER: { label: "사용자", icon: User, color: "bg-orange-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" }, PERMISSION: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" }, COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, DATA: { label: "데이터", icon: Database, color: "bg-gray-100 text-gray-700" }, TABLE: { label: "테이블", icon: Database, color: "bg-gray-100 text-gray-700" }, NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" }, BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" }, }; const ACTION_CONFIG: Record = { CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" }, UPDATE: { label: "수정", color: "bg-blue-100 text-blue-700" }, DELETE: { label: "삭제", color: "bg-red-100 text-red-700" }, COPY: { label: "복사", color: "bg-violet-100 text-violet-700" }, LOGIN: { label: "로그인", color: "bg-gray-100 text-gray-700" }, STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" }, BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" }, BATCH_UPDATE: { label: "배치수정", color: "bg-blue-100 text-blue-700" }, BATCH_DELETE: { label: "배치삭제", color: "bg-red-100 text-red-700" }, }; function formatDateTime(dateStr: string): string { const d = new Date(dateStr); return d.toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } function formatTime(dateStr: string): string { const d = new Date(dateStr); return d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", }); } const FIELD_NAME_MAP: Record = { status: "상태", menuUrl: "메뉴 URL", menu_url: "메뉴 URL", menuNameKor: "메뉴명", menu_name_kor: "메뉴명", menuNameEng: "메뉴명(영)", menu_name_eng: "메뉴명(영)", screenName: "화면명", screen_name: "화면명", tableName: "테이블명", table_name: "테이블명", description: "설명", isActive: "활성 여부", is_active: "활성 여부", userName: "사용자명", user_name: "사용자명", userId: "사용자 ID", user_id: "사용자 ID", deptName: "부서명", dept_name: "부서명", authName: "권한명", authCode: "권한코드", companyCode: "회사코드", company_code: "회사코드", company_name: "회사명", name: "이름", user_password: "비밀번호", prefix: "접두사", ruleName: "규칙명", stepName: "스텝명", stepOrder: "스텝 순서", sourceScreenId: "원본 화면 ID", targetCompanyCode: "대상 회사코드", mainScreenName: "메인 화면명", screenCode: "화면코드", menuObjid: "메뉴 ID", deleteReason: "삭제 사유", force: "강제 삭제", deletedMenus: "삭제된 메뉴", failedMenuIds: "실패한 메뉴", deletedCount: "삭제 건수", items: "항목 수", }; function formatFieldValue(value: unknown): string { if (value === null || value === undefined) return "(없음)"; if (typeof value === "boolean") return value ? "예" : "아니오"; if (Array.isArray(value)) return value.length > 0 ? `${value.length}건` : "(없음)"; if (typeof value === "object") return JSON.stringify(value); return String(value); } function renderChanges(changes: Record) { const before = (changes.before as Record) || {}; const after = (changes.after as Record) || {}; const fields = (changes.fields as string[]) || []; const allKeys = new Set([ ...Object.keys(before), ...Object.keys(after), ...fields, ]); if (allKeys.size === 0) return null; const rows = Array.from(allKeys) .filter((key) => key !== "deletedMenus" && key !== "failedMenuIds") .map((key) => ({ field: FIELD_NAME_MAP[key] || key, beforeVal: key in before ? formatFieldValue(before[key]) : null, afterVal: key in after ? formatFieldValue(after[key]) : null, isSensitive: fields.includes(key) && !(key in before) && !(key in after), })); const hasBefore = Object.keys(before).length > 0; const hasAfter = Object.keys(after).length > 0; return (
{hasBefore && ( )} {hasAfter && ( )} {rows.map((row, i) => ( {row.isSensitive ? ( ) : ( <> {hasBefore && ( )} {hasAfter && ( )} )} ))}
항목 변경 전 변경 후
{row.field} (보안 항목 - 값 비공개) {row.beforeVal !== null ? ( {row.beforeVal} ) : ( - )} {row.afterVal !== null ? ( {row.afterVal} ) : ( - )}
); } function formatDateGroup(dateStr: string): string { const d = new Date(dateStr); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (d.toDateString() === today.toDateString()) return "오늘"; if (d.toDateString() === yesterday.toDateString()) return "어제"; return d.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "short", }); } function groupByDate(entries: AuditLogEntry[]): Map { const groups = new Map(); for (const entry of entries) { const dateKey = new Date(entry.created_at).toDateString(); if (!groups.has(dateKey)) groups.set(dateKey, []); groups.get(dateKey)!.push(entry); } return groups; } export default function AuditLogPage() { const { user } = useAuth(); const isSuperAdmin = user?.companyCode === "*" || user?.company_code === "*"; const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [filters, setFilters] = useState({ page: 1, limit: 50, }); const [stats, setStats] = useState(null); const [selectedEntry, setSelectedEntry] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [userComboOpen, setUserComboOpen] = useState(false); const [companyComboOpen, setCompanyComboOpen] = useState(false); const [companies, setCompanies] = useState([]); const [auditUsers, setAuditUsers] = useState([]); const fetchCompanies = useCallback(async () => { if (!isSuperAdmin) return; try { const list = await getCompanyList({ status: "Y" }); setCompanies(list); } catch (error) { console.error("회사 목록 조회 실패:", error); } }, [isSuperAdmin]); const fetchAuditUsers = useCallback(async () => { try { const result = await getAuditLogUsers(filters.companyCode); if (result.success) { setAuditUsers(result.data); } } catch (error) { console.error("사용자 목록 조회 실패:", error); } }, [filters.companyCode]); const fetchLogs = useCallback(async () => { setLoading(true); try { const result = await getAuditLogs(filters); if (result.success) { setEntries(result.data); setTotal(result.total); } } catch (error) { console.error("감사 로그 조회 실패:", error); } finally { setLoading(false); } }, [filters]); const fetchStats = useCallback(async () => { try { const result = await getAuditLogStats(filters.companyCode, 30); if (result.success) { setStats(result.data); } } catch (error) { console.error("통계 조회 실패:", error); } }, [filters.companyCode]); useEffect(() => { fetchCompanies(); }, [fetchCompanies]); useEffect(() => { fetchAuditUsers(); }, [fetchAuditUsers]); useEffect(() => { fetchLogs(); }, [fetchLogs]); useEffect(() => { fetchStats(); }, [fetchStats]); const totalPages = Math.ceil(total / (filters.limit || 50)); const dateGroups = groupByDate(entries); const handleFilterChange = (key: keyof AuditLogFilters, value: string) => { const updates: Partial = { [key]: value || undefined, page: 1 }; if (key === "companyCode") { updates.userId = undefined; } setFilters((prev) => ({ ...prev, ...updates })); }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); fetchLogs(); }; const openDetail = (entry: AuditLogEntry) => { setSelectedEntry(entry); setDetailOpen(true); }; return (

통합 변경 이력

시스템 전체 변경 사항을 추적합니다

{stats && (

최근 30일 총 변경

{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}건

리소스 유형

{stats.resourceTypeCounts.length}종

활동 사용자

{stats.topUsers.length}명

오늘 변경

{( stats.dailyCounts.find( (d) => new Date(d.date).toDateString() === new Date().toDateString() )?.count || 0 ).toLocaleString()}건

)}
handleFilterChange("search", e.target.value)} className="h-9 pl-8 text-sm" />
{isSuperAdmin && (
회사를 찾을 수 없습니다 { handleFilterChange("companyCode", ""); setCompanyComboOpen(false); }} className="text-xs" > 전체 회사 {companies.map((company) => ( { handleFilterChange( "companyCode", filters.companyCode === company.company_code ? "" : company.company_code ); setCompanyComboOpen(false); }} className="text-xs" >
{company.company_name} {company.company_code}
))}
)}
사용자를 찾을 수 없습니다 { handleFilterChange("userId", ""); setUserComboOpen(false); }} className="text-xs" > 전체 {auditUsers.map((u) => ( { handleFilterChange( "userId", filters.userId === u.user_id ? "" : u.user_id ); setUserComboOpen(false); }} className="text-xs" >
{u.user_name} {u.user_id} ({u.count}건)
))}
handleFilterChange("dateFrom", e.target.value)} className="h-9 text-xs" />
handleFilterChange("dateTo", e.target.value)} className="h-9 text-xs" />
변경 이력 ({total.toLocaleString()}건)
{filters.page || 1} / {totalPages || 1}
{loading ? (
) : entries.length === 0 ? (

변경 이력이 없습니다

) : (
{Array.from(dateGroups.entries()).map(([dateKey, items]) => (
{formatDateGroup(items[0].created_at)} {items.length}건
{items.map((entry) => { const rtConfig = RESOURCE_TYPE_CONFIG[entry.resource_type] || RESOURCE_TYPE_CONFIG.DATA; const actConfig = ACTION_CONFIG[entry.action] || ACTION_CONFIG.UPDATE; const IconComp = rtConfig.icon; return (
openDetail(entry)} >
{entry.user_name || entry.user_id} {rtConfig.label} {actConfig.label} {entry.company_code && entry.company_code !== "*" && ( [{entry.company_code}] )}

{entry.summary || entry.resource_name || "-"}

{formatTime(entry.created_at)}
); })}
))}
)}
변경 상세 정보 {selectedEntry && formatDateTime(selectedEntry.created_at)} {selectedEntry && (

{selectedEntry.user_name || selectedEntry.user_id}

{selectedEntry.company_code}

{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]?.label || selectedEntry.resource_type}

{ACTION_CONFIG[selectedEntry.action]?.label || selectedEntry.action}

{selectedEntry.resource_name && (

{selectedEntry.resource_name}

)} {selectedEntry.table_name && (

{selectedEntry.table_name}

)} {selectedEntry.ip_address && (

{selectedEntry.ip_address}

)}
{selectedEntry.summary && (

{selectedEntry.summary}

)} {selectedEntry.changes && (
{renderChanges( selectedEntry.changes as Record )}
)} {selectedEntry.request_path && (

{selectedEntry.request_path}

)}
)}
); }