ERP-node/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx

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;