From 36c1f3579e5284077dc452dedfcfb19512c2eddc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 14:42:52 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20DataGrid=20=E2=80=94=20log?= =?UTF-8?q?icstudio=20=EC=8A=A4=ED=83=80=EC=9D=BC=20toolbar=20+=20footer?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20+=20=EC=B0=A8=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=8C=A8=EB=84=90=20=ED=9D=A1=EC=88=98,=20?= =?UTF-8?q?=EA=B2=AC=EC=A0=81=EA=B4=80=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataGrid: - ⟳ Refresh · ⬇ Download · ⚙️ 컬럼 표시 설정 · 📊 차트 분석 toolbar - 컬럼 visibility 토글 (데이터/시스템 그룹 분리 + 표시·순서·너비 reset) - summaryStats 하단 통계 행 (라벨/값/접미사) - paginationStyle 'range' — "1-N / 총 X건" + 페이지 크기 Select - 행 높이 컴팩트화 (h-7 + py-0 + leading-none, 아이콘 h-3.5) - sticky 헤더 불투명 배경(bg-muted)으로 스크롤 시 본문 비침 차단 - ⋮⋮ 드래그 핸들 항상 표시 DataGridChartPanel (신규): - 여러 차트 추가/삭제, 제목 인라인 편집, 드래그 순서 변경 - Bar/Line/Pie + X/Y축 선택 + count/sum/avg/min/max 집계 - localStorage 영속, 360px 고정 높이 + 내부 스크롤 견적관리: - 컬럼 폭 조정 (⋮⋮ 추가로 좁아진 한국어 4글자 라벨 보장) - summaryStats, onRefresh, onDownload(exportToExcel) 연결 - gridId="sales-estimate"로 영속화 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(main)/COMPANY_16/sales/estimate/page.tsx | 59 ++- frontend/components/common/DataGrid.tsx | 323 ++++++++++++++-- .../components/common/DataGridChartPanel.tsx | 366 ++++++++++++++++++ 3 files changed, 703 insertions(+), 45 deletions(-) create mode 100644 frontend/components/common/DataGridChartPanel.tsx diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 25480e51..94b4e385 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -26,30 +26,31 @@ import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog" import { AttachmentDialog } from "@/components/common/AttachmentDialog"; import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog"; import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate"; +import { exportToExcel } from "@/lib/utils/excelExport"; // ─── 컬럼 ───────────────────────────────────────────────────── // wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름 const GRID_COLUMNS: DataGridColumn[] = [ { key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true }, - { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, + { key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" }, { key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" }, { key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" }, { key: "customer_name", label: "고객사", width: "w-[150px]" }, { key: "item_summary", label: "품명", width: "w-[200px]" }, - { key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true }, - { key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" }, - { key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true }, - { key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true }, - { key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" }, - { key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" }, - { key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" }, - { key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" }, - { key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" }, - { key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, + { key: "estimate_quantity", label: "견적수량", width: "w-[115px]", align: "right", formatNumber: true }, + { key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" }, + { key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true }, + { key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true }, + { key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" }, + { key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" }, + { key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" }, + { key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" }, + { key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" }, + { key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[120px]" }, - { key: "writer_name", label: "작성자", width: "w-[100px]" }, + { key: "writer_name", label: "작성자", width: "w-[115px]" }, /* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제. { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, @@ -251,6 +252,23 @@ export default function SalesEstimatePage() { useEffect(() => { fetchList(); }, [fetchList]); + // ─── 하단 통계 ────────────────────────────────────────────── + // 견적 건수 / 견적수량 합계 / 공급가액 합계 / 원화환산공급가액 합계 + const estimateSummary = useMemo(() => { + const count = rows.length; + const qtySum = rows.reduce((acc, r) => acc + Number(r.estimate_quantity || 0), 0); + const amtSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount || 0), 0); + const krwSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount_krw || 0), 0); + const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const intFmt = (n: number) => n.toLocaleString(); + return [ + { label: "견적 건수", value: intFmt(count), suffix: "건" }, + { label: "견적수량 합계", value: intFmt(qtySum) }, + { label: "공급가액 합계", value: money(amtSum) }, + { label: "원화환산공급가액 합계", value: money(krwSum) }, + ]; + }, [rows]); + // ─── 다이얼로그 열기 ──────────────────────────────────────── const openCreate = async () => { @@ -567,6 +585,7 @@ export default function SalesEstimatePage() { {/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */} { setSelected(row); openEdit(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} + showColumnSettings + paginationStyle="range" + pageSizeOptions={[10, 15, 20, 50, 100]} + summaryStats={estimateSummary} + systemColumnKeys={["writer_name"]} + onRefresh={fetchList} + onDownload={() => { + if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = rows.map((r) => { + const out: Record = {}; + GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "견적관리.xlsx", "견적관리"); + }} + showChart /> {/* 등록/수정 Dialog */} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 8b911afc..cef90a87 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -22,7 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; -import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip, Settings, GripVertical, RotateCw, Download as DownloadIcon, BarChart3 } from "lucide-react"; +import { DataGridChartPanel } from "./DataGridChartPanel"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; @@ -76,6 +78,22 @@ export interface DataGridProps { showPagination?: boolean; /** 초기 페이지 크기 (기본: 50) */ defaultPageSize?: number; + /** 페이지네이션 라벨 포맷 — 'classic'(기본): "전체 N건", 'range': "1-N / 총 X건" */ + paginationStyle?: "classic" | "range"; + /** 페이지 크기 드롭다운 옵션. 지정 시 Input 대신 Select 렌더. 예: [10,15,20,50,100] */ + pageSizeOptions?: number[]; + /** 컬럼 표시/숨김 설정 드롭다운 활성화 (gear 아이콘) */ + showColumnSettings?: boolean; + /** 시스템 컬럼으로 분리 표시할 key 배열. 표시 메뉴에서 '시스템 컬럼' 그룹으로 묶임 */ + systemColumnKeys?: string[]; + /** 하단 통계 행 — 평균/합계 등. footer에 좌측 배치 */ + summaryStats?: Array<{ label: string; value: string | number; suffix?: string }>; + /** 재조회 콜백. 지정 시 그리드 상단 toolbar에 ⟳ 아이콘 표시 */ + onRefresh?: () => void; + /** 다운로드 콜백 (Excel/CSV 등). 지정 시 그리드 상단 toolbar에 ⬇ 아이콘 표시 */ + onDownload?: () => void; + /** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */ + showChart?: boolean; } const fmtNum = (val: any) => { @@ -140,15 +158,19 @@ function SortableHeaderCell({ style={style} className={cn( widthPx == null && col.width, widthPx == null && col.minWidth, - "select-none relative", + "select-none relative group/th", col.frozen && cn("sticky z-20 bg-background", frozenLeftClass), )} > -
-
+ +
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); @@ -273,10 +295,64 @@ export function DataGrid({ gridId, showPagination = true, defaultPageSize = 50, + paginationStyle = "classic", + pageSizeOptions, + showColumnSettings = false, + systemColumnKeys, + summaryStats, + onRefresh, + onDownload, + showChart = false, }: DataGridProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); + // 차트 패널 열림 상태 (gridId 있으면 localStorage 영속) + const [chartPanelOpen, setChartPanelOpen] = useState(false); + useEffect(() => { + if (!gridId) return; + const saved = localStorage.getItem(`datagrid_chart_open_${gridId}`); + if (saved === "1") setChartPanelOpen(true); + }, [gridId]); + const toggleChartPanel = useCallback(() => { + setChartPanelOpen((prev) => { + const next = !prev; + if (gridId) { + try { localStorage.setItem(`datagrid_chart_open_${gridId}`, next ? "1" : "0"); } catch { /* skip */ } + } + return next; + }); + }, [gridId]); + + // 컬럼 visibility — 숨겨진 컬럼 key Set. localStorage 영속(gridId 있을 때). + const [hiddenColumns, setHiddenColumns] = useState>(new Set()); + useEffect(() => { + if (!gridId) return; + const saved = localStorage.getItem(`datagrid_col_hidden_${gridId}`); + if (saved) { + try { setHiddenColumns(new Set(JSON.parse(saved) as string[])); } catch { /* skip */ } + } + }, [gridId]); + const toggleColumnVisibility = useCallback((key: string) => { + setHiddenColumns((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + if (gridId) { + try { localStorage.setItem(`datagrid_col_hidden_${gridId}`, JSON.stringify(Array.from(next))); } catch { /* skip */ } + } + return next; + }); + }, [gridId]); + const resetVisibility = useCallback(() => { + setHiddenColumns(new Set()); + if (gridId) try { localStorage.removeItem(`datagrid_col_hidden_${gridId}`); } catch { /* skip */ } + }, [gridId]); + const resetOrder = useCallback(() => { + setColumns(initialColumns); + if (gridId) try { localStorage.removeItem(`datagrid_col_order_${gridId}`); } catch { /* skip */ } + }, [gridId, initialColumns]); + // resetWidthsAll은 setColumnWidths 선언 이후에 정의 (아래에서) + // 정렬 const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); @@ -331,6 +407,16 @@ export function DataGrid({ try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ } } }, [gridId]); + const resetWidthsAll = useCallback(() => { + setColumnWidths({}); + if (gridId) try { localStorage.removeItem(`datagrid_col_widths_${gridId}`); } catch { /* skip */ } + }, [gridId]); + + // 가시 컬럼 — hiddenColumns 적용. 헤더/바디 렌더에 사용. DnD/순서 영속은 전체 columns 기준 유지. + const visibleColumns = useMemo( + () => columns.filter((c) => !hiddenColumns.has(c.key)), + [columns, hiddenColumns], + ); // 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출. const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => { @@ -629,7 +715,7 @@ export function DataGrid({ const isRightAlign = col.align === "right" || isNumericCol; return ( - {display} @@ -637,22 +723,135 @@ export function DataGrid({ }; // 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치 - const hasFrozen = columns.some((c) => c.frozen); + const hasFrozen = visibleColumns.some((c) => c.frozen); const hasFirstCol = showCheckbox || showRowNumber; const stickyFirstColClass = "sticky left-0 z-20 bg-background"; const stickyFirstColBodyClass = "sticky left-0 z-[6]"; const frozenLeftClass = hasFirstCol ? "left-10" : "left-0"; + // 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정. + const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]); + const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]); + const systemCols = useMemo(() => columns.filter((c) => systemKeySet.has(c.key)), [columns, systemKeySet]); + + const hasToolbar = showColumnSettings || onRefresh || onDownload || showChart; + return (
+ {hasToolbar && ( +
+ {onRefresh && ( + + )} + {showChart && ( + + )} + {onDownload && ( + + )} + {showColumnSettings && ( + + + + + +
+ 표시할 컬럼 선택 +
+
+ {dataCols.length > 0 && ( +
+
데이터 컬럼
+
+ {dataCols.map((col) => { + const checked = !hiddenColumns.has(col.key); + return ( + + ); + })} +
+
+ )} + {systemCols.length > 0 && ( +
+
시스템 컬럼
+
+ {systemCols.map((col) => { + const checked = !hiddenColumns.has(col.key); + return ( + + ); + })} +
+
+ )} +
+
+ + + +
+
+
+ )} +
+ )}
- - c.key)} strategy={horizontalListSortingStrategy}> + + c.key)} strategy={horizontalListSortingStrategy}> {showCheckbox && ( - + 0 && checkedIds.length === processedData.length} onCheckedChange={(checked) => { @@ -662,9 +861,9 @@ export function DataGrid({ )} {showRowNumber && !showCheckbox && ( - No + No )} - {columns.map((col) => ( + {visibleColumns.map((col) => ( - + {loading ? ( - + 로딩 중... ) : paginatedData.length === 0 ? ( - + {emptyMessage} @@ -704,6 +903,7 @@ export function DataGrid({ return ( {pageOffset + rowIdx + 1} )} - {columns.map((col) => { + {visibleColumns.map((col) => { const w = columnWidths[col.key]; const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined; // 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외) @@ -759,7 +959,7 @@ export function DataGrid({ key={col.key} style={inlineStyle} className={cn( - w == null && col.width, w == null && col.minWidth, "py-2.5", + w == null && col.width, w == null && col.minWidth, "py-1", col.editable && "cursor-text", cellClickable && "cursor-pointer hover:underline text-primary", isSelected && "bg-accent", @@ -784,27 +984,79 @@ export function DataGrid({ + {/* 하단 통계 행 — summaryStats 지정 시. 페이지네이션 footer 위에 별도 띠로 노출. */} + {summaryStats && summaryStats.length > 0 && ( +
+ {summaryStats.map((s, i) => ( +
+ {s.label} + {s.value}{s.suffix ?? ""} +
+ ))} +
+ )} + {/* 페이지네이션 footer */} {showPagination && (
- {/* 좌측: 데이터 수량 + 페이지 크기 입력 */} + {/* 좌측: 데이터 수량 + 페이지 크기 입력/Select */}
- 전체 - {totalItems.toLocaleString()} - + {paginationStyle === "range" ? ( + <> + + {totalItems === 0 ? 0 : (pageOffset + 1).toLocaleString()}-{(pageOffset + paginatedData.length).toLocaleString()} + + / 총 + {totalItems.toLocaleString()} + + + ) : ( + <> + 전체 + {totalItems.toLocaleString()} + + + )}
- setPageSizeInput(e.target.value)} - onBlur={applyPageSize} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} - className="h-7 w-16 text-center text-xs" - /> - 건씩 보기 + {pageSizeOptions && pageSizeOptions.length > 0 ? ( + + ) : ( + <> + setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 + + )}
@@ -854,6 +1106,11 @@ export function DataGrid({
)} + {/* 차트 분석 패널 — showChart + chartPanelOpen 시 노출 */} + {showChart && chartPanelOpen && ( + + )} + {/* 이미지 확대 모달 */} {previewImage && (
= { + count: "건수", sum: "합계", avg: "평균", min: "최소", max: "최대", +}; +const CHART_TYPE_LABEL: Record = { + bar: "막대", line: "선", pie: "원형", +}; +const PIE_COLORS = ["#3b82f6", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"]; + +function newChartConfig(columns: DataGridColumn[]): ChartConfig { + const numericCols = columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"); + const firstCat = columns.find((c) => !numericCols.includes(c)); + const firstNum = numericCols[0]; + return { + id: `chart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: "제목 없는 차트", + type: "bar", + xKey: firstCat?.key ?? columns[0]?.key ?? "", + yKey: firstNum?.key ?? "", + agg: firstNum ? "sum" : "count", + }; +} + +function aggregate( + rows: Record[], + xKey: string, + yKey: string, + agg: AggType, +): Array<{ name: string; value: number }> { + const groups: Record = {}; + for (const row of rows) { + const xVal = row[xKey] == null || row[xKey] === "" ? "(빈 값)" : String(row[xKey]); + if (!groups[xVal]) groups[xVal] = []; + if (agg === "count") { + groups[xVal].push(1); + } else { + const raw = row[yKey]; + const n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/,/g, "")); + if (!isNaN(n)) groups[xVal].push(n); + } + } + return Object.entries(groups).map(([name, vals]) => { + let v: number; + if (agg === "count") v = vals.length; + else if (agg === "sum") v = vals.reduce((a, b) => a + b, 0); + else if (agg === "avg") v = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + else if (agg === "min") v = vals.length > 0 ? Math.min(...vals) : 0; + else v = vals.length > 0 ? Math.max(...vals) : 0; + return { name, value: Number(v.toFixed(2)) }; + }); +} + +// ─── 단일 차트 카드 ───────────────────────────────────────────── + +interface ChartCardProps { + cfg: ChartConfig; + columns: DataGridColumn[]; + data: Record[]; + onChange: (next: ChartConfig) => void; + onDelete: () => void; +} + +function SortableChartCard({ cfg, columns, data, onChange, onDelete }: ChartCardProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: cfg.id }); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(cfg.title); + + const numericCols = useMemo( + () => columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"), + [columns], + ); + + const chartData = useMemo( + () => aggregate(data, cfg.xKey, cfg.yKey, cfg.agg), + [data, cfg.xKey, cfg.yKey, cfg.agg], + ); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {/* 카드 헤더: 드래그 핸들 + 제목 + 삭제 */} +
+ + {editingTitle ? ( + setTitleDraft(e.target.value)} + onBlur={() => { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }} + onKeyDown={(e) => { + if (e.key === "Enter") { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); } + if (e.key === "Escape") { setTitleDraft(cfg.title); setEditingTitle(false); } + }} + className="h-7 text-sm flex-1" + /> + ) : ( + + )} + +
+ + {/* 컨트롤 행 */} +
+ 유형 + + + X축 + + + 집계 + + + {cfg.agg !== "count" && ( + <> + Y축 + + + )} +
+ + {/* 차트 본체 */} +
+ {chartData.length === 0 ? ( +
+ 데이터 없음 +
+ ) : ( + + {cfg.type === "bar" ? ( + + + + + + c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} /> + + ) : cfg.type === "line" ? ( + + + + + + c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} /> + + ) : ( + + + {chartData.map((_, idx) => )} + + + + + )} + + )} +
+
+ ); +} + +// ─── 패널 본체 ────────────────────────────────────────────────── + +interface DataGridChartPanelProps { + /** localStorage 키 prefix용 grid ID. 미지정 시 영속 비활성 */ + gridId?: string; + columns: DataGridColumn[]; + data: Record[]; +} + +export function DataGridChartPanel({ gridId, columns, data }: DataGridChartPanelProps) { + const [charts, setCharts] = useState([]); + const [collapsed, setCollapsed] = useState(false); + + const storageKey = gridId ? `datagrid_charts_${gridId}` : null; + + // 영속 로드 + useEffect(() => { + if (!storageKey) return; + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = JSON.parse(saved) as ChartConfig[]; + if (Array.isArray(parsed)) setCharts(parsed); + } + } catch { /* skip */ } + }, [storageKey]); + + const persist = useCallback((next: ChartConfig[]) => { + setCharts(next); + if (storageKey) { + try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* skip */ } + } + }, [storageKey]); + + const addChart = () => persist([...charts, newChartConfig(columns)]); + const updateChart = (id: string, next: ChartConfig) => persist(charts.map((c) => c.id === id ? next : c)); + const deleteChart = (id: string) => persist(charts.filter((c) => c.id !== id)); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + const handleDragEnd = (e: DragEndEvent) => { + const { active, over } = e; + if (!over || active.id === over.id) return; + const oldIdx = charts.findIndex((c) => c.id === active.id); + const newIdx = charts.findIndex((c) => c.id === over.id); + if (oldIdx < 0 || newIdx < 0) return; + persist(arrayMove(charts, oldIdx, newIdx)); + }; + + return ( +
+ {/* 패널 헤더 */} +
+
+ + 차트 분석 ({charts.length}개) +
+ +
+ + {/* 차트 카드들 — 내부 스크롤 */} + {!collapsed && ( +
+ {charts.length === 0 ? ( +
+ + 차트 추가 버튼을 눌러 분석 차트를 만들어보세요. +
+ ) : ( + + c.id)} strategy={verticalListSortingStrategy}> +
= 2 ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1")}> + {charts.map((cfg) => ( + updateChart(cfg.id, next)} + onDelete={() => deleteChart(cfg.id)} + /> + ))} +
+
+
+ )} +
+ )} +
+ ); +}