Files
vexplor_dev/frontend/app/(main)/admin/audit-log/page.tsx
kjs 540b6290c4 Implement multi-language support in audit log, system notices, collection management, and common code management pages
- 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.
2026-04-01 16:16:40 +09:00

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>
);
}