272 lines
9.5 KiB
TypeScript
272 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
|
import { toast } from "sonner";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { History, RefreshCw, Filter, X } from "lucide-react";
|
|
|
|
interface TableLogViewerProps {
|
|
tableName: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface LogData {
|
|
log_id: number;
|
|
operation_type: string;
|
|
original_id: string;
|
|
changed_column?: string;
|
|
old_value?: string;
|
|
new_value?: string;
|
|
changed_by?: string;
|
|
changed_at: string;
|
|
ip_address?: string;
|
|
}
|
|
|
|
export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewerProps) {
|
|
const [logs, setLogs] = useState<LogData[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(20);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
|
|
// 필터 상태
|
|
const [operationType, setOperationType] = useState<string>("");
|
|
const [startDate, setStartDate] = useState<string>("");
|
|
const [endDate, setEndDate] = useState<string>("");
|
|
const [changedBy, setChangedBy] = useState<string>("");
|
|
const [originalId, setOriginalId] = useState<string>("");
|
|
|
|
// 로그 데이터 로드
|
|
const loadLogs = async () => {
|
|
if (!tableName) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await tableManagementApi.getLogData(tableName, {
|
|
page,
|
|
size: pageSize,
|
|
operationType: operationType || undefined,
|
|
startDate: startDate || undefined,
|
|
endDate: endDate || undefined,
|
|
changedBy: changedBy || undefined,
|
|
originalId: originalId || undefined,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
setLogs(response.data.data);
|
|
setTotal(response.data.total);
|
|
setTotalPages(response.data.totalPages);
|
|
} else {
|
|
toast.error(response.message || "로그 데이터를 불러올 수 없습니다.");
|
|
}
|
|
} catch (error) {
|
|
toast.error("로그 데이터 조회 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 다이얼로그가 열릴 때 로그 로드
|
|
useEffect(() => {
|
|
if (open && tableName) {
|
|
loadLogs();
|
|
}
|
|
}, [open, tableName, page]);
|
|
|
|
// 필터 초기화
|
|
const resetFilters = () => {
|
|
setOperationType("");
|
|
setStartDate("");
|
|
setEndDate("");
|
|
setChangedBy("");
|
|
setOriginalId("");
|
|
setPage(1);
|
|
};
|
|
|
|
// 작업 타입에 따른 뱃지 색상
|
|
const getOperationBadge = (type: string) => {
|
|
switch (type) {
|
|
case "INSERT":
|
|
return <Badge className="bg-green-500">추가</Badge>;
|
|
case "UPDATE":
|
|
return <Badge className="bg-blue-500">수정</Badge>;
|
|
case "DELETE":
|
|
return <Badge className="bg-red-500">삭제</Badge>;
|
|
default:
|
|
return <Badge>{type}</Badge>;
|
|
}
|
|
};
|
|
|
|
// 날짜 포맷팅
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString("ko-KR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<History className="h-5 w-5" />
|
|
{tableName} - 변경 이력
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* 필터 영역 */}
|
|
<div className="space-y-3 rounded-lg border p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
|
<Filter className="h-4 w-4" />
|
|
필터
|
|
</h4>
|
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
|
<X className="mr-1 h-4 w-4" />
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-600">작업 유형</label>
|
|
{/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__all__" 사용 */}
|
|
<Select
|
|
value={operationType || "__all__"}
|
|
onValueChange={(value) => setOperationType(value === "__all__" ? "" : value)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__all__">전체</SelectItem>
|
|
<SelectItem value="INSERT">추가</SelectItem>
|
|
<SelectItem value="UPDATE">수정</SelectItem>
|
|
<SelectItem value="DELETE">삭제</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-600">시작 날짜</label>
|
|
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-600">종료 날짜</label>
|
|
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-600">변경자</label>
|
|
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm text-gray-600">원본 ID</label>
|
|
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<Button onClick={loadLogs} className="w-full">
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
조회
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 로그 테이블 */}
|
|
<div className="flex-1 overflow-auto rounded-lg border">
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<LoadingSpinner />
|
|
</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="flex h-64 items-center justify-center text-gray-500">변경 이력이 없습니다.</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[100px]">작업</TableHead>
|
|
<TableHead>원본 ID</TableHead>
|
|
<TableHead>변경 컬럼</TableHead>
|
|
<TableHead>변경 전</TableHead>
|
|
<TableHead>변경 후</TableHead>
|
|
<TableHead>변경자</TableHead>
|
|
<TableHead>변경 시각</TableHead>
|
|
<TableHead>IP</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{logs.map((log) => (
|
|
<TableRow key={log.log_id}>
|
|
<TableCell>{getOperationBadge(log.operation_type)}</TableCell>
|
|
<TableCell className="font-mono text-sm">{log.original_id}</TableCell>
|
|
<TableCell className="text-sm">{log.changed_column || "-"}</TableCell>
|
|
<TableCell className="max-w-[200px] truncate text-sm" title={log.old_value}>
|
|
{log.old_value || "-"}
|
|
</TableCell>
|
|
<TableCell className="max-w-[200px] truncate text-sm" title={log.new_value}>
|
|
{log.new_value || "-"}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{log.changed_by || "system"}</TableCell>
|
|
<TableCell className="text-sm">{formatDate(log.changed_at)}</TableCell>
|
|
<TableCell className="font-mono text-xs">{log.ip_address || "-"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
<div className="flex items-center justify-between border-t pt-4">
|
|
<div className="text-sm text-gray-600">
|
|
전체 {total}건 (페이지 {page} / {totalPages})
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1 || loading}
|
|
>
|
|
이전
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages || loading}
|
|
>
|
|
다음
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|