2025-10-27 11:11:08 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 이력 뷰어 모달
|
|
|
|
|
* 테이블 타입 관리의 {테이블명}_log 테이블 데이터를 표시
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import { Clock, User, FileEdit, Trash2, Plus, AlertCircle, Loader2, Search, X } from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
getRecordHistory,
|
|
|
|
|
getRecordTimeline,
|
|
|
|
|
TableHistoryRecord,
|
|
|
|
|
TableHistoryTimelineEvent,
|
|
|
|
|
} from "@/lib/api/tableHistory";
|
|
|
|
|
import { format } from "date-fns";
|
|
|
|
|
import { ko } from "date-fns/locale";
|
|
|
|
|
|
|
|
|
|
interface TableHistoryModalProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
tableName: string;
|
|
|
|
|
recordId?: string | number | null; // 선택사항: null이면 전체 테이블 이력
|
|
|
|
|
recordLabel?: string;
|
|
|
|
|
displayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TableHistoryModal({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
tableName,
|
|
|
|
|
recordId,
|
|
|
|
|
recordLabel,
|
|
|
|
|
displayColumn,
|
|
|
|
|
}: TableHistoryModalProps) {
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [timeline, setTimeline] = useState<TableHistoryTimelineEvent[]>([]);
|
|
|
|
|
const [detailRecords, setDetailRecords] = useState<TableHistoryRecord[]>([]);
|
|
|
|
|
const [allRecords, setAllRecords] = useState<TableHistoryRecord[]>([]); // 검색용 원본 데이터
|
|
|
|
|
// recordId가 없으면 (전체 테이블 모드) detail 탭부터 시작
|
|
|
|
|
const [activeTab, setActiveTab] = useState<"timeline" | "detail">(recordId ? "timeline" : "detail");
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState<string>(""); // 검색어
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
loadHistory();
|
|
|
|
|
// recordId 변경 시 탭도 초기화
|
|
|
|
|
setActiveTab(recordId ? "timeline" : "detail");
|
|
|
|
|
}
|
|
|
|
|
}, [open, tableName, recordId]);
|
|
|
|
|
|
|
|
|
|
const loadHistory = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (recordId) {
|
|
|
|
|
// 단일 레코드 이력 로드
|
|
|
|
|
const [timelineRes, detailRes] = await Promise.all([
|
|
|
|
|
getRecordTimeline(tableName, recordId),
|
|
|
|
|
getRecordHistory(tableName, recordId, { limit: 100 }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (timelineRes.success && timelineRes.data) {
|
|
|
|
|
setTimeline(timelineRes.data);
|
|
|
|
|
} else {
|
|
|
|
|
setError(timelineRes.error || "타임라인 로드 실패");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (detailRes.success && detailRes.data) {
|
|
|
|
|
setDetailRecords(detailRes.data.records);
|
|
|
|
|
setAllRecords(detailRes.data.records);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 전체 테이블 이력 로드 (recordId 없이)
|
|
|
|
|
const detailRes = await getRecordHistory(tableName, null, { limit: 200 });
|
|
|
|
|
|
|
|
|
|
if (detailRes.success && detailRes.data) {
|
|
|
|
|
const records = detailRes.data.records;
|
|
|
|
|
setAllRecords(records); // 원본 데이터 저장
|
|
|
|
|
setDetailRecords(records); // 초기 표시 데이터
|
|
|
|
|
// 타임라인은 전체 테이블에서는 사용하지 않음
|
|
|
|
|
setTimeline([]);
|
|
|
|
|
} else {
|
|
|
|
|
setError(detailRes.error || "이력 로드 실패");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
setError(err.message || "이력 로드 중 오류 발생");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getOperationIcon = (type: string) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "INSERT":
|
|
|
|
|
return <Plus className="h-4 w-4 text-green-600" />;
|
|
|
|
|
case "UPDATE":
|
|
|
|
|
return <FileEdit className="h-4 w-4 text-blue-600" />;
|
|
|
|
|
case "DELETE":
|
|
|
|
|
return <Trash2 className="h-4 w-4 text-red-600" />;
|
|
|
|
|
default:
|
|
|
|
|
return <Clock className="h-4 w-4 text-gray-600" />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getOperationBadge = (type: string) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "INSERT":
|
|
|
|
|
return <Badge className="bg-green-100 text-xs text-green-800">추가</Badge>;
|
|
|
|
|
case "UPDATE":
|
|
|
|
|
return <Badge className="bg-blue-100 text-xs text-blue-800">수정</Badge>;
|
|
|
|
|
case "DELETE":
|
|
|
|
|
return <Badge className="bg-red-100 text-xs text-red-800">삭제</Badge>;
|
|
|
|
|
default:
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{type}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string) => {
|
|
|
|
|
try {
|
|
|
|
|
return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
|
|
|
|
} catch {
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 검색 필터링 (전체 테이블 모드에서만)
|
|
|
|
|
const handleSearch = (term: string) => {
|
|
|
|
|
setSearchTerm(term);
|
|
|
|
|
|
|
|
|
|
if (!term.trim()) {
|
|
|
|
|
// 검색어가 없으면 전체 표시
|
|
|
|
|
setDetailRecords(allRecords);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lowerTerm = term.toLowerCase();
|
|
|
|
|
const filtered = allRecords.filter((record) => {
|
|
|
|
|
// 레코드 ID로 검색
|
|
|
|
|
if (record.original_id?.toString().toLowerCase().includes(lowerTerm)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// displayColumn 값으로 검색 (full_row_after에서 추출)
|
|
|
|
|
if (displayColumn && record.full_row_after) {
|
|
|
|
|
const displayValue = record.full_row_after[displayColumn];
|
|
|
|
|
if (displayValue && displayValue.toString().toLowerCase().includes(lowerTerm)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 변경자로 검색
|
|
|
|
|
if (record.changed_by?.toLowerCase().includes(lowerTerm)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컬럼명으로 검색
|
|
|
|
|
if (record.changed_column?.toLowerCase().includes(lowerTerm)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setDetailRecords(filtered);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// displayColumn 값 추출 헬퍼 함수
|
|
|
|
|
const getDisplayValue = (record: TableHistoryRecord): string | null => {
|
|
|
|
|
if (!displayColumn) return null;
|
|
|
|
|
|
|
|
|
|
// full_row_after에서 먼저 시도
|
|
|
|
|
if (record.full_row_after && record.full_row_after[displayColumn]) {
|
|
|
|
|
return record.full_row_after[displayColumn];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// full_row_before에서 시도 (DELETE의 경우)
|
|
|
|
|
if (record.full_row_before && record.full_row_before[displayColumn]) {
|
|
|
|
|
return record.full_row_before[displayColumn];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-h-[90vh] max-w-[95vw] sm:max-w-[900px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
|
|
|
<Clock className="h-5 w-5" />
|
|
|
|
|
변경 이력{" "}
|
|
|
|
|
{!recordId && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
전체
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
{recordId
|
|
|
|
|
? `${recordLabel || `레코드 ID: ${recordId}`} - ${tableName} 테이블`
|
|
|
|
|
: `${tableName} 테이블 전체 이력`}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
|
|
|
|
<span className="text-muted-foreground ml-2 text-sm">로딩 중...</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : error ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
|
|
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
|
|
|
|
<p className="text-destructive text-sm">{error}</p>
|
|
|
|
|
<Button variant="outline" onClick={loadHistory} className="mt-4 h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
다시 시도
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="w-full">
|
|
|
|
|
{recordId && (
|
|
|
|
|
<TabsList className="w-full">
|
|
|
|
|
<TabsTrigger value="timeline" className="flex-1 text-xs sm:text-sm">
|
|
|
|
|
타임라인 ({timeline.length})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
|
|
|
|
|
상세 내역 ({detailRecords.length})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
)}
|
|
|
|
|
{!recordId && (
|
|
|
|
|
<>
|
|
|
|
|
<TabsList className="w-full">
|
|
|
|
|
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
|
|
|
|
|
전체 변경 이력 ({detailRecords.length})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
{/* 검색창 (전체 테이블 모드) */}
|
|
|
|
|
<div className="relative mt-4">
|
|
|
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={`레코드 ID${displayColumn ? `, ${displayColumn}` : ""}, 변경자, 컬럼명으로 검색...`}
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
|
|
|
className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
{searchTerm && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleSearch("")}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{searchTerm && (
|
|
|
|
|
<p className="text-muted-foreground mt-2 text-xs">
|
|
|
|
|
검색 결과: {detailRecords.length}개 / 전체 {allRecords.length}개
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 타임라인 뷰 */}
|
|
|
|
|
<TabsContent value="timeline">
|
|
|
|
|
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
|
|
|
|
|
{timeline.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<Clock className="text-muted-foreground mb-2 h-12 w-12" />
|
|
|
|
|
<p className="text-muted-foreground text-sm">변경 이력이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{timeline.map((event, index) => (
|
|
|
|
|
<div key={index} className="relative border-l-2 border-gray-200 pb-6 pl-8 last:border-l-0">
|
|
|
|
|
<div className="absolute top-0 -left-3 rounded-full border-2 border-gray-200 bg-white p-1">
|
|
|
|
|
{getOperationIcon(event.operation_type)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
{getOperationBadge(event.operation_type)}
|
|
|
|
|
<span className="text-muted-foreground text-xs">{formatDate(event.changed_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm">
|
|
|
|
|
<User className="text-muted-foreground h-3 w-3" />
|
|
|
|
|
<span className="font-medium">{event.changed_by}</span>
|
|
|
|
|
{event.ip_address && (
|
|
|
|
|
<span className="text-muted-foreground text-xs">({event.ip_address})</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{event.changes && event.changes.length > 0 && (
|
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
|
<p className="text-muted-foreground text-xs font-medium">변경된 항목:</p>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{event.changes.map((change, idx) => (
|
|
|
|
|
<div key={idx} className="rounded bg-gray-50 p-2 text-xs">
|
|
|
|
|
<span className="font-mono font-medium">{change.column}</span>
|
|
|
|
|
<div className="mt-1 flex items-center gap-2">
|
|
|
|
|
<span className="text-red-600 line-through">{change.oldValue || "(없음)"}</span>
|
|
|
|
|
<span>→</span>
|
|
|
|
|
<span className="font-medium text-green-600">{change.newValue || "(없음)"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* 상세 내역 뷰 */}
|
|
|
|
|
<TabsContent value="detail">
|
|
|
|
|
<ScrollArea className="h-[500px] w-full rounded-md border">
|
|
|
|
|
{detailRecords.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<FileEdit className="text-muted-foreground mb-2 h-12 w-12" />
|
|
|
|
|
<p className="text-muted-foreground text-sm">변경 내역이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<thead className="sticky top-0 border-b bg-gray-50">
|
|
|
|
|
<tr>
|
|
|
|
|
{!recordId && <th className="p-2 text-left font-medium">레코드</th>}
|
|
|
|
|
<th className="p-2 text-left font-medium">작업</th>
|
|
|
|
|
<th className="p-2 text-left font-medium">컬럼</th>
|
|
|
|
|
<th className="p-2 text-left font-medium">이전 값</th>
|
|
|
|
|
<th className="p-2 text-left font-medium">새 값</th>
|
|
|
|
|
<th className="p-2 text-left font-medium">변경자</th>
|
|
|
|
|
<th className="p-2 text-left font-medium">일시</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{detailRecords.map((record) => {
|
|
|
|
|
const displayValue = getDisplayValue(record);
|
|
|
|
|
return (
|
|
|
|
|
<tr key={record.log_id} className="border-b hover:bg-gray-50">
|
|
|
|
|
{!recordId && (
|
|
|
|
|
<td className="p-2">
|
|
|
|
|
{displayValue ? (
|
2025-10-27 11:41:30 +09:00
|
|
|
<span className="font-medium text-gray-900">{displayValue}</span>
|
2025-10-27 11:11:08 +09:00
|
|
|
) : (
|
2025-10-27 11:41:30 +09:00
|
|
|
<span className="text-muted-foreground text-xs">-</span>
|
2025-10-27 11:11:08 +09:00
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
<td className="p-2">{getOperationBadge(record.operation_type)}</td>
|
|
|
|
|
<td className="p-2 font-mono">{record.changed_column}</td>
|
|
|
|
|
<td className="max-w-[200px] truncate p-2 text-red-600">{record.old_value || "-"}</td>
|
|
|
|
|
<td className="max-w-[200px] truncate p-2 text-green-600">{record.new_value || "-"}</td>
|
|
|
|
|
<td className="p-2">{record.changed_by}</td>
|
|
|
|
|
<td className="text-muted-foreground p-2">{formatDate(record.changed_at)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
)}
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
|
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
닫기
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|