430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
"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<DrillDownModalProps> = ({
|
|
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<SortConfig | null>(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<string>();
|
|
|
|
// 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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>상세 데이터</DialogTitle>
|
|
<DialogDescription>
|
|
{pathDisplay || "선택한 셀의 원본 데이터"}
|
|
<span className="ml-2 text-primary font-medium">
|
|
({filteredData.length}건)
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 툴바 */}
|
|
<div className="flex items-center gap-2 py-2 border-b border-border">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
|
|
<Select
|
|
value={String(pageSize)}
|
|
onValueChange={(v) => {
|
|
setPageSize(Number(v));
|
|
setCurrentPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-28 h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10개씩</SelectItem>
|
|
<SelectItem value="20">20개씩</SelectItem>
|
|
<SelectItem value="50">50개씩</SelectItem>
|
|
<SelectItem value="100">100개씩</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleExportCSV}
|
|
disabled={filteredData.length === 0}
|
|
className="h-9"
|
|
>
|
|
<Download className="h-4 w-4 mr-1" />
|
|
CSV
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<ScrollArea className="flex-1 -mx-6">
|
|
<div className="px-6">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{displayColumns.map((col) => (
|
|
<TableHead
|
|
key={col.field}
|
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
|
onClick={() => handleSort(col.field)}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span>{col.caption}</span>
|
|
{sortConfig?.field === col.field ? (
|
|
sortConfig.direction === "asc" ? (
|
|
<ArrowUp className="h-3 w-3" />
|
|
) : (
|
|
<ArrowDown className="h-3 w-3" />
|
|
)
|
|
) : (
|
|
<ArrowUpDown className="h-3 w-3 opacity-30" />
|
|
)}
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{paginatedData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={displayColumns.length}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
데이터가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
paginatedData.map((row, idx) => (
|
|
<TableRow key={idx}>
|
|
{displayColumns.map((col) => (
|
|
<TableCell
|
|
key={col.field}
|
|
className={cn(
|
|
"whitespace-nowrap",
|
|
col.dataType === "number" && "text-right tabular-nums"
|
|
)}
|
|
>
|
|
{formatCellValue(row[col.field], col.dataType)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* 페이지네이션 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-border">
|
|
<div className="text-sm text-muted-foreground">
|
|
{(currentPage - 1) * pageSize + 1} -{" "}
|
|
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
|
|
{filteredData.length}건
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => goToPage(1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => goToPage(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<span className="px-3 text-sm">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => goToPage(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => goToPage(totalPages)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
<ChevronsRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
// ==================== 유틸리티 ====================
|
|
|
|
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;
|
|
|