ERP-node/frontend/components/common/TableHistoryModal.tsx

394 lines
16 KiB
TypeScript

"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 ? (
<span className="font-medium text-gray-900">{displayValue}</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</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>
);
}