"use client"; /** * DrillDownModal 컴포넌트 * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 */ import React, { useState, useMemo } from "react"; import { cn } from "@/lib/utils"; import { PivotCellData, PivotFieldConfig } from "../types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Search, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, ArrowUp, ArrowDown, } from "lucide-react"; // ==================== 타입 ==================== interface DrillDownModalProps { open: boolean; onOpenChange: (open: boolean) => void; cellData: PivotCellData | null; data: any[]; // 전체 원본 데이터 fields: PivotFieldConfig[]; rowFields: PivotFieldConfig[]; columnFields: PivotFieldConfig[]; } interface SortConfig { field: string; direction: "asc" | "desc"; } // ==================== 메인 컴포넌트 ==================== export const DrillDownModal: React.FC = ({ open, onOpenChange, cellData, data, fields, rowFields, columnFields, }) => { const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [sortConfig, setSortConfig] = useState(null); // 드릴다운 데이터 필터링 const filteredData = useMemo(() => { if (!cellData || !data) return []; // 행/열 경로에 해당하는 데이터 필터링 let result = data.filter((row) => { // 행 경로 매칭 for (let i = 0; i < cellData.rowPath.length; i++) { const field = rowFields[i]; if (field && String(row[field.field]) !== cellData.rowPath[i]) { return false; } } // 열 경로 매칭 for (let i = 0; i < cellData.columnPath.length; i++) { const field = columnFields[i]; if (field && String(row[field.field]) !== cellData.columnPath[i]) { return false; } } return true; }); // 검색 필터 if (searchQuery) { const query = searchQuery.toLowerCase(); result = result.filter((row) => Object.values(row).some((val) => String(val).toLowerCase().includes(query) ) ); } // 정렬 if (sortConfig) { result = [...result].sort((a, b) => { const aVal = a[sortConfig.field]; const bVal = b[sortConfig.field]; if (aVal === null || aVal === undefined) return 1; if (bVal === null || bVal === undefined) return -1; let comparison = 0; if (typeof aVal === "number" && typeof bVal === "number") { comparison = aVal - bVal; } else { comparison = String(aVal).localeCompare(String(bVal)); } return sortConfig.direction === "asc" ? comparison : -comparison; }); } return result; }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); // 페이지네이션 const totalPages = Math.ceil(filteredData.length / pageSize); const paginatedData = useMemo(() => { const start = (currentPage - 1) * pageSize; return filteredData.slice(start, start + pageSize); }, [filteredData, currentPage, pageSize]); // 표시할 컬럼 결정 const displayColumns = useMemo(() => { // 모든 필드의 field명 수집 const fieldNames = new Set(); // fields에서 가져오기 fields.forEach((f) => fieldNames.add(f.field)); // 데이터에서 추가 컬럼 가져오기 if (data.length > 0) { Object.keys(data[0]).forEach((key) => fieldNames.add(key)); } return Array.from(fieldNames).map((fieldName) => { const fieldConfig = fields.find((f) => f.field === fieldName); return { field: fieldName, caption: fieldConfig?.caption || fieldName, dataType: fieldConfig?.dataType || "string", }; }); }, [fields, data]); // 정렬 토글 const handleSort = (field: string) => { setSortConfig((prev) => { if (!prev || prev.field !== field) { return { field, direction: "asc" }; } if (prev.direction === "asc") { return { field, direction: "desc" }; } return null; }); }; // CSV 내보내기 const handleExportCSV = () => { if (filteredData.length === 0) return; const headers = displayColumns.map((c) => c.caption); const rows = filteredData.map((row) => displayColumns.map((c) => { const val = row[c.field]; if (val === null || val === undefined) return ""; if (typeof val === "string" && val.includes(",")) { return `"${val}"`; } return String(val); }) ); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;", }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; link.click(); }; // 페이지 변경 const goToPage = (page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); }; // 경로 표시 const pathDisplay = cellData ? [ ...(cellData.rowPath.length > 0 ? [`행: ${cellData.rowPath.join(" > ")}`] : []), ...(cellData.columnPath.length > 0 ? [`열: ${cellData.columnPath.join(" > ")}`] : []), ].join(" | ") : ""; return ( 상세 데이터 {pathDisplay || "선택한 셀의 원본 데이터"} ({filteredData.length}건) {/* 툴바 */}
{ setSearchQuery(e.target.value); setCurrentPage(1); }} className="pl-9 h-9" />
{/* 테이블 */}
{displayColumns.map((col) => ( handleSort(col.field)} >
{col.caption} {sortConfig?.field === col.field ? ( sortConfig.direction === "asc" ? ( ) : ( ) ) : ( )}
))}
{paginatedData.length === 0 ? ( 데이터가 없습니다. ) : ( paginatedData.map((row, idx) => ( {displayColumns.map((col) => ( {formatCellValue(row[col.field], col.dataType)} ))} )) )}
{/* 페이지네이션 */} {totalPages > 1 && (
{(currentPage - 1) * pageSize + 1} -{" "} {Math.min(currentPage * pageSize, filteredData.length)} /{" "} {filteredData.length}건
{currentPage} / {totalPages}
)}
); }; // ==================== 유틸리티 ==================== function formatCellValue(value: any, dataType: string): string { if (value === null || value === undefined) return "-"; if (dataType === "number") { const num = Number(value); if (isNaN(num)) return String(value); return num.toLocaleString(); } if (dataType === "date") { try { const date = new Date(value); if (!isNaN(date.getTime())) { return date.toLocaleDateString("ko-KR"); } } catch { // 변환 실패 시 원본 반환 } } return String(value); } export default DrillDownModal;