"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([]); const [detailRecords, setDetailRecords] = useState([]); const [allRecords, setAllRecords] = useState([]); // 검색용 원본 데이터 // recordId가 없으면 (전체 테이블 모드) detail 탭부터 시작 const [activeTab, setActiveTab] = useState<"timeline" | "detail">(recordId ? "timeline" : "detail"); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); // 검색어 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 ; case "UPDATE": return ; case "DELETE": return ; default: return ; } }; const getOperationBadge = (type: string) => { switch (type) { case "INSERT": return 추가; case "UPDATE": return 수정; case "DELETE": return 삭제; default: return {type}; } }; const formatDate = (dateString: string) => { try { const date = new Date(dateString); // 🚨 타임존 보정 로직 // 실 서비스 DB는 UTC로 저장되는데, 프론트엔드에서 이를 KST로 인식하지 못하고 // UTC 시간 그대로(예: 02:55)를 한국 시간 02:55로 보여주는 문제가 있음 (9시간 느림). // 반면 로컬 DB는 이미 KST로 저장되어 있어서 변환하면 안 됨. // 따라서 로컬 환경이 아닐 때만 강제로 9시간을 더해줌. const isLocal = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); if (!isLocal) { date.setHours(date.getHours() + 9); } return format(date, "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; }; // 단일 레코드 모드에서 displayColumn 값 가져오기 const recordDisplayValue = recordId && displayColumn && detailRecords.length > 0 ? getDisplayValue(detailRecords[0]) : null; return ( 변경 이력{" "} {!recordId && ( 전체 )} {recordId ? `${recordDisplayValue || recordLabel || "-"} - ${tableName} 테이블` : `${tableName} 테이블 전체 이력`} {loading ? (
로딩 중...
) : error ? (

{error}

) : ( setActiveTab(v as any)} className="w-full"> {recordId && ( 타임라인 ({timeline.length}) 상세 내역 ({detailRecords.length}) )} {!recordId && ( <> 전체 변경 이력 ({detailRecords.length}) {/* 검색창 (전체 테이블 모드) */}
handleSearch(e.target.value)} className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm" /> {searchTerm && ( )}
{searchTerm && (

검색 결과: {detailRecords.length}개 / 전체 {allRecords.length}개

)} )} {/* 타임라인 뷰 */} {timeline.length === 0 ? (

변경 이력이 없습니다

) : (
{timeline.map((event, index) => (
{getOperationIcon(event.operation_type)}
{getOperationBadge(event.operation_type)} {formatDate(event.changed_at)}
{event.changed_by} {event.ip_address && ( ({event.ip_address}) )}
{event.changes && event.changes.length > 0 && (

변경된 항목:

{event.changes.map((change, idx) => (
{change.column}
{change.oldValue || "(없음)"} {change.newValue || "(없음)"}
))}
)}
))}
)}
{/* 상세 내역 뷰 */} {detailRecords.length === 0 ? (

변경 내역이 없습니다

) : ( {!recordId && } {detailRecords.map((record) => { const displayValue = getDisplayValue(record); return ( {!recordId && ( )} ); })}
레코드작업 컬럼 이전 값 새 값 변경자 일시
{displayValue ? ( {displayValue} ) : ( - )} {getOperationBadge(record.operation_type)} {record.changed_column} {record.old_value || "-"} {record.new_value || "-"} {record.changed_by} {formatDate(record.changed_at)}
)}
)}
); }