- Integrated multi-language functionality across the audit log, system notices, collection management, and common code management components, enhancing accessibility for diverse users. - Updated UI elements to utilize translation keys, ensuring that all text is dynamically translated based on user preferences. - Improved error handling messages to be localized, providing a better user experience in case of issues. These changes significantly enhance the usability and internationalization of the management features, making the application more inclusive.
1169 lines
44 KiB
TypeScript
1169 lines
44 KiB
TypeScript
"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";
|
|
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
|
|
|
// 다국어 키 목록
|
|
const LANG_KEYS = [
|
|
"audit.title",
|
|
"audit.description",
|
|
"audit.button.refresh",
|
|
"audit.stats.totalChanges30d",
|
|
"audit.stats.resourceTypes",
|
|
"audit.stats.activeUsers",
|
|
"audit.stats.todayChanges",
|
|
"audit.stats.countSuffix",
|
|
"audit.stats.typeSuffix",
|
|
"audit.stats.userSuffix",
|
|
"audit.filter.searchLabel",
|
|
"audit.filter.searchPlaceholder",
|
|
"audit.filter.typeLabel",
|
|
"audit.filter.actionLabel",
|
|
"audit.filter.companyLabel",
|
|
"audit.filter.userLabel",
|
|
"audit.filter.dateFromLabel",
|
|
"audit.filter.dateToLabel",
|
|
"audit.filter.all",
|
|
"audit.filter.allCompanies",
|
|
"audit.filter.companySearchPlaceholder",
|
|
"audit.filter.companyNotFound",
|
|
"audit.filter.userSearchPlaceholder",
|
|
"audit.filter.userNotFound",
|
|
"audit.filter.apply",
|
|
"audit.list.title",
|
|
"audit.list.empty",
|
|
"audit.list.countSuffix",
|
|
"audit.detail.title",
|
|
"audit.detail.user",
|
|
"audit.detail.company",
|
|
"audit.detail.resourceType",
|
|
"audit.detail.action",
|
|
"audit.detail.resourceName",
|
|
"audit.detail.tableName",
|
|
"audit.detail.ipAddress",
|
|
"audit.detail.summary",
|
|
"audit.detail.changes",
|
|
"audit.detail.apiPath",
|
|
"audit.changes.field",
|
|
"audit.changes.before",
|
|
"audit.changes.after",
|
|
"audit.changes.securityHidden",
|
|
"audit.dateGroup.today",
|
|
"audit.dateGroup.yesterday",
|
|
"audit.fieldValue.empty",
|
|
"audit.fieldValue.yes",
|
|
"audit.fieldValue.no",
|
|
"audit.resource.menu",
|
|
"audit.resource.screen",
|
|
"audit.resource.screenLayout",
|
|
"audit.resource.flow",
|
|
"audit.resource.flowStep",
|
|
"audit.resource.nodeFlow",
|
|
"audit.resource.user",
|
|
"audit.resource.role",
|
|
"audit.resource.company",
|
|
"audit.resource.codeCategory",
|
|
"audit.resource.code",
|
|
"audit.resource.data",
|
|
"audit.resource.table",
|
|
"audit.resource.numberingRule",
|
|
"audit.action.create",
|
|
"audit.action.update",
|
|
"audit.action.delete",
|
|
"audit.action.copy",
|
|
"audit.action.login",
|
|
"audit.action.statusChange",
|
|
"audit.action.batchCreate",
|
|
"audit.action.batchUpdate",
|
|
"audit.action.batchDelete",
|
|
"audit.field.status",
|
|
"audit.field.menuUrl",
|
|
"audit.field.menuNameKor",
|
|
"audit.field.menuNameEng",
|
|
"audit.field.screenName",
|
|
"audit.field.tableName",
|
|
"audit.field.description",
|
|
"audit.field.isActive",
|
|
"audit.field.userName",
|
|
"audit.field.userId",
|
|
"audit.field.deptName",
|
|
"audit.field.authName",
|
|
"audit.field.authCode",
|
|
"audit.field.companyCode",
|
|
"audit.field.companyName",
|
|
"audit.field.name",
|
|
"audit.field.userPassword",
|
|
"audit.field.prefix",
|
|
"audit.field.ruleName",
|
|
"audit.field.stepName",
|
|
"audit.field.stepOrder",
|
|
"audit.field.sourceScreenId",
|
|
"audit.field.targetCompanyCode",
|
|
"audit.field.mainScreenName",
|
|
"audit.field.screenCode",
|
|
"audit.field.menuObjid",
|
|
"audit.field.deleteReason",
|
|
"audit.field.force",
|
|
"audit.field.deletedMenus",
|
|
"audit.field.failedMenuIds",
|
|
"audit.field.deletedCount",
|
|
"audit.field.items",
|
|
] as const;
|
|
|
|
// 한국어 기본 텍스트
|
|
const DEFAULT_TEXTS: Record<string, string> = {
|
|
"audit.title": "통합 변경 이력",
|
|
"audit.description": "시스템 전체 변경 사항을 추적합니다",
|
|
"audit.button.refresh": "새로고침",
|
|
"audit.stats.totalChanges30d": "최근 30일 총 변경",
|
|
"audit.stats.resourceTypes": "리소스 유형",
|
|
"audit.stats.activeUsers": "활동 사용자",
|
|
"audit.stats.todayChanges": "오늘 변경",
|
|
"audit.stats.countSuffix": "건",
|
|
"audit.stats.typeSuffix": "종",
|
|
"audit.stats.userSuffix": "명",
|
|
"audit.filter.searchLabel": "검색어",
|
|
"audit.filter.searchPlaceholder": "이름, 요약, 사용자...",
|
|
"audit.filter.typeLabel": "유형",
|
|
"audit.filter.actionLabel": "동작",
|
|
"audit.filter.companyLabel": "회사",
|
|
"audit.filter.userLabel": "사용자",
|
|
"audit.filter.dateFromLabel": "시작일",
|
|
"audit.filter.dateToLabel": "종료일",
|
|
"audit.filter.all": "전체",
|
|
"audit.filter.allCompanies": "전체 회사",
|
|
"audit.filter.companySearchPlaceholder": "회사 검색...",
|
|
"audit.filter.companyNotFound": "회사를 찾을 수 없습니다",
|
|
"audit.filter.userSearchPlaceholder": "사용자 검색...",
|
|
"audit.filter.userNotFound": "사용자를 찾을 수 없습니다",
|
|
"audit.filter.apply": "필터 적용",
|
|
"audit.list.title": "변경 이력",
|
|
"audit.list.empty": "변경 이력이 없습니다",
|
|
"audit.list.countSuffix": "건",
|
|
"audit.detail.title": "변경 상세 정보",
|
|
"audit.detail.user": "사용자",
|
|
"audit.detail.company": "회사",
|
|
"audit.detail.resourceType": "리소스 유형",
|
|
"audit.detail.action": "동작",
|
|
"audit.detail.resourceName": "리소스명",
|
|
"audit.detail.tableName": "테이블명",
|
|
"audit.detail.ipAddress": "IP 주소",
|
|
"audit.detail.summary": "요약",
|
|
"audit.detail.changes": "변경 내역",
|
|
"audit.detail.apiPath": "API 경로",
|
|
"audit.changes.field": "항목",
|
|
"audit.changes.before": "변경 전",
|
|
"audit.changes.after": "변경 후",
|
|
"audit.changes.securityHidden": "(보안 항목 - 값 비공개)",
|
|
"audit.dateGroup.today": "오늘",
|
|
"audit.dateGroup.yesterday": "어제",
|
|
"audit.fieldValue.empty": "(없음)",
|
|
"audit.fieldValue.yes": "예",
|
|
"audit.fieldValue.no": "아니오",
|
|
"audit.resource.menu": "메뉴",
|
|
"audit.resource.screen": "화면",
|
|
"audit.resource.screenLayout": "레이아웃",
|
|
"audit.resource.flow": "플로우",
|
|
"audit.resource.flowStep": "플로우 스텝",
|
|
"audit.resource.nodeFlow": "플로우 제어",
|
|
"audit.resource.user": "사용자",
|
|
"audit.resource.role": "권한",
|
|
"audit.resource.company": "회사",
|
|
"audit.resource.codeCategory": "코드 카테고리",
|
|
"audit.resource.code": "코드",
|
|
"audit.resource.data": "데이터",
|
|
"audit.resource.table": "테이블",
|
|
"audit.resource.numberingRule": "채번 규칙",
|
|
"audit.action.create": "생성",
|
|
"audit.action.update": "수정",
|
|
"audit.action.delete": "삭제",
|
|
"audit.action.copy": "복사",
|
|
"audit.action.login": "로그인",
|
|
"audit.action.statusChange": "상태변경",
|
|
"audit.action.batchCreate": "배치생성",
|
|
"audit.action.batchUpdate": "배치수정",
|
|
"audit.action.batchDelete": "배치삭제",
|
|
"audit.field.status": "상태",
|
|
"audit.field.menuUrl": "메뉴 URL",
|
|
"audit.field.menuNameKor": "메뉴명",
|
|
"audit.field.menuNameEng": "메뉴명(영)",
|
|
"audit.field.screenName": "화면명",
|
|
"audit.field.tableName": "테이블명",
|
|
"audit.field.description": "설명",
|
|
"audit.field.isActive": "활성 여부",
|
|
"audit.field.userName": "사용자명",
|
|
"audit.field.userId": "사용자 ID",
|
|
"audit.field.deptName": "부서명",
|
|
"audit.field.authName": "권한명",
|
|
"audit.field.authCode": "권한코드",
|
|
"audit.field.companyCode": "회사코드",
|
|
"audit.field.companyName": "회사명",
|
|
"audit.field.name": "이름",
|
|
"audit.field.userPassword": "비밀번호",
|
|
"audit.field.prefix": "접두사",
|
|
"audit.field.ruleName": "규칙명",
|
|
"audit.field.stepName": "스텝명",
|
|
"audit.field.stepOrder": "스텝 순서",
|
|
"audit.field.sourceScreenId": "원본 화면 ID",
|
|
"audit.field.targetCompanyCode": "대상 회사코드",
|
|
"audit.field.mainScreenName": "메인 화면명",
|
|
"audit.field.screenCode": "화면코드",
|
|
"audit.field.menuObjid": "메뉴 ID",
|
|
"audit.field.deleteReason": "삭제 사유",
|
|
"audit.field.force": "강제 삭제",
|
|
"audit.field.deletedMenus": "삭제된 메뉴",
|
|
"audit.field.failedMenuIds": "실패한 메뉴",
|
|
"audit.field.deletedCount": "삭제 건수",
|
|
"audit.field.items": "항목 수",
|
|
};
|
|
|
|
const RESOURCE_TYPE_CONFIG: Record<
|
|
string,
|
|
{ langKey: string; icon: React.ElementType; color: string }
|
|
> = {
|
|
MENU: { langKey: "audit.resource.menu", icon: Layout, color: "bg-primary/10 text-primary" },
|
|
SCREEN: { langKey: "audit.resource.screen", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
|
SCREEN_LAYOUT: { langKey: "audit.resource.screenLayout", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
|
FLOW: { langKey: "audit.resource.flow", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
|
FLOW_STEP: { langKey: "audit.resource.flowStep", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
|
NODE_FLOW: { langKey: "audit.resource.nodeFlow", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
|
USER: { langKey: "audit.resource.user", icon: User, color: "bg-amber-100 text-orange-700" },
|
|
ROLE: { langKey: "audit.resource.role", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
|
COMPANY: { langKey: "audit.resource.company", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
|
CODE_CATEGORY: { langKey: "audit.resource.codeCategory", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
|
CODE: { langKey: "audit.resource.code", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
|
DATA: { langKey: "audit.resource.data", icon: Database, color: "bg-muted text-foreground" },
|
|
TABLE: { langKey: "audit.resource.table", icon: Database, color: "bg-muted text-foreground" },
|
|
NUMBERING_RULE: { langKey: "audit.resource.numberingRule", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
|
};
|
|
|
|
const ACTION_CONFIG: Record<string, { langKey: string; color: string }> = {
|
|
CREATE: { langKey: "audit.action.create", color: "bg-emerald-100 text-emerald-700" },
|
|
UPDATE: { langKey: "audit.action.update", color: "bg-primary/10 text-primary" },
|
|
DELETE: { langKey: "audit.action.delete", color: "bg-destructive/10 text-destructive" },
|
|
COPY: { langKey: "audit.action.copy", color: "bg-violet-100 text-violet-700" },
|
|
LOGIN: { langKey: "audit.action.login", color: "bg-muted text-foreground" },
|
|
STATUS_CHANGE: { langKey: "audit.action.statusChange", color: "bg-amber-100 text-amber-700" },
|
|
BATCH_CREATE: { langKey: "audit.action.batchCreate", color: "bg-emerald-100 text-emerald-700" },
|
|
BATCH_UPDATE: { langKey: "audit.action.batchUpdate", color: "bg-primary/10 text-primary" },
|
|
BATCH_DELETE: { langKey: "audit.action.batchDelete", color: "bg-destructive/10 text-destructive" },
|
|
};
|
|
|
|
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<string, string> = {
|
|
status: "audit.field.status",
|
|
menuUrl: "audit.field.menuUrl",
|
|
menu_url: "audit.field.menuUrl",
|
|
menuNameKor: "audit.field.menuNameKor",
|
|
menu_name_kor: "audit.field.menuNameKor",
|
|
menuNameEng: "audit.field.menuNameEng",
|
|
menu_name_eng: "audit.field.menuNameEng",
|
|
screenName: "audit.field.screenName",
|
|
screen_name: "audit.field.screenName",
|
|
tableName: "audit.field.tableName",
|
|
table_name: "audit.field.tableName",
|
|
description: "audit.field.description",
|
|
isActive: "audit.field.isActive",
|
|
is_active: "audit.field.isActive",
|
|
userName: "audit.field.userName",
|
|
user_name: "audit.field.userName",
|
|
userId: "audit.field.userId",
|
|
user_id: "audit.field.userId",
|
|
deptName: "audit.field.deptName",
|
|
dept_name: "audit.field.deptName",
|
|
authName: "audit.field.authName",
|
|
authCode: "audit.field.authCode",
|
|
companyCode: "audit.field.companyCode",
|
|
company_code: "audit.field.companyCode",
|
|
company_name: "audit.field.companyName",
|
|
name: "audit.field.name",
|
|
user_password: "audit.field.userPassword",
|
|
prefix: "audit.field.prefix",
|
|
ruleName: "audit.field.ruleName",
|
|
stepName: "audit.field.stepName",
|
|
stepOrder: "audit.field.stepOrder",
|
|
sourceScreenId: "audit.field.sourceScreenId",
|
|
targetCompanyCode: "audit.field.targetCompanyCode",
|
|
mainScreenName: "audit.field.mainScreenName",
|
|
screenCode: "audit.field.screenCode",
|
|
menuObjid: "audit.field.menuObjid",
|
|
deleteReason: "audit.field.deleteReason",
|
|
force: "audit.field.force",
|
|
deletedMenus: "audit.field.deletedMenus",
|
|
failedMenuIds: "audit.field.failedMenuIds",
|
|
deletedCount: "audit.field.deletedCount",
|
|
items: "audit.field.items",
|
|
};
|
|
|
|
function formatFieldValue(value: unknown, t: (key: string, params?: Record<string, string | number>) => string): string {
|
|
if (value === null || value === undefined) return t("audit.fieldValue.empty");
|
|
if (typeof value === "boolean") return value ? t("audit.fieldValue.yes") : t("audit.fieldValue.no");
|
|
if (Array.isArray(value)) return value.length > 0 ? `${value.length}${t("audit.list.countSuffix")}` : t("audit.fieldValue.empty");
|
|
if (typeof value === "object") return JSON.stringify(value);
|
|
return String(value);
|
|
}
|
|
|
|
function renderChanges(changes: Record<string, unknown>, t: (key: string, params?: Record<string, string | number>) => string) {
|
|
const before = (changes.before as Record<string, unknown>) || {};
|
|
const after = (changes.after as Record<string, unknown>) || {};
|
|
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] ? t(FIELD_NAME_MAP[key]) : key,
|
|
beforeVal: key in before ? formatFieldValue(before[key], t) : null,
|
|
afterVal: key in after ? formatFieldValue(after[key], t) : 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 (
|
|
<div className="overflow-hidden rounded border">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="bg-muted/50">
|
|
<th className="px-3 py-1.5 text-left font-medium">{t("audit.changes.field")}</th>
|
|
{hasBefore && (
|
|
<th className="px-3 py-1.5 text-left font-medium text-destructive">
|
|
{t("audit.changes.before")}
|
|
</th>
|
|
)}
|
|
{hasAfter && (
|
|
<th className="px-3 py-1.5 text-left font-medium text-primary">
|
|
{t("audit.changes.after")}
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className="border-t">
|
|
<td className="text-muted-foreground px-3 py-1.5 font-medium">
|
|
{row.field}
|
|
</td>
|
|
{row.isSensitive ? (
|
|
<td
|
|
colSpan={
|
|
(hasBefore ? 1 : 0) + (hasAfter ? 1 : 0)
|
|
}
|
|
className="px-3 py-1.5 italic text-amber-600"
|
|
>
|
|
{t("audit.changes.securityHidden")}
|
|
</td>
|
|
) : (
|
|
<>
|
|
{hasBefore && (
|
|
<td className="px-3 py-1.5">
|
|
{row.beforeVal !== null ? (
|
|
<span className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive">
|
|
{row.beforeVal}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
{hasAfter && (
|
|
<td className="px-3 py-1.5">
|
|
{row.afterVal !== null ? (
|
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-primary">
|
|
{row.afterVal}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
</>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDateGroup(dateStr: string, t: (key: string) => 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 t("audit.dateGroup.today");
|
|
if (d.toDateString() === yesterday.toDateString()) return t("audit.dateGroup.yesterday");
|
|
|
|
return d.toLocaleDateString("ko-KR", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
weekday: "short",
|
|
});
|
|
}
|
|
|
|
function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
|
const groups = new Map<string, AuditLogEntry[]>();
|
|
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 { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "admin.auditLog" });
|
|
|
|
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [filters, setFilters] = useState<AuditLogFilters>({
|
|
page: 1,
|
|
limit: 50,
|
|
});
|
|
const [stats, setStats] = useState<AuditLogStats | null>(null);
|
|
const [selectedEntry, setSelectedEntry] = useState<AuditLogEntry | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [userComboOpen, setUserComboOpen] = useState(false);
|
|
const [companyComboOpen, setCompanyComboOpen] = useState(false);
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [auditUsers, setAuditUsers] = useState<AuditLogUser[]>([]);
|
|
|
|
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<AuditLogFilters> = { [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 (
|
|
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{t("audit.title")}</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("audit.description")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
fetchLogs();
|
|
fetchStats();
|
|
}}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
{t("audit.button.refresh")}
|
|
</Button>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-muted-foreground text-xs">{t("audit.stats.totalChanges30d")}</p>
|
|
<p className="text-2xl font-bold">
|
|
{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}{t("audit.stats.countSuffix")}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-muted-foreground text-xs">{t("audit.stats.resourceTypes")}</p>
|
|
<p className="text-2xl font-bold">{stats.resourceTypeCounts.length}{t("audit.stats.typeSuffix")}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-muted-foreground text-xs">{t("audit.stats.activeUsers")}</p>
|
|
<p className="text-2xl font-bold">{stats.topUsers.length}{t("audit.stats.userSuffix")}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-muted-foreground text-xs">{t("audit.stats.todayChanges")}</p>
|
|
<p className="text-2xl font-bold">
|
|
{(
|
|
stats.dailyCounts.find(
|
|
(d) =>
|
|
new Date(d.date).toDateString() ===
|
|
new Date().toDateString()
|
|
)?.count || 0
|
|
).toLocaleString()}{t("audit.stats.countSuffix")}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<form
|
|
onSubmit={handleSearch}
|
|
className="flex flex-col gap-3 sm:flex-wrap sm:flex-row sm:items-end"
|
|
>
|
|
<div className="w-full sm:min-w-[120px] sm:flex-1">
|
|
<label className="text-xs font-medium">{t("audit.filter.searchLabel")}</label>
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
|
<Input
|
|
placeholder={t("audit.filter.searchPlaceholder")}
|
|
value={filters.search || ""}
|
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
|
className="h-9 pl-8 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full sm:w-[130px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.typeLabel")}</label>
|
|
<Select
|
|
value={filters.resourceType || "all"}
|
|
onValueChange={(v) =>
|
|
handleFilterChange("resourceType", v === "all" ? "" : v)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t("audit.filter.all")}</SelectItem>
|
|
{Object.entries(RESOURCE_TYPE_CONFIG).map(([key, cfg]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{t(cfg.langKey)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="w-full sm:w-[120px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.actionLabel")}</label>
|
|
<Select
|
|
value={filters.action || "all"}
|
|
onValueChange={(v) =>
|
|
handleFilterChange("action", v === "all" ? "" : v)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t("audit.filter.all")}</SelectItem>
|
|
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{t(cfg.langKey)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{isSuperAdmin && (
|
|
<div className="w-full sm:w-[160px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.companyLabel")}</label>
|
|
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={companyComboOpen}
|
|
className="h-9 w-full justify-between text-xs"
|
|
>
|
|
{filters.companyCode
|
|
? companies.find((c) => c.company_code === filters.companyCode)
|
|
?.company_name || filters.companyCode
|
|
: t("audit.filter.allCompanies")}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder={t("audit.filter.companySearchPlaceholder")} className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-3 text-center text-xs">
|
|
{t("audit.filter.companyNotFound")}
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="__all_companies__"
|
|
onSelect={() => {
|
|
handleFilterChange("companyCode", "");
|
|
setCompanyComboOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
!filters.companyCode ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
{t("audit.filter.allCompanies")}
|
|
</CommandItem>
|
|
{companies.map((company) => (
|
|
<CommandItem
|
|
key={company.company_code}
|
|
value={`${company.company_name} ${company.company_code}`}
|
|
onSelect={() => {
|
|
handleFilterChange(
|
|
"companyCode",
|
|
filters.companyCode === company.company_code
|
|
? ""
|
|
: company.company_code
|
|
);
|
|
setCompanyComboOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
filters.companyCode === company.company_code
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{company.company_name}</span>
|
|
<span className="text-muted-foreground text-[10px]">
|
|
{company.company_code}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
|
|
<div className="w-full sm:w-[160px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.userLabel")}</label>
|
|
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={userComboOpen}
|
|
className="h-9 w-full justify-between text-xs"
|
|
>
|
|
{filters.userId
|
|
? auditUsers.find((u) => u.user_id === filters.userId)
|
|
?.user_name || filters.userId
|
|
: t("audit.filter.all")}
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder={t("audit.filter.userSearchPlaceholder")} className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-3 text-center text-xs">
|
|
{t("audit.filter.userNotFound")}
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="__all_users__"
|
|
onSelect={() => {
|
|
handleFilterChange("userId", "");
|
|
setUserComboOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
!filters.userId ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
{t("audit.filter.all")}
|
|
</CommandItem>
|
|
{auditUsers.map((u) => (
|
|
<CommandItem
|
|
key={u.user_id}
|
|
value={`${u.user_name} ${u.user_id}`}
|
|
onSelect={() => {
|
|
handleFilterChange(
|
|
"userId",
|
|
filters.userId === u.user_id ? "" : u.user_id
|
|
);
|
|
setUserComboOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3 w-3",
|
|
filters.userId === u.user_id
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{u.user_name}</span>
|
|
<span className="text-muted-foreground text-[10px]">
|
|
{u.user_id} ({u.count}{t("audit.list.countSuffix")})
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="w-full sm:w-[130px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.dateFromLabel")}</label>
|
|
<Input
|
|
type="date"
|
|
value={filters.dateFrom || ""}
|
|
onChange={(e) => handleFilterChange("dateFrom", e.target.value)}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-full sm:w-[130px]">
|
|
<label className="text-xs font-medium">{t("audit.filter.dateToLabel")}</label>
|
|
<Input
|
|
type="date"
|
|
value={filters.dateTo || ""}
|
|
onChange={(e) => handleFilterChange("dateTo", e.target.value)}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<Button type="submit" size="sm" className="h-9 w-full sm:w-auto">
|
|
<Filter className="mr-1 h-4 w-4" />
|
|
{t("audit.filter.apply")}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="flex-1 overflow-hidden">
|
|
<CardHeader className="border-b px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">
|
|
{t("audit.list.title")} ({total.toLocaleString()}{t("audit.list.countSuffix")})
|
|
</CardTitle>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={filters.page === 1}
|
|
onClick={() =>
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
page: (prev.page || 1) - 1,
|
|
}))
|
|
}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-muted-foreground text-xs">
|
|
{filters.page || 1} / {totalPages || 1}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={(filters.page || 1) >= totalPages}
|
|
onClick={() =>
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
page: (prev.page || 1) + 1,
|
|
}))
|
|
}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="overflow-auto p-0" style={{ maxHeight: "calc(100vh - 400px)" }}>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<RefreshCw className="text-muted-foreground h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : entries.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Clock className="text-muted-foreground mb-3 h-10 w-10" />
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("audit.list.empty")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{Array.from(dateGroups.entries()).map(([dateKey, items]) => (
|
|
<div key={dateKey}>
|
|
<div className="bg-muted/50 sticky top-0 z-10 border-b px-4 py-2">
|
|
<span className="text-xs font-semibold">
|
|
{formatDateGroup(items[0].created_at, t)}
|
|
</span>
|
|
<span className="text-muted-foreground ml-2 text-xs">
|
|
{items.length}{t("audit.list.countSuffix")}
|
|
</span>
|
|
</div>
|
|
{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 (
|
|
<div
|
|
key={entry.id}
|
|
className="hover:bg-muted/30 flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors"
|
|
onClick={() => openDetail(entry)}
|
|
>
|
|
<div
|
|
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${rtConfig.color}`}
|
|
>
|
|
<IconComp className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">
|
|
{entry.user_name || entry.user_id}
|
|
</span>
|
|
<Badge
|
|
variant="secondary"
|
|
className={`text-[10px] ${rtConfig.color}`}
|
|
>
|
|
{t(rtConfig.langKey)}
|
|
</Badge>
|
|
<Badge
|
|
variant="secondary"
|
|
className={`text-[10px] ${actConfig.color}`}
|
|
>
|
|
{t(actConfig.langKey)}
|
|
</Badge>
|
|
{entry.company_code && entry.company_code !== "*" && (
|
|
<span className="text-muted-foreground text-[10px]">
|
|
[{entry.company_name || entry.company_code}]
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
|
{entry.summary || entry.resource_name || "-"}
|
|
</p>
|
|
</div>
|
|
<span className="text-muted-foreground shrink-0 text-xs">
|
|
{formatTime(entry.created_at)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{t("audit.detail.title")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{selectedEntry &&
|
|
formatDateTime(selectedEntry.created_at)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{selectedEntry && (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.user")}
|
|
</label>
|
|
<p className="font-medium">
|
|
{selectedEntry.user_name || selectedEntry.user_id}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.company")}
|
|
</label>
|
|
<p className="font-medium">
|
|
{selectedEntry.company_name || selectedEntry.company_code}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.resourceType")}
|
|
</label>
|
|
<p className="font-medium">
|
|
{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]
|
|
? t(RESOURCE_TYPE_CONFIG[selectedEntry.resource_type].langKey)
|
|
: selectedEntry.resource_type}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">{t("audit.detail.action")}</label>
|
|
<p className="font-medium">
|
|
{ACTION_CONFIG[selectedEntry.action]
|
|
? t(ACTION_CONFIG[selectedEntry.action].langKey)
|
|
: selectedEntry.action}
|
|
</p>
|
|
</div>
|
|
{selectedEntry.resource_name && (
|
|
<div className="col-span-2">
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.resourceName")}
|
|
</label>
|
|
<p className="font-medium">{selectedEntry.resource_name}</p>
|
|
</div>
|
|
)}
|
|
{selectedEntry.table_name && (
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.tableName")}
|
|
</label>
|
|
<p className="font-medium">{selectedEntry.table_name}</p>
|
|
</div>
|
|
)}
|
|
{selectedEntry.ip_address && (
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.ipAddress")}
|
|
</label>
|
|
<p className="font-medium">{selectedEntry.ip_address}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedEntry.summary && (
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">{t("audit.detail.summary")}</label>
|
|
<p className="bg-muted rounded p-2 text-xs">
|
|
{selectedEntry.summary}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedEntry.changes && (
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.changes")}
|
|
</label>
|
|
<div className="mt-1">
|
|
{renderChanges(
|
|
selectedEntry.changes as Record<string, unknown>,
|
|
t
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedEntry.request_path && (
|
|
<div>
|
|
<label className="text-muted-foreground text-xs">
|
|
{t("audit.detail.apiPath")}
|
|
</label>
|
|
<p className="text-muted-foreground text-xs">
|
|
{selectedEntry.request_path}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|