feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management. - Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made. - Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks. - Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes. - This integration improves traceability and accountability for data modifications within the application.
This commit is contained in:
@@ -0,0 +1,948 @@
|
||||
"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<string, { label: string; color: string }> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown>) {
|
||||
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] || 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 (
|
||||
<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">항목</th>
|
||||
{hasBefore && (
|
||||
<th className="px-3 py-1.5 text-left font-medium text-red-600">
|
||||
변경 전
|
||||
</th>
|
||||
)}
|
||||
{hasAfter && (
|
||||
<th className="px-3 py-1.5 text-left font-medium text-blue-600">
|
||||
변경 후
|
||||
</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"
|
||||
>
|
||||
(보안 항목 - 값 비공개)
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
{hasBefore && (
|
||||
<td className="px-3 py-1.5">
|
||||
{row.beforeVal !== null ? (
|
||||
<span className="rounded bg-red-50 px-1.5 py-0.5 text-red-700">
|
||||
{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-blue-50 px-1.5 py-0.5 text-blue-700">
|
||||
{row.afterVal}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<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 [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">통합 변경 이력</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
시스템 전체 변경 사항을 추적합니다
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
fetchStats();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</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">최근 30일 총 변경</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">리소스 유형</p>
|
||||
<p className="text-2xl font-bold">{stats.resourceTypeCounts.length}종</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">활동 사용자</p>
|
||||
<p className="text-2xl font-bold">{stats.topUsers.length}명</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">오늘 변경</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{(
|
||||
stats.dailyCounts.find(
|
||||
(d) =>
|
||||
new Date(d.date).toDateString() ===
|
||||
new Date().toDateString()
|
||||
)?.count || 0
|
||||
).toLocaleString()}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<div className="min-w-[120px] flex-1">
|
||||
<label className="text-xs font-medium">검색어</label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="이름, 요약, 사용자..."
|
||||
value={filters.search || ""}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
className="h-9 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[130px]">
|
||||
<label className="text-xs font-medium">유형</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">전체</SelectItem>
|
||||
{Object.entries(RESOURCE_TYPE_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{cfg.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[120px]">
|
||||
<label className="text-xs font-medium">동작</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">전체</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{cfg.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<div className="w-[160px]">
|
||||
<label className="text-xs font-medium">회사</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
|
||||
: "전체 회사"}
|
||||
<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="회사 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">
|
||||
회사를 찾을 수 없습니다
|
||||
</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"
|
||||
)}
|
||||
/>
|
||||
전체 회사
|
||||
</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-[160px]">
|
||||
<label className="text-xs font-medium">사용자</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
|
||||
: "전체"}
|
||||
<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="사용자 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">
|
||||
사용자를 찾을 수 없습니다
|
||||
</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"
|
||||
)}
|
||||
/>
|
||||
전체
|
||||
</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}건)
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="w-[130px]">
|
||||
<label className="text-xs font-medium">시작일</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateFrom || ""}
|
||||
onChange={(e) => handleFilterChange("dateFrom", e.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[130px]">
|
||||
<label className="text-xs font-medium">종료일</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">
|
||||
<Filter className="mr-1 h-4 w-4" />
|
||||
필터 적용
|
||||
</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">
|
||||
변경 이력 ({total.toLocaleString()}건)
|
||||
</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">
|
||||
변경 이력이 없습니다
|
||||
</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)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
{items.length}건
|
||||
</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}`}
|
||||
>
|
||||
{rtConfig.label}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${actConfig.color}`}
|
||||
>
|
||||
{actConfig.label}
|
||||
</Badge>
|
||||
{entry.company_code && entry.company_code !== "*" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
[{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">
|
||||
변경 상세 정보
|
||||
</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">
|
||||
사용자
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.user_name || selectedEntry.user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
회사코드
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
리소스 유형
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]?.label ||
|
||||
selectedEntry.resource_type}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">동작</label>
|
||||
<p className="font-medium">
|
||||
{ACTION_CONFIG[selectedEntry.action]?.label ||
|
||||
selectedEntry.action}
|
||||
</p>
|
||||
</div>
|
||||
{selectedEntry.resource_name && (
|
||||
<div className="col-span-2">
|
||||
<label className="text-muted-foreground text-xs">
|
||||
리소스명
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.resource_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedEntry.table_name && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
테이블명
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.table_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedEntry.ip_address && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
IP 주소
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.ip_address}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEntry.summary && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">요약</label>
|
||||
<p className="bg-muted rounded p-2 text-xs">
|
||||
{selectedEntry.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry.changes && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
변경 내역
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
{renderChanges(
|
||||
selectedEntry.changes as Record<string, unknown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry.request_path && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
API 경로
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedEntry.request_path}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -380,8 +380,8 @@ export function CreateTableModal({
|
||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 생성 옵션 */}
|
||||
<div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
|
||||
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
<Checkbox
|
||||
id="useLogTable"
|
||||
checked={useLogTable}
|
||||
@@ -401,7 +401,7 @@ export function CreateTableModal({
|
||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 자동 추가 컬럼 안내 */}
|
||||
<Alert>
|
||||
|
||||
@@ -2092,24 +2092,25 @@ export default function ScreenDesigner({
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
// POP 모드: screen_layouts_pop 테이블에 저장
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
||||
// console.log("✅ 저장 성공!");
|
||||
// 테이블이 변경된 경우 전용 API로 명시적으로 업데이트
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
|
||||
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
@@ -5625,33 +5626,38 @@ export default function ScreenDesigner({
|
||||
if (layout.components.length > 0 && selectedScreen?.screenId) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 해상도 정보를 포함한 레이아웃 데이터 생성
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName,
|
||||
};
|
||||
console.log("⚡ 자동 저장할 레이아웃 데이터:", {
|
||||
componentsCount: layoutWithResolution.components.length,
|
||||
gridSettings: layoutWithResolution.gridSettings,
|
||||
screenResolution: layoutWithResolution.screenResolution,
|
||||
});
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("레이아웃이 저장되었습니다.");
|
||||
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 저장 실패:", error);
|
||||
toast.error("레이아웃 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -5783,6 +5789,8 @@ export default function ScreenDesigner({
|
||||
handleGroupDistribute,
|
||||
handleMatchSize,
|
||||
handleToggleAllLabels,
|
||||
tables,
|
||||
onScreenUpdate,
|
||||
]);
|
||||
|
||||
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createCategoryValue,
|
||||
updateCategoryValue,
|
||||
deleteCategoryValue,
|
||||
checkCanDeleteCategoryValue,
|
||||
CreateCategoryValueInput,
|
||||
} from "@/lib/api/categoryTree";
|
||||
import {
|
||||
@@ -310,53 +311,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
// 하위 항목 개수만 계산 (자기 자신 제외)
|
||||
const countAllDescendants = useCallback(
|
||||
(node: CategoryValue): number => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return countAllValues(node.children);
|
||||
},
|
||||
[countAllValues],
|
||||
);
|
||||
|
||||
// 노드와 모든 하위 항목의 ID 수집
|
||||
const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => {
|
||||
const ids: number[] = [node.valueId];
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
ids.push(...collectNodeAndDescendantIds(child));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, []);
|
||||
|
||||
// 트리에서 valueId로 노드 찾기
|
||||
const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.valueId === valueId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, valueId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함)
|
||||
const totalDeleteCount = useMemo(() => {
|
||||
const allIds = new Set<number>();
|
||||
checkedIds.forEach((id) => {
|
||||
const node = findNodeById(tree, id);
|
||||
if (node) {
|
||||
collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId));
|
||||
}
|
||||
});
|
||||
return allIds.size;
|
||||
}, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]);
|
||||
|
||||
// 활성 노드만 필터링
|
||||
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
||||
@@ -504,8 +458,20 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 다이얼로그 열기
|
||||
const handleOpenDeleteDialog = (value: CategoryValue) => {
|
||||
// 삭제 다이얼로그 열기 (사전 확인 후)
|
||||
const handleOpenDeleteDialog = async (value: CategoryValue) => {
|
||||
try {
|
||||
const response = await checkCanDeleteCategoryValue(value.valueId);
|
||||
if (response.success && response.data) {
|
||||
if (!response.data.canDelete) {
|
||||
toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증)
|
||||
}
|
||||
|
||||
setDeletingValue(value);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
@@ -616,8 +582,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failMessages: string[] = [];
|
||||
|
||||
// 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제)
|
||||
for (const valueId of Array.from(checkedIds)) {
|
||||
try {
|
||||
const response = await deleteCategoryValue(valueId);
|
||||
@@ -625,6 +591,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
if (response.error) failMessages.push(response.error);
|
||||
}
|
||||
} catch {
|
||||
failCount++;
|
||||
@@ -634,12 +601,14 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setCheckedIds(new Set());
|
||||
setSelectedValue(null);
|
||||
loadTree(true); // 기존 펼침 상태 유지
|
||||
loadTree(true);
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`);
|
||||
toast.success(`${successCount}개 카테고리가 삭제되었습니다`);
|
||||
} else if (successCount === 0) {
|
||||
toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`);
|
||||
} else {
|
||||
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`);
|
||||
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 일괄 삭제 오류:", error);
|
||||
@@ -889,14 +858,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
||||
{deletingValue && countAllDescendants(deletingValue) > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -918,12 +881,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
<AlertDialogTitle>카테고리 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 <strong>{checkedIds.size}개</strong> 카테고리를 삭제하시겠습니까?
|
||||
{totalDeleteCount > checkedIds.size && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">하위 카테고리 포함 총 {totalDeleteCount}개가 삭제됩니다.</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||
</AlertDialogDescription>
|
||||
@@ -934,7 +891,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
onClick={handleBulkDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{totalDeleteCount}개 삭제
|
||||
{checkedIds.size}개 삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string | null;
|
||||
resource_name: string | null;
|
||||
table_name: string | null;
|
||||
summary: string | null;
|
||||
changes: any;
|
||||
ip_address: string | null;
|
||||
request_path: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
resourceType?: string;
|
||||
action?: string;
|
||||
tableName?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogStats {
|
||||
dailyCounts: Array<{ date: string; count: number }>;
|
||||
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
||||
actionCounts: Array<{ action: string; count: number }>;
|
||||
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(
|
||||
filters: AuditLogFilters
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyCode) params.append("companyCode", filters.companyCode);
|
||||
if (filters.userId) params.append("userId", filters.userId);
|
||||
if (filters.resourceType) params.append("resourceType", filters.resourceType);
|
||||
if (filters.action) params.append("action", filters.action);
|
||||
if (filters.tableName) params.append("tableName", filters.tableName);
|
||||
if (filters.dateFrom) params.append("dateFrom", filters.dateFrom);
|
||||
if (filters.dateTo) params.append("dateTo", filters.dateTo);
|
||||
if (filters.search) params.append("search", filters.search);
|
||||
if (filters.page) params.append("page", String(filters.page));
|
||||
if (filters.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const response = await apiClient.get(`/audit-log?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAuditLogStats(
|
||||
companyCode?: string,
|
||||
days?: number
|
||||
): Promise<{ success: boolean; data: AuditLogStats }> {
|
||||
const params = new URLSearchParams();
|
||||
if (companyCode) params.append("companyCode", companyCode);
|
||||
if (days) params.append("days", String(days));
|
||||
|
||||
const response = await apiClient.get(`/audit-log/stats?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface AuditLogUser {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogUsers(
|
||||
companyCode?: string
|
||||
): Promise<{ success: boolean; data: AuditLogUser[] }> {
|
||||
const params = new URLSearchParams();
|
||||
if (companyCode) params.append("companyCode", companyCode);
|
||||
|
||||
const response = await apiClient.get(`/audit-log/users?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -156,6 +156,24 @@ export async function updateCategoryValue(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
export async function checkCanDeleteCategoryValue(
|
||||
valueId: number
|
||||
): Promise<ApiResponse<{ canDelete: boolean; reason?: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-tree/test/value/${valueId}/can-delete`);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
return {
|
||||
success: false,
|
||||
error: err.response?.data?.error || err.message || "삭제 가능 여부 확인 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제
|
||||
*/
|
||||
|
||||
@@ -87,6 +87,11 @@ export const screenApi = {
|
||||
await apiClient.put(`/screen-management/screens/${screenId}/info`, data);
|
||||
},
|
||||
|
||||
// 화면 테이블명 변경
|
||||
updateScreenTableName: async (screenId: number, tableName: string): Promise<void> => {
|
||||
await apiClient.patch(`/screen-management/screens/${screenId}/table-name`, { tableName });
|
||||
},
|
||||
|
||||
// 화면 의존성 체크
|
||||
checkScreenDependencies: async (
|
||||
screenId: number,
|
||||
|
||||
Reference in New Issue
Block a user