From 52e6824e76d174ce89604a19170746246bfe7516 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 13:28:26 +0900 Subject: [PATCH 01/19] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj From 819a281df40a189d1cf82730b2a043a6d0b8d7b6 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 13:34:55 +0900 Subject: [PATCH 02/19] =?UTF-8?q?fix:=20=ED=94=BC=EB=B2=97=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/registry/components/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..b72b5154 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // ๋‚ด๋ถ€ ์ธ // ๐Ÿ†• ์—ฐ๊ด€ ๋ฐ์ดํ„ฐ ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ import "./related-data-buttons/RelatedDataButtonsRenderer"; // ์ขŒ์ธก ์„ ํƒ ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ์—ฐ๊ด€ ํ…Œ์ด๋ธ” ๋ฒ„ํŠผ ํ‘œ์‹œ +// ๐Ÿ†• ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ปดํฌ๋„ŒํŠธ +import "./pivot-grid/PivotGridRenderer"; // ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” (ํ–‰/์—ด ๊ทธ๋ฃนํ™”, ์ง‘๊ณ„, ๋“œ๋ฆด๋‹ค์šด) + /** * ์ปดํฌ๋„ŒํŠธ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜ */ From d49883d25fb4ae1f98a7ad2c131c1485ef7ef13e Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 14:41:27 +0900 Subject: [PATCH 03/19] =?UTF-8?q?=ED=94=BC=EB=B2=97=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 522 ++++++- .../pivot-grid/PivotGridConfigPanel.tsx | 1247 ++++++----------- .../pivot-grid/components/ContextMenu.tsx | 213 +++ .../pivot-grid/components/FieldPanel.tsx | 52 +- .../components/pivot-grid/components/index.ts | 1 + 5 files changed, 1122 insertions(+), 913 deletions(-) create mode 100644 frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index e7904a95..b0e8d207 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * ๋‹ค์ฐจ์› ๋ฐ์ดํ„ฐ ๋ถ„์„์„ ์œ„ํ•œ ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” */ -import React, { useState, useMemo, useCallback, useEffect } from "react"; +import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,7 +15,6 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, - PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; import { exportPivotToExcel } from "./utils/exportExcel"; @@ -24,6 +23,8 @@ import { FieldPanel } from "./components/FieldPanel"; import { FieldChooser } from "./components/FieldChooser"; import { DrillDownModal } from "./components/DrillDownModal"; import { PivotChart } from "./components/PivotChart"; +import { FilterPopup } from "./components/FilterPopup"; +import { useVirtualScroll } from "./hooks/useVirtualScroll"; import { ChevronRight, ChevronDown, @@ -35,6 +36,10 @@ import { LayoutGrid, FileSpreadsheet, BarChart3, + Filter, + ArrowUp, + ArrowDown, + ArrowUpDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -88,6 +93,7 @@ const RowHeaderCell: React.FC = ({ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; + isSelected?: boolean; onClick?: () => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; @@ -96,6 +102,7 @@ interface DataCellProps { const DataCell: React.FC = ({ values, isTotal = false, + isSelected = false, onClick, onDoubleClick, conditionalStyle, @@ -104,6 +111,9 @@ const DataCell: React.FC = ({ const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; const icon = conditionalStyle?.icon; + + // ์„ ํƒ ์ƒํƒœ ์Šคํƒ€์ผ + const selectedClass = isSelected && "ring-2 ring-primary ring-inset bg-primary/10"; if (!values || values.length === 0) { return ( @@ -111,7 +121,8 @@ const DataCell: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1.5 text-right text-sm", - isTotal && "bg-primary/5 font-medium" + isTotal && "bg-primary/5 font-medium", + selectedClass )} style={cellStyle} onClick={onClick} @@ -130,7 +141,8 @@ const DataCell: React.FC = ({ "border-r border-b border-border relative", "px-2 py-1.5 text-right text-sm tabular-nums", isTotal && "bg-primary/5 font-medium", - (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50" + (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50", + selectedClass )} style={cellStyle} onClick={onClick} @@ -164,7 +176,8 @@ const DataCell: React.FC = ({ "border-r border-b border-border relative", "px-2 py-1.5 text-right text-sm tabular-nums", isTotal && "bg-primary/5 font-medium", - (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50" + (onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50", + selectedClass )} style={cellStyle} onClick={onClick} @@ -237,13 +250,28 @@ export const PivotGridComponent: React.FC = ({ filterConfig: {}, }); const [isFullscreen, setIsFullscreen] = useState(false); - const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldPanel, setShowFieldPanel] = useState(false); // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ ‘ํžŒ ์ƒํƒœ const [showFieldChooser, setShowFieldChooser] = useState(false); const [drillDownData, setDrillDownData] = useState<{ open: boolean; cellData: PivotCellData | null; }>({ open: false, cellData: null }); const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + const [containerHeight, setContainerHeight] = useState(400); + const tableContainerRef = useRef(null); + + // ์…€ ์„ ํƒ ์ƒํƒœ + const [selectedCell, setSelectedCell] = useState<{ + rowIndex: number; + colIndex: number; + } | null>(null); + const tableRef = useRef(null); + + // ์ •๋ ฌ ์ƒํƒœ + const [sortConfig, setSortConfig] = useState<{ + field: string; + direction: "asc" | "desc"; + } | null>(null); // ์™ธ๋ถ€ fields ๋ณ€๊ฒฝ ์‹œ ๋™๊ธฐํ™” useEffect(() => { @@ -281,6 +309,7 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + // ํ•„ํ„ฐ ์˜์—ญ ํ•„๋“œ const filterFields = useMemo( () => fields @@ -318,25 +347,53 @@ export const PivotGridComponent: React.FC = ({ }); }, [data, fields]); + // ==================== ํ•„ํ„ฐ ์ ์šฉ ==================== + + const filteredData = useMemo(() => { + if (!data || data.length === 0) return data; + + // ํ•„ํ„ฐ ์˜์—ญ์˜ ํ•„๋“œ๋“ค๋กœ ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง + const activeFilters = fields.filter( + (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + ); + + if (activeFilters.length === 0) return data; + + return data.filter((row) => { + return activeFilters.every((filter) => { + const value = row[filter.field]; + const filterValues = filter.filterValues || []; + const filterType = filter.filterType || "include"; + + if (filterType === "include") { + return filterValues.includes(value); + } else { + return !filterValues.includes(value); + } + }); + }); + }, [data, fields]); + // ==================== ํ”ผ๋ฒ— ์ฒ˜๋ฆฌ ==================== const pivotResult = useMemo(() => { - if (!data || data.length === 0 || fields.length === 0) { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { return null; } const visibleFields = fields.filter((f) => f.visible !== false); - if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + // ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜ (ํ•„ํ„ฐ๋Š” ์ œ์™ธ) + if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { return null; } return processPivotData( - data, + filteredData, visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); - }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); // ์กฐ๊ฑด๋ถ€ ์„œ์‹์šฉ ์ „์ฒด ๊ฐ’ ์ˆ˜์ง‘ const allCellValues = useMemo(() => { @@ -380,6 +437,42 @@ export const PivotGridComponent: React.FC = ({ return valuesByField; }, [pivotResult]); + // ==================== ๊ฐ€์ƒ ์Šคํฌ๋กค ==================== + + const ROW_HEIGHT = 32; // ํ–‰ ๋†’์ด (px) + const VIRTUAL_SCROLL_THRESHOLD = 50; // ์ด ํ–‰ ์ˆ˜ ์ด์ƒ์ด๋ฉด ๊ฐ€์ƒ ์Šคํฌ๋กค ํ™œ์„ฑํ™” + + // ์ปจํ…Œ์ด๋„ˆ ๋†’์ด ์ธก์ • + useEffect(() => { + if (!tableContainerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(tableContainerRef.current); + return () => observer.disconnect(); + }, []); + + // ๊ฐ€์ƒ ์Šคํฌ๋กค ํ›… ์‚ฌ์šฉ + const flatRows = pivotResult?.flatRows || []; + const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + const virtualScroll = useVirtualScroll({ + itemCount: flatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // ๊ฐ€์ƒ ์Šคํฌ๋กค ์ ์šฉ๋œ ํ–‰ ๋ฐ์ดํ„ฐ + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return flatRows; + return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ ๊ณ„์‚ฐ ํ—ฌํผ const getCellConditionalStyle = useCallback( (value: number | undefined, field: string): CellFormatStyle => { @@ -587,9 +680,9 @@ export const PivotGridComponent: React.FC = ({ ); } - // ํ•„๋“œ ๋ฏธ์„ค์ • + // ํ•„๋“œ ๋ฏธ์„ค์ • (ํ–‰, ์—ด, ๋ฐ์ดํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ) const hasActiveFields = fields.some( - (f) => f.visible !== false && f.area !== "filter" + (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) ); if (!hasActiveFields) { return ( @@ -646,7 +739,125 @@ export const PivotGridComponent: React.FC = ({ ); } - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + const { flatColumns, dataMatrix, grandTotals } = pivotResult; + + // ==================== ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ==================== + + // ํ‚ค๋ณด๋“œ ํ•ธ๋“ค๋Ÿฌ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selectedCell) return; + + const { rowIndex, colIndex } = selectedCell; + const maxRowIndex = visibleFlatRows.length - 1; + const maxColIndex = flatColumns.length - 1; + + let newRowIndex = rowIndex; + let newColIndex = colIndex; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 1); + break; + case "ArrowDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 1); + break; + case "ArrowLeft": + e.preventDefault(); + newColIndex = Math.max(0, colIndex - 1); + break; + case "ArrowRight": + e.preventDefault(); + newColIndex = Math.min(maxColIndex, colIndex + 1); + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = 0; + newColIndex = 0; + } else { + newColIndex = 0; + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + newRowIndex = maxRowIndex; + newColIndex = maxColIndex; + } else { + newColIndex = maxColIndex; + } + break; + case "PageUp": + e.preventDefault(); + newRowIndex = Math.max(0, rowIndex - 10); + break; + case "PageDown": + e.preventDefault(); + newRowIndex = Math.min(maxRowIndex, rowIndex + 10); + break; + case "Enter": + e.preventDefault(); + // ์…€ ๋”๋ธ”ํด๋ฆญ๊ณผ ๋™์ผํ•œ ๋™์ž‘ (๋“œ๋ฆด๋‹ค์šด) + if (visibleFlatRows[rowIndex] && flatColumns[colIndex]) { + const row = visibleFlatRows[rowIndex]; + const col = flatColumns[colIndex]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + // ๋“œ๋ฆด๋‹ค์šด ๋ชจ๋‹ฌ ์—ด๊ธฐ + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath: row.path, + columnPath: col.path, + field: values[0]?.field, + }; + setDrillDownData({ open: true, cellData }); + } + break; + case "Escape": + e.preventDefault(); + setSelectedCell(null); + break; + default: + return; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // ์…€ ํด๋ฆญ์œผ๋กœ ์„ ํƒ + const handleCellSelect = (rowIndex: number, colIndex: number) => { + setSelectedCell({ rowIndex, colIndex }); + }; + + // ์ •๋ ฌ ํ† ๊ธ€ + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (prev?.field === field) { + // ๊ฐ™์€ ํ•„๋“œ ํด๋ฆญ: asc -> desc -> null ์ˆœํ™˜ + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; // ์ •๋ ฌ ํ•ด์ œ + } + // ์ƒˆ๋กœ์šด ํ•„๋“œ: asc๋กœ ์‹œ์ž‘ + return { field, direction: "asc" }; + }); + }; + + // ์ •๋ ฌ ์•„์ด์ฝ˜ ๋ Œ๋”๋ง + const SortIcon = ({ field }: { field: string }) => { + if (sortConfig?.field !== field) { + return ; + } + if (sortConfig.direction === "asc") { + return ; + } + return ; + }; return (
= ({
{title &&

{title}

} - ({data.length}๊ฑด) + ({filteredData.length !== data.length + ? `${filteredData.length} / ${data.length}๊ฑด` + : `${data.length}๊ฑด`})
@@ -780,13 +993,68 @@ export const PivotGridComponent: React.FC = ({
+ {/* ํ•„ํ„ฐ ๋ฐ” - ํ•„ํ„ฐ ์˜์—ญ์— ํ•„๋“œ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {filterFields.length > 0 && ( +
+ + ํ•„ํ„ฐ: +
+ {filterFields.map((filterField) => { + const selectedValues = filterField.filterValues || []; + const isFiltered = selectedValues.length > 0; + + return ( + { + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, filterValues: values, filterType: type } + : f + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ); + })} +
+
+ )} + {/* ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” */} -
- +
+
{/* ์—ด ํ—ค๋” */} - {/* ์ขŒ์ƒ๋‹จ ์ฝ”๋„ˆ (ํ–‰ ํ•„๋“œ ๋ผ๋ฒจ) */} + {/* ์ขŒ์ƒ๋‹จ ์ฝ”๋„ˆ (ํ–‰ ํ•„๋“œ ๋ผ๋ฒจ + ํ•„ํ„ฐ) */} {/* ์—ด ํ—ค๋” ์…€ */} @@ -805,13 +1104,59 @@ export const PivotGridComponent: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1.5 text-center text-xs font-medium", - "bg-muted/70 sticky top-0 z-10" + "bg-muted/70 sticky top-0 z-10", + dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" )} colSpan={dataFields.length || 1} + onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} > - {col.caption || "(์ „์ฒด)"} +
+ {col.caption || "(์ „์ฒด)"} + {dataFields.length === 1 && } +
))} + + {/* ์—ด ํ•„๋“œ ํ•„ํ„ฐ (ํ—ค๋” ์™ผ์ชฝ์— ํ‘œ์‹œ) */} + {columnFields.length > 0 && ( +
+ )} {/* ํ–‰ ์ด๊ณ„ ํ—ค๋” */} {totals?.showRowGrandTotals && ( @@ -839,10 +1184,14 @@ export const PivotGridComponent: React.FC = ({ className={cn( "border-r border-b border-border", "px-2 py-1 text-center text-xs font-normal", - "text-muted-foreground" + "text-muted-foreground cursor-pointer hover:bg-accent/50" )} + onClick={() => handleSort(df.field)} > - {df.caption} +
+ {df.caption} + +
))} @@ -865,59 +1214,84 @@ export const PivotGridComponent: React.FC = ({
- {flatRows.map((row, rowIdx) => ( - - {/* ํ–‰ ํ—ค๋” */} - - - {/* ๋ฐ์ดํ„ฐ ์…€ */} - {flatColumns.map((col, colIdx) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey) || []; - - // ์กฐ๊ฑด๋ถ€ ์„œ์‹ (์ฒซ ๋ฒˆ์งธ ๊ฐ’ ๊ธฐ์ค€) - const conditionalStyle = - values.length > 0 && values[0].field - ? getCellConditionalStyle(values[0].value, values[0].field) - : undefined; - - return ( - handleCellClick(row.path, col.path, values) - : undefined - } - onDoubleClick={() => - handleCellDoubleClick(row.path, col.path, values) - } - /> - ); - })} - - {/* ํ–‰ ์ด๊ณ„ */} - {totals?.showRowGrandTotals && ( - - )} + {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ์ƒ๋‹จ ์—ฌ๋ฐฑ */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + - ))} + )} + + {visibleFlatRows.map((row, idx) => { + // ์‹ค์ œ ํ–‰ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + + return ( + + {/* ํ–‰ ํ—ค๋” */} + + + {/* ๋ฐ์ดํ„ฐ ์…€ */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // ์กฐ๊ฑด๋ถ€ ์„œ์‹ (์ฒซ ๋ฒˆ์งธ ๊ฐ’ ๊ธฐ์ค€) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) + : undefined; + + // ์„ ํƒ ์ƒํƒœ ํ™•์ธ + const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx; + + return ( + { + handleCellSelect(rowIdx, colIdx); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* ํ–‰ ์ด๊ณ„ */} + {totals?.showRowGrandTotals && ( + + )} + + ); + })} + + {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ํ•˜๋‹จ ์—ฌ๋ฐฑ */} + {enableVirtualScroll && ( + + + )} {/* ์—ด ์ด๊ณ„ ํ–‰ */} {totals?.showColumnGrandTotals && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index f3e9a976..ba691afa 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -1,8 +1,11 @@ "use client"; /** - * PivotGrid ์„ค์ • ํŒจ๋„ - * ํ™”๋ฉด ๊ด€๋ฆฌ์—์„œ PivotGrid ์ปดํฌ๋„ŒํŠธ๋ฅผ ์„ค์ •ํ•˜๋Š” UI + * PivotGrid ์„ค์ • ํŒจ๋„ - ๊ฐ„์†Œํ™” ๋ฒ„์ „ + * + * ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ์„ค์ • ๋ฐฉ๋ฒ•: + * 1. ํ…Œ์ด๋ธ” ์„ ํƒ + * 2. ์ปฌ๋Ÿผ์„ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ํ–‰/์—ด/๊ฐ’ ์˜์—ญ์— ๋ฐฐ์น˜ */ import React, { useState, useEffect, useCallback } from "react"; @@ -12,14 +15,12 @@ import { PivotFieldConfig, PivotAreaType, AggregationType, - DateGroupInterval, FieldDataType, } from "./types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -29,24 +30,20 @@ import { SelectValue, } from "@/components/ui/select"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { - Plus, - Trash2, - GripVertical, - Settings2, Rows, Columns, - Database, - Filter, - ChevronUp, + Calculator, + X, + Plus, + GripVertical, + Table2, + BarChart3, + Settings, ChevronDown, + ChevronUp, + Info, } from "lucide-react"; -import { apiClient } from "@/lib/api/client"; +import { tableTypeApi } from "@/lib/api/screen"; // ==================== ํƒ€์ž… ==================== @@ -59,7 +56,6 @@ interface ColumnInfo { column_name: string; data_type: string; column_comment?: string; - is_nullable: string; } interface PivotGridConfigPanelProps { @@ -67,57 +63,13 @@ interface PivotGridConfigPanelProps { onChange: (config: PivotGridComponentConfig) => void; } -// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ==================== - -const AREA_LABELS: Record = { - row: { label: "ํ–‰ ์˜์—ญ", icon: }, - column: { label: "์—ด ์˜์—ญ", icon: }, - data: { label: "๋ฐ์ดํ„ฐ ์˜์—ญ", icon: }, - filter: { label: "ํ•„ํ„ฐ ์˜์—ญ", icon: }, -}; - -const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ - { value: "sum", label: "ํ•ฉ๊ณ„" }, - { value: "count", label: "๊ฐœ์ˆ˜" }, - { value: "avg", label: "ํ‰๊ท " }, - { value: "min", label: "์ตœ์†Œ" }, - { value: "max", label: "์ตœ๋Œ€" }, - { value: "countDistinct", label: "๊ณ ์œ ๊ฐ’ ๊ฐœ์ˆ˜" }, -]; - -const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ - { value: "year", label: "์—ฐ๋„" }, - { value: "quarter", label: "๋ถ„๊ธฐ" }, - { value: "month", label: "์›”" }, - { value: "week", label: "์ฃผ" }, - { value: "day", label: "์ผ" }, -]; - -const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ - { value: "string", label: "๋ฌธ์ž์—ด" }, - { value: "number", label: "์ˆซ์ž" }, - { value: "date", label: "๋‚ ์งœ" }, - { value: "boolean", label: "๋ถ€์šธ" }, -]; - // DB ํƒ€์ž…์„ FieldDataType์œผ๋กœ ๋ณ€ํ™˜ function mapDbTypeToFieldType(dbType: string): FieldDataType { const type = dbType.toLowerCase(); - if ( - type.includes("int") || - type.includes("numeric") || - type.includes("decimal") || - type.includes("float") || - type.includes("double") || - type.includes("real") - ) { + if (type.includes("int") || type.includes("numeric") || type.includes("decimal") || type.includes("float")) { return "number"; } - if ( - type.includes("date") || - type.includes("time") || - type.includes("timestamp") - ) { + if (type.includes("date") || type.includes("time") || type.includes("timestamp")) { return "date"; } if (type.includes("bool")) { @@ -126,332 +78,174 @@ function mapDbTypeToFieldType(dbType: string): FieldDataType { return "string"; } -// ==================== ํ•„๋“œ ์„ค์ • ์ปดํฌ๋„ŒํŠธ ==================== +// ==================== ์ปฌ๋Ÿผ ์นฉ ์ปดํฌ๋„ŒํŠธ ==================== -interface FieldConfigItemProps { - field: PivotFieldConfig; - index: number; - onChange: (field: PivotFieldConfig) => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; - isFirst: boolean; - isLast: boolean; +interface ColumnChipProps { + column: ColumnInfo; + isUsed: boolean; + onClick: () => void; } -const FieldConfigItem: React.FC = ({ - field, - index, - onChange, - onRemove, - onMoveUp, - onMoveDown, - isFirst, - isLast, -}) => { +const ColumnChip: React.FC = ({ column, isUsed, onClick }) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const typeColor = { + number: "bg-blue-100 text-blue-700 border-blue-200", + string: "bg-green-100 text-green-700 border-green-200", + date: "bg-purple-100 text-purple-700 border-purple-200", + boolean: "bg-orange-100 text-orange-700 border-orange-200", + }[dataType]; + return ( -
- {/* ๋“œ๋ž˜๊ทธ ํ•ธ๋“ค & ์ˆœ์„œ ๋ฒ„ํŠผ */} -
- - - -
- - {/* ํ•„๋“œ ์„ค์ • */} -
- {/* ํ•„๋“œ๋ช… & ๋ผ๋ฒจ */} -
-
- - onChange({ ...field, field: e.target.value })} - placeholder="column_name" - className="h-8 text-xs" - /> -
-
- - onChange({ ...field, caption: e.target.value })} - placeholder="ํ‘œ์‹œ๋ช…" - className="h-8 text-xs" - /> -
-
- - {/* ๋ฐ์ดํ„ฐ ํƒ€์ž… & ์ง‘๊ณ„ ํ•จ์ˆ˜ */} -
-
- - -
- - {field.area === "data" && ( -
- - -
- )} - - {field.dataType === "date" && - (field.area === "row" || field.area === "column") && ( -
- - -
- )} -
-
- - {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} - -
+ ); }; -// ==================== ์˜์—ญ๋ณ„ ํ•„๋“œ ๋ชฉ๋ก ==================== +// ==================== ์˜์—ญ ๋“œ๋กญ์กด ์ปดํฌ๋„ŒํŠธ ==================== -interface AreaFieldListProps { +interface AreaDropZoneProps { area: PivotAreaType; + label: string; + description: string; + icon: React.ReactNode; fields: PivotFieldConfig[]; - allColumns: ColumnInfo[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; + columns: ColumnInfo[]; + onAddField: (column: ColumnInfo) => void; + onRemoveField: (index: number) => void; + onUpdateField: (index: number, updates: Partial) => void; + color: string; } -const AreaFieldList: React.FC = ({ +const AreaDropZone: React.FC = ({ area, + label, + description, + icon, fields, - allColumns, - onFieldsChange, + columns, + onAddField, + onRemoveField, + onUpdateField, + color, }) => { - const areaFields = fields.filter((f) => f.area === area); - const { label, icon } = AREA_LABELS[area]; - - const handleAddField = () => { - const newField: PivotFieldConfig = { - field: "", - caption: "", - area, - areaIndex: areaFields.length, - dataType: "string", - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleAddFromColumn = (column: ColumnInfo) => { - const dataType = mapDbTypeToFieldType(column.data_type); - const newField: PivotFieldConfig = { - field: column.column_name, - caption: column.column_comment || column.column_name, - area, - areaIndex: areaFields.length, - dataType, - visible: true, - }; - if (area === "data") { - newField.summaryType = "sum"; - } - onFieldsChange([...fields, newField]); - }; - - const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { - const newFields = [...fields]; - const globalIndex = fields.findIndex( - (f) => f.area === area && f.areaIndex === index - ); - if (globalIndex >= 0) { - newFields[globalIndex] = updatedField; - onFieldsChange(newFields); - } - }; - - const handleRemoveField = (index: number) => { - const newFields = fields.filter( - (f) => !(f.area === area && f.areaIndex === index) - ); - // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ - let idx = 0; - newFields.forEach((f) => { - if (f.area === area) { - f.areaIndex = idx++; - } - }); - onFieldsChange(newFields); - }; - - const handleMoveField = (fromIndex: number, direction: "up" | "down") => { - const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; - if (toIndex < 0 || toIndex >= areaFields.length) return; - - const newAreaFields = [...areaFields]; - const [moved] = newAreaFields.splice(fromIndex, 1); - newAreaFields.splice(toIndex, 0, moved); - - // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ - newAreaFields.forEach((f, idx) => { - f.areaIndex = idx; - }); - - // ์ „์ฒด ํ•„๋“œ ์—…๋ฐ์ดํŠธ - const newFields = fields.filter((f) => f.area !== area); - onFieldsChange([...newFields, ...newAreaFields]); - }; - - // ์ด๋ฏธ ์ถ”๊ฐ€๋œ ์ปฌ๋Ÿผ ์ œ์™ธ - const availableColumns = allColumns.filter( + const [isExpanded, setIsExpanded] = useState(true); + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ (์ด๋ฏธ ์ถ”๊ฐ€๋œ ์ปฌ๋Ÿผ ์ œ์™ธ) + const availableColumns = columns.filter( (col) => !fields.some((f) => f.field === col.column_name) ); return ( - - +
+ {/* ํ—ค๋” */} +
setIsExpanded(!isExpanded)} + >
{icon} - {label} - - {areaFields.length} + {label} + + {fields.length}
- - - {/* ํ•„๋“œ ๋ชฉ๋ก */} - {areaFields - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) - .map((field, idx) => ( - handleFieldChange(field.areaIndex || idx, f)} - onRemove={() => handleRemoveField(field.areaIndex || idx)} - onMoveUp={() => handleMoveField(idx, "up")} - onMoveDown={() => handleMoveField(idx, "down")} - isFirst={idx === 0} - isLast={idx === areaFields.length - 1} - /> - ))} + {isExpanded ? : } +
+ + {/* ์„ค๋ช… */} +

{description}

- {/* ํ•„๋“œ ์ถ”๊ฐ€ */} -
- onUpdateField(idx, { summaryType: v as AggregationType })} + > + + + + + ํ•ฉ๊ณ„ + ๊ฐœ์ˆ˜ + ํ‰๊ท  + ์ตœ์†Œ + ์ตœ๋Œ€ + + + )} + + +
+ ))} +
+ ) : ( +
+ ์•„๋ž˜์—์„œ ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š” +
+ )} + + {/* ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ๋“œ๋กญ๋‹ค์šด */} + {availableColumns.length > 0 && ( + - - + ))} + + + )} - -
+ )} + ); }; @@ -465,17 +259,19 @@ export const PivotGridConfigPanel: React.FC = ({ const [columns, setColumns] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); // ํ…Œ์ด๋ธ” ๋ชฉ๋ก ๋กœ๋“œ useEffect(() => { const loadTables = async () => { setLoadingTables(true); try { - // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ - const response = await apiClient.get("/table-management/tables"); - if (response.data.success) { - setTables(response.data.data || []); - } + const tableList = await tableTypeApi.getTables(); + const mappedTables: TableInfo[] = tableList.map((t: any) => ({ + table_name: t.tableName, + table_comment: t.tableLabel || t.displayName || t.tableName, + })); + setTables(mappedTables); } catch (error) { console.error("ํ…Œ์ด๋ธ” ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); } finally { @@ -495,13 +291,13 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { - // apiClient์˜ baseURL์ด ์ด๋ฏธ /api๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ /api ์ œ์™ธ - const response = await apiClient.get( - `/table-management/tables/${config.dataSource.tableName}/columns` - ); - if (response.data.success) { - setColumns(response.data.data || []); - } + const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); + const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ + column_name: c.columnName || c.column_name, + data_type: c.dataType || c.data_type || "text", + column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name, + })); + setColumns(mappedColumns); } catch (error) { console.error("์ปฌ๋Ÿผ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); } finally { @@ -519,489 +315,288 @@ export const PivotGridConfigPanel: React.FC = ({ [config, onChange] ); + // ํ•„๋“œ ์ถ”๊ฐ€ + const handleAddField = (area: PivotAreaType, column: ColumnInfo) => { + const currentFields = config.fields || []; + const areaFields = currentFields.filter(f => f.area === area); + + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType: mapDbTypeToFieldType(column.data_type), + visible: true, + }; + + if (area === "data") { + newField.summaryType = "sum"; + } + + updateConfig({ fields: [...currentFields, newField] }); + }; + + // ํ•„๋“œ ์ œ๊ฑฐ + const handleRemoveField = (area: PivotAreaType, index: number) => { + const currentFields = config.fields || []; + const newFields = currentFields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + + // ์ธ๋ฑ์Šค ์žฌ์ •๋ ฌ + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + + updateConfig({ fields: newFields }); + }; + + // ํ•„๋“œ ์—…๋ฐ์ดํŠธ + const handleUpdateField = (area: PivotAreaType, index: number, updates: Partial) => { + const currentFields = config.fields || []; + const newFields = currentFields.map((f) => { + if (f.area === area && f.areaIndex === index) { + return { ...f, ...updates }; + } + return f; + }); + updateConfig({ fields: newFields }); + }; + + // ์˜์—ญ๋ณ„ ํ•„๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + const getFieldsByArea = (area: PivotAreaType) => { + return (config.fields || []) + .filter(f => f.area === area) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + }; + return (
- {/* ๋ฐ์ดํ„ฐ ์†Œ์Šค ์„ค์ • */} -
- - -
- - + {/* ์‚ฌ์šฉ ๊ฐ€์ด๋“œ */} +
+
+ +
+

ํ”ผ๋ฒ— ํ…Œ์ด๋ธ” ์„ค์ • ๋ฐฉ๋ฒ•

+
    +
  1. ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ํ…Œ์ด๋ธ”์„ ์„ ํƒํ•˜์„ธ์š”
  2. +
  3. ํ–‰ ๊ทธ๋ฃน์— ๊ทธ๋ฃนํ™”ํ•  ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ์ง€์—ญ, ๋ถ€์„œ)
  4. +
  5. ์—ด ๊ทธ๋ฃน์— ๊ฐ€๋กœ๋กœ ํŽผ์น  ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ์›”, ๋ถ„๊ธฐ)
  6. +
  7. ๊ฐ’์— ์ง‘๊ณ„ํ•  ์ˆซ์ž ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” (์˜ˆ: ๋งค์ถœ, ์ˆ˜๋Ÿ‰)
  8. +
+
- + {/* STEP 1: ํ…Œ์ด๋ธ” ์„ ํƒ */} +
+
+ + +
+ + +
- {/* ํ•„๋“œ ์„ค์ • */} + {/* STEP 2: ํ•„๋“œ ๋ฐฐ์น˜ */} {config.dataSource?.tableName && (
-
- - - {columns.length}๊ฐœ ์ปฌ๋Ÿผ - +
+ + + {loadingColumns && (์ปฌ๋Ÿผ ๋กœ๋”ฉ ์ค‘...)}
- {loadingColumns ? ( -
- ์ปฌ๋Ÿผ ๋กœ๋”ฉ ์ค‘... + {/* ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ ๋ชฉ๋ก */} + {columns.length > 0 && ( +
+ +
+ {columns.map((col) => { + const isUsed = (config.fields || []).some(f => f.field === col.column_name); + return ( + {/* ํด๋ฆญ ์‹œ ์•„๋ฌด๊ฒƒ๋„ ์•ˆํ•จ - ๋“œ๋กญ์กด์—์„œ ์ถ”๊ฐ€ */}} + /> + ); + })} +
- ) : ( - - {(["row", "column", "data", "filter"] as PivotAreaType[]).map( - (area) => ( - updateConfig({ fields })} - /> - ) - )} - )} + + {/* ์˜์—ญ๋ณ„ ๋“œ๋กญ์กด */} +
+ } + fields={getFieldsByArea("row")} + columns={columns} + onAddField={(col) => handleAddField("row", col)} + onRemoveField={(idx) => handleRemoveField("row", idx)} + onUpdateField={(idx, updates) => handleUpdateField("row", idx, updates)} + color="border-emerald-200 bg-emerald-50/50" + /> + + } + fields={getFieldsByArea("column")} + columns={columns} + onAddField={(col) => handleAddField("column", col)} + onRemoveField={(idx) => handleRemoveField("column", idx)} + onUpdateField={(idx, updates) => handleUpdateField("column", idx, updates)} + color="border-blue-200 bg-blue-50/50" + /> + + } + fields={getFieldsByArea("data")} + columns={columns} + onAddField={(col) => handleAddField("data", col)} + onRemoveField={(idx) => handleRemoveField("data", idx)} + onUpdateField={(idx, updates) => handleUpdateField("data", idx, updates)} + color="border-amber-200 bg-amber-50/50" + /> +
)} - - - {/* ํ‘œ์‹œ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - totals: { ...config.totals, showRowGrandTotals: v }, - }) - } - /> + {/* ๊ณ ๊ธ‰ ์„ค์ • ํ† ๊ธ€ */} +
+
- -
- - - updateConfig({ - style: { ...config.style, alternateRowColors: v }, - }) - } - /> -
- -
- - - updateConfig({ - style: { ...config.style, highlightTotals: v }, - }) - } - /> -
+ {showAdvanced ? : } +
- - - {/* ๊ธฐ๋Šฅ ์„ค์ • */} -
- - -
-
- - - updateConfig({ allowExpandAll: v }) - } - /> -
- -
- - - updateConfig({ - exportConfig: { ...config.exportConfig, excel: v }, - }) - } - /> -
-
-
- - - - {/* ์ฐจํŠธ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - chart: { - ...config.chart, - enabled: v, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - }, - }) - } - /> -
- - {config.chart?.enabled && ( -
-
- - -
- -
- - - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - height: Number(e.target.value), - }, - }) - } - className="h-8 text-xs" />
- -
- + +
+ - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - showLegend: v, - }, - }) + updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, alternateRowColors: v } }) + } + /> +
+ +
+ + + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) } />
- )} -
-
- - - - {/* ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • */} -
- - -
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, enabled: v }, - }) - } - />
-
- - - updateConfig({ - fieldChooser: { ...config.fieldChooser, allowSearch: v }, - }) - } - /> + {/* ํฌ๊ธฐ ์„ค์ • */} +
+ +
+
+ + updateConfig({ height: e.target.value })} + placeholder="400px" + className="h-8 text-xs" + /> +
+
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
-
- - - - {/* ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์„ค์ • */} -
- - -
-
- - r.type === "colorScale" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "colorScale" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "colorScale-1", - type: "colorScale" as const, - colorScale: { - minColor: "#ff6b6b", - midColor: "#ffd93d", - maxColor: "#6bcb77", - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "dataBar" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "dataBar" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "dataBar-1", - type: "dataBar" as const, - dataBar: { - color: "#3b82f6", - showValue: true, - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- -
- - r.type === "iconSet" - ) || false - } - onCheckedChange={(v) => { - const existingFormats = config.style?.conditionalFormats || []; - const filtered = existingFormats.filter( - (r) => r.type !== "iconSet" - ); - updateConfig({ - style: { - ...config.style, - theme: config.style?.theme || "default", - headerStyle: config.style?.headerStyle || "default", - cellPadding: config.style?.cellPadding || "normal", - borderStyle: config.style?.borderStyle || "light", - conditionalFormats: v - ? [ - ...filtered, - { - id: "iconSet-1", - type: "iconSet" as const, - iconSet: { - type: "traffic", - thresholds: [33, 66], - }, - }, - ] - : filtered, - }, - }); - }} - /> -
- - {config.style?.conditionalFormats && - config.style.conditionalFormats.length > 0 && ( -

- {config.style.conditionalFormats.length}๊ฐœ์˜ ์กฐ๊ฑด๋ถ€ ์„œ์‹์ด - ์ ์šฉ๋จ -

- )} -
-
- - - - {/* ํฌ๊ธฐ ์„ค์ • */} -
- - -
-
- - updateConfig({ height: e.target.value })} - placeholder="auto ๋˜๋Š” 400px" - className="h-8 text-xs" - /> -
- -
- - updateConfig({ maxHeight: e.target.value })} - placeholder="600px" - className="h-8 text-xs" - /> -
-
-
+ )}
); }; export default PivotGridConfigPanel; - diff --git a/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx new file mode 100644 index 00000000..1dac623b --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx @@ -0,0 +1,213 @@ +"use client"; + +/** + * PivotGrid ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ์ปดํฌ๋„ŒํŠธ + * ์šฐํด๋ฆญ ์‹œ ์ •๋ ฌ, ํ•„ํ„ฐ, ํ™•์žฅ/์ถ•์†Œ ๋“ฑ์˜ ์˜ต์…˜ ์ œ๊ณต + */ + +import React from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + ArrowUpAZ, + ArrowDownAZ, + Filter, + ChevronDown, + ChevronRight, + Copy, + Eye, + EyeOff, + BarChart3, +} from "lucide-react"; +import { PivotFieldConfig, AggregationType } from "../types"; + +interface PivotContextMenuProps { + children: React.ReactNode; + // ํ˜„์žฌ ์ปจํ…์ŠคํŠธ ์ •๋ณด + cellType: "header" | "data" | "rowHeader" | "columnHeader"; + field?: PivotFieldConfig; + rowPath?: string[]; + columnPath?: string[]; + value?: any; + // ์ฝœ๋ฐฑ + onSort?: (field: string, direction: "asc" | "desc") => void; + onFilter?: (field: string) => void; + onExpand?: (path: string[]) => void; + onCollapse?: (path: string[]) => void; + onExpandAll?: () => void; + onCollapseAll?: () => void; + onCopy?: (value: any) => void; + onHideField?: (field: string) => void; + onChangeSummary?: (field: string, summaryType: AggregationType) => void; + onDrillDown?: (rowPath: string[], columnPath: string[]) => void; +} + +export const PivotContextMenu: React.FC = ({ + children, + cellType, + field, + rowPath, + columnPath, + value, + onSort, + onFilter, + onExpand, + onCollapse, + onExpandAll, + onCollapseAll, + onCopy, + onHideField, + onChangeSummary, + onDrillDown, +}) => { + const handleCopy = () => { + if (value !== undefined && value !== null) { + navigator.clipboard.writeText(String(value)); + onCopy?.(value); + } + }; + + return ( + + {children} + + {/* ์ •๋ ฌ ์˜ต์…˜ (ํ—ค๋”์—์„œ๋งŒ) */} + {(cellType === "rowHeader" || cellType === "columnHeader") && field && ( + <> + + + + ์ •๋ ฌ + + + onSort?.(field.field, "asc")}> + + ์˜ค๋ฆ„์ฐจ์ˆœ + + onSort?.(field.field, "desc")}> + + ๋‚ด๋ฆผ์ฐจ์ˆœ + + + + + + )} + + {/* ํ™•์žฅ/์ถ•์†Œ ์˜ต์…˜ */} + {(cellType === "rowHeader" || cellType === "columnHeader") && ( + <> + {rowPath && rowPath.length > 0 && ( + <> + onExpand?.(rowPath)}> + + ํ™•์žฅ + + onCollapse?.(rowPath)}> + + ์ถ•์†Œ + + + )} + + + ์ „์ฒด ํ™•์žฅ + + + + ์ „์ฒด ์ถ•์†Œ + + + + )} + + {/* ํ•„ํ„ฐ ์˜ต์…˜ */} + {field && onFilter && ( + <> + onFilter(field.field)}> + + ํ•„ํ„ฐ + + + + )} + + {/* ์ง‘๊ณ„ ํ•จ์ˆ˜ ๋ณ€๊ฒฝ (๋ฐ์ดํ„ฐ ํ•„๋“œ์—์„œ๋งŒ) */} + {cellType === "data" && field && onChangeSummary && ( + <> + + + + ์ง‘๊ณ„ ํ•จ์ˆ˜ + + + onChangeSummary(field.field, "sum")} + > + ํ•ฉ๊ณ„ + + onChangeSummary(field.field, "count")} + > + ๊ฐœ์ˆ˜ + + onChangeSummary(field.field, "avg")} + > + ํ‰๊ท  + + onChangeSummary(field.field, "min")} + > + ์ตœ์†Œ + + onChangeSummary(field.field, "max")} + > + ์ตœ๋Œ€ + + + + + + )} + + {/* ๋“œ๋ฆด๋‹ค์šด (๋ฐ์ดํ„ฐ ์…€์—์„œ๋งŒ) */} + {cellType === "data" && rowPath && columnPath && onDrillDown && ( + <> + onDrillDown(rowPath, columnPath)}> + + ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋ณด๊ธฐ + + + + )} + + {/* ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ */} + {field && onHideField && ( + onHideField(field.field)}> + + ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ + + )} + + {/* ๋ณต์‚ฌ */} + + + ๋ณต์‚ฌ + + + + ); +}; + +export default PivotContextMenu; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 063b4c6c..fed43afb 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -2,7 +2,7 @@ /** * FieldPanel ์ปดํฌ๋„ŒํŠธ - * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒ๋‹จ์˜ ํ•„๋“œ ๋ฐฐ์น˜ ์˜์—ญ (ํ•„ํ„ฐ, ์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) + * ํ”ผ๋ฒ— ๊ทธ๋ฆฌ๋“œ ์ƒ๋‹จ์˜ ํ•„๋“œ ๋ฐฐ์น˜ ์˜์—ญ (์—ด, ํ–‰, ๋ฐ์ดํ„ฐ) * ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ํ•„๋“œ ์žฌ๋ฐฐ์น˜ ๊ฐ€๋Šฅ */ @@ -247,7 +247,7 @@ const DroppableArea: React.FC = ({ return (
= ({ data-area={area} > {/* ์˜์—ญ ํ—ค๋” */} -
+
{icon} {title} {areaFields.length > 0 && ( @@ -267,9 +267,9 @@ const DroppableArea: React.FC = ({ {/* ํ•„๋“œ ๋ชฉ๋ก */} -
+
{areaFields.length === 0 ? ( - + ํ•„๋“œ๋ฅผ ์—ฌ๊ธฐ๋กœ ๋“œ๋ž˜๊ทธ ) : ( @@ -443,16 +443,42 @@ export const FieldPanel: React.FC = ({ ? fields.find((f) => `${f.area}-${f.field}` === activeId) : null; + // ๊ฐ ์˜์—ญ์˜ ํ•„๋“œ ์ˆ˜ ๊ณ„์‚ฐ + const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length; + const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length; + const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length; + const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length; + if (collapsed) { return ( -
+
+
+ {filterCount > 0 && ( + + + ํ•„ํ„ฐ {filterCount} + + )} + + + ์—ด {columnCount} + + + + ํ–‰ {rowCount} + + + + ๋ฐ์ดํ„ฐ {dataCount} + +
); @@ -466,9 +492,9 @@ export const FieldPanel: React.FC = ({ onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
- {/* 2x2 ๊ทธ๋ฆฌ๋“œ๋กœ ์˜์—ญ ๋ฐฐ์น˜ */} -
+
+ {/* 4๊ฐœ ์˜์—ญ ๋ฐฐ์น˜: 2x2 ๊ทธ๋ฆฌ๋“œ */} +
{/* ํ•„ํ„ฐ ์˜์—ญ */} = ({ {/* ์ ‘๊ธฐ ๋ฒ„ํŠผ */} {onToggleCollapse && ( -
+
diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts index a901a7cf..9272e7db 100644 --- a/frontend/lib/registry/components/pivot-grid/components/index.ts +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser"; export { DrillDownModal } from "./DrillDownModal"; export { FilterPopup } from "./FilterPopup"; export { PivotChart } from "./PivotChart"; +export { PivotContextMenu } from "./ContextMenu"; From ba20a2bf425fec5274af7555646703ab665b6584 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 15:11:30 +0900 Subject: [PATCH 04/19] =?UTF-8?q?=ED=94=BC=EB=B2=97=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A7=8C=20=ED=95=98=EB=A9=B4=20=EB=90=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=80=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 629 ++++++++++++++++-- .../pivot-grid/PivotGridConfigPanel.tsx | 196 ++++++ .../pivot-grid/components/FieldChooser.tsx | 9 + .../registry/components/pivot-grid/types.ts | 7 + 4 files changed, 802 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b0e8d207..4f4595ff 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -40,9 +40,62 @@ import { ArrowUp, ArrowDown, ArrowUpDown, + Printer, + Save, + RotateCcw, + FileText, + Loader2, + Eye, + EyeOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; +// ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ ==================== + +// ์…€ ๋ณ‘ํ•ฉ ์ •๋ณด ๊ณ„์‚ฐ +interface MergeCellInfo { + rowSpan: number; + skip: boolean; // ๋ณ‘ํ•ฉ๋œ ์…€์—์„œ ๊ฑด๋„ˆ๋›ธ์ง€ ์—ฌ๋ถ€ +} + +const calculateMergeCells = ( + rows: PivotFlatRow[], + mergeCells: boolean +): Map => { + const mergeInfo = new Map(); + + if (!mergeCells || rows.length === 0) { + rows.forEach((_, idx) => mergeInfo.set(idx, { rowSpan: 1, skip: false })); + return mergeInfo; + } + + let i = 0; + while (i < rows.length) { + const currentPath = rows[i].path.join("|||"); + let spanCount = 1; + + // ๊ฐ™์€ path๋ฅผ ๊ฐ€์ง„ ์—ฐ์† ํ–‰ ์ฐพ๊ธฐ + while ( + i + spanCount < rows.length && + rows[i + spanCount].path.join("|||") === currentPath + ) { + spanCount++; + } + + // ์ฒซ ๋ฒˆ์งธ ํ–‰์€ rowSpan ์„ค์ • + mergeInfo.set(i, { rowSpan: spanCount, skip: false }); + + // ๋‚˜๋จธ์ง€ ํ–‰์€ skip + for (let j = 1; j < spanCount; j++) { + mergeInfo.set(i + j, { rowSpan: 1, skip: true }); + } + + i += spanCount; + } + + return mergeInfo; +}; + // ==================== ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ ==================== // ํ–‰ ํ—ค๋” ์…€ @@ -50,12 +103,14 @@ interface RowHeaderCellProps { row: PivotFlatRow; rowFields: PivotFieldConfig[]; onToggleExpand: (path: string[]) => void; + rowSpan?: number; } const RowHeaderCell: React.FC = ({ row, rowFields, onToggleExpand, + rowSpan = 1, }) => { const indentSize = row.level * 20; @@ -68,6 +123,7 @@ const RowHeaderCell: React.FC = ({ row.isExpanded && "bg-muted/70" )} style={{ paddingLeft: `${8 + indentSize}px` }} + rowSpan={rowSpan > 1 ? rowSpan : undefined} >
{row.hasChildren && ( @@ -94,7 +150,7 @@ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; isSelected?: boolean; - onClick?: () => void; + onClick?: (e?: React.MouseEvent) => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } @@ -133,12 +189,17 @@ const DataCell: React.FC = ({ ); } + // ํˆดํŒ ๋‚ด์šฉ ์ƒ์„ฑ + const tooltipContent = values.map((v) => + `${v.field || "๊ฐ’"}: ${v.formattedValue || v.value}` + ).join("\n"); + // ๋‹จ์ผ ๋ฐ์ดํ„ฐ ํ•„๋“œ์ธ ๊ฒฝ์šฐ if (values.length === 1) { return (
))} @@ -1214,6 +1728,34 @@ export const PivotGridComponent: React.FC = ({ + {/* ์—ด ์ด๊ณ„ ํ–‰ (์ƒ๋‹จ ์œ„์น˜) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* ๋Œ€์ดํ•ฉ */} + {totals?.showRowGrandTotals && ( + + )} + + )} + {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ์ƒ๋‹จ ์—ฌ๋ฐฑ */} {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( @@ -1221,26 +1763,34 @@ export const PivotGridComponent: React.FC = ({ )} - {visibleFlatRows.map((row, idx) => { - // ์‹ค์ œ ํ–‰ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ - const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + {(() => { + // ์…€ ๋ณ‘ํ•ฉ ์ •๋ณด ๊ณ„์‚ฐ + const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); - return ( - - {/* ํ–‰ ํ—ค๋” */} - + return visibleFlatRows.map((row, idx) => { + // ์‹ค์ œ ํ–‰ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ + const rowIdx = enableVirtualScroll ? virtualScroll.startIndex + idx : idx; + const cellMerge = mergeInfo.get(idx) || { rowSpan: 1, skip: false }; + + return ( + + {/* ํ–‰ ํ—ค๋” (๋ณ‘ํ•ฉ๋˜๋ฉด skip) */} + {!cellMerge.skip && ( + + )} {/* ๋ฐ์ดํ„ฐ ์…€ */} {flatColumns.map((col, colIdx) => { @@ -1253,8 +1803,8 @@ export const PivotGridComponent: React.FC = ({ ? getCellConditionalStyle(values[0].value ?? undefined, values[0].field) : undefined; - // ์„ ํƒ ์ƒํƒœ ํ™•์ธ - const isCellSelected = selectedCell?.rowIndex === rowIdx && selectedCell?.colIndex === colIdx; + // ์„ ํƒ ์ƒํƒœ ํ™•์ธ (๋ฒ”์œ„ ์„ ํƒ ํฌํ•จ) + const isCellSelected = isCellInRange(rowIdx, colIdx); return ( = ({ values={values} conditionalStyle={conditionalStyle} isSelected={isCellSelected} - onClick={() => { - handleCellSelect(rowIdx, colIdx); + onClick={(e?: React.MouseEvent) => { + handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); if (onCellClick) { handleCellClick(row.path, col.path, values); } @@ -1283,8 +1833,9 @@ export const PivotGridComponent: React.FC = ({ /> )} - ); - })} + ); + }); + })()} {/* ๊ฐ€์ƒ ์Šคํฌ๋กค ํ•˜๋‹จ ์—ฌ๋ฐฑ */} {enableVirtualScroll && ( @@ -1293,8 +1844,8 @@ export const PivotGridComponent: React.FC = ({ )} - {/* ์—ด ์ด๊ณ„ ํ–‰ */} - {totals?.showColumnGrandTotals && ( + {/* ์—ด ์ด๊ณ„ ํ–‰ (ํ•˜๋‹จ ์œ„์น˜ - ๊ธฐ๋ณธ๊ฐ’) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (
= ({ )} rowSpan={columnFields.length > 0 ? 2 : 1} > - {rowFields.map((f) => f.caption).join(" / ") || "ํ•ญ๋ชฉ"} +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && ํ•ญ๋ชฉ} +
0 ? 2 : 1} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
+
= ({ style={cellStyle} onClick={onClick} onDoubleClick={onDoubleClick} + title={tooltipContent} > {/* Data Bar */} {hasDataBar && ( @@ -182,6 +244,7 @@ const DataCell: React.FC = ({ style={cellStyle} onClick={onClick} onDoubleClick={onDoubleClick} + title={`${val.field || "๊ฐ’"}: ${val.formattedValue || val.value}`} > {hasDataBar && (
= ({ const [containerHeight, setContainerHeight] = useState(400); const tableContainerRef = useRef(null); - // ์…€ ์„ ํƒ ์ƒํƒœ + // ์…€ ์„ ํƒ ์ƒํƒœ (๋ฒ”์œ„ ์„ ํƒ ์ง€์›) const [selectedCell, setSelectedCell] = useState<{ rowIndex: number; colIndex: number; } | null>(null); + const [selectionRange, setSelectionRange] = useState<{ + startRow: number; + startCol: number; + endRow: number; + endCol: number; + } | null>(null); const tableRef = useRef(null); // ์ •๋ ฌ ์ƒํƒœ @@ -272,6 +341,12 @@ export const PivotGridComponent: React.FC = ({ field: string; direction: "asc" | "desc"; } | null>(null); + + // ์—ด ๋„ˆ๋น„ ์ƒํƒœ + const [columnWidths, setColumnWidths] = useState>({}); + const [resizingColumn, setResizingColumn] = useState(null); + const [resizeStartX, setResizeStartX] = useState(0); + const [resizeStartWidth, setResizeStartWidth] = useState(0); // ์™ธ๋ถ€ fields ๋ณ€๊ฒฝ ์‹œ ๋™๊ธฐํ™” useEffect(() => { @@ -280,6 +355,38 @@ export const PivotGridComponent: React.FC = ({ } }, [initialFields]); + // ์ƒํƒœ ์ €์žฅ ํ‚ค + const stateStorageKey = `pivot-state-${title || "default"}`; + + // ์ƒํƒœ ์ €์žฅ (localStorage) + const saveStateToStorage = useCallback(() => { + if (typeof window === "undefined") return; + const stateToSave = { + fields, + pivotState, + sortConfig, + columnWidths, + }; + localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); + }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); + + // ์ƒํƒœ ๋ณต์› (localStorage) + useEffect(() => { + if (typeof window === "undefined") return; + const savedState = localStorage.getItem(stateStorageKey); + if (savedState) { + try { + const parsed = JSON.parse(savedState); + if (parsed.fields) setFields(parsed.fields); + if (parsed.pivotState) setPivotState(parsed.pivotState); + if (parsed.sortConfig) setSortConfig(parsed.sortConfig); + if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); + } catch (e) { + console.warn("ํ”ผ๋ฒ— ์ƒํƒœ ๋ณต์› ์‹คํŒจ:", e); + } + } + }, [stateStorageKey]); + // ๋ฐ์ดํ„ฐ const data = externalData || []; @@ -456,12 +563,72 @@ export const PivotGridComponent: React.FC = ({ return () => observer.disconnect(); }, []); + // ์—ด ํฌ๊ธฐ ์กฐ์ ˆ ์ค‘ + useEffect(() => { + if (resizingColumn === null) return; + + const handleMouseMove = (e: MouseEvent) => { + const diff = e.clientX - resizeStartX; + const newWidth = Math.max(50, resizeStartWidth + diff); // ์ตœ์†Œ 50px + setColumnWidths((prev) => ({ + ...prev, + [resizingColumn]: newWidth, + })); + }; + + const handleMouseUp = () => { + setResizingColumn(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [resizingColumn, resizeStartX, resizeStartWidth]); + // ๊ฐ€์ƒ ์Šคํฌ๋กค ํ›… ์‚ฌ์šฉ const flatRows = pivotResult?.flatRows || []; - const enableVirtualScroll = flatRows.length > VIRTUAL_SCROLL_THRESHOLD; + + // ์ •๋ ฌ๋œ ํ–‰ ๋ฐ์ดํ„ฐ + const sortedFlatRows = useMemo(() => { + if (!sortConfig || !pivotResult) return flatRows; + + const { field, direction } = sortConfig; + const { dataMatrix, flatColumns } = pivotResult; + + // ๊ฐ ํ–‰์˜ ์ •๋ ฌ ๊ธฐ์ค€ ๊ฐ’ ๊ณ„์‚ฐ + const rowsWithSortValue = flatRows.map((row) => { + let sortValue = 0; + // ๋ชจ๋“  ์—ด์— ๋Œ€ํ•ด ํ•ด๋‹น ํ•„๋“œ์˜ ํ•ฉ๊ณ„ ๊ณ„์‚ฐ + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const targetValue = values.find((v) => v.field === field); + if (targetValue?.value != null) { + sortValue += targetValue.value; + } + }); + return { row, sortValue }; + }); + + // ์ •๋ ฌ + rowsWithSortValue.sort((a, b) => { + if (direction === "asc") { + return a.sortValue - b.sortValue; + } + return b.sortValue - a.sortValue; + }); + + return rowsWithSortValue.map((item) => item.row); + }, [flatRows, sortConfig, pivotResult]); + + const enableVirtualScroll = sortedFlatRows.length > VIRTUAL_SCROLL_THRESHOLD; const virtualScroll = useVirtualScroll({ - itemCount: flatRows.length, + itemCount: sortedFlatRows.length, itemHeight: ROW_HEIGHT, containerHeight: containerHeight, overscan: 10, @@ -469,9 +636,9 @@ export const PivotGridComponent: React.FC = ({ // ๊ฐ€์ƒ ์Šคํฌ๋กค ์ ์šฉ๋œ ํ–‰ ๋ฐ์ดํ„ฐ const visibleFlatRows = useMemo(() => { - if (!enableVirtualScroll) return flatRows; - return flatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); - }, [enableVirtualScroll, flatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ์Šคํƒ€์ผ ๊ณ„์‚ฐ ํ—ฌํผ const getCellConditionalStyle = useCallback( @@ -660,6 +827,154 @@ export const PivotGridComponent: React.FC = ({ console.error("Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); } }, [pivotResult, fields, totals, title]); + + // ์ธ์‡„ ๊ธฐ๋Šฅ (PDF ๋‚ด๋ณด๋‚ด๊ธฐ๋ณด๋‹ค ๋จผ์ € ์ •์˜ํ•ด์•ผ ํ•จ) + const handlePrint = useCallback(() => { + const printContent = tableRef.current; + if (!printContent) return; + + const printWindow = window.open("", "_blank"); + if (!printWindow) return; + + printWindow.document.write(` + + + + ${title || "ํ”ผ๋ฒ— ํ…Œ์ด๋ธ”"} + + + +

${title || "ํ”ผ๋ฒ— ํ…Œ์ด๋ธ”"}

+ ${printContent.outerHTML} + + + `); + + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); + }, [title]); + + // PDF ๋‚ด๋ณด๋‚ด๊ธฐ + const handleExportPDF = useCallback(async () => { + if (!pivotResult || !tableRef.current) return; + + try { + // ๋™์  import๋กœ jspdf์™€ html2canvas ๋กœ๋“œ + const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ + import("jspdf"), + import("html2canvas"), + ]); + + const canvas = await html2canvas(tableRef.current, { + scale: 2, + useCORS: true, + logging: false, + }); + + const imgData = canvas.toDataURL("image/png"); + const pdf = new jsPDF({ + orientation: canvas.width > canvas.height ? "landscape" : "portrait", + unit: "px", + format: [canvas.width, canvas.height], + }); + + pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height); + pdf.save(`${title || "pivot"}_export.pdf`); + } catch (error) { + console.error("PDF ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); + // jspdf๊ฐ€ ์—†์œผ๋ฉด ์ธ์‡„ ๋Œ€ํ™”์ƒ์ž๋กœ ๋Œ€์ฒด + handlePrint(); + } + }, [pivotResult, title, handlePrint]); + + // ๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRefreshData = useCallback(async () => { + setIsRefreshing(true); + // ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค๊ฐ€ ์žˆ์œผ๋ฉด ์ƒˆ๋กœ๊ณ ์นจ + // ์—ฌ๊ธฐ์„œ๋Š” ์ƒํƒœ๋งŒ ์ดˆ๊ธฐํ™” + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setSelectedCell(null); + setSelectionRange(null); + setTimeout(() => setIsRefreshing(false), 500); + }, []); + + // ์ƒํƒœ ์ €์žฅ ๋ฒ„ํŠผ ํ•ธ๋“ค๋Ÿฌ + const handleSaveState = useCallback(() => { + saveStateToStorage(); + console.log("ํ”ผ๋ฒ— ์ƒํƒœ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + }, [saveStateToStorage]); + + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + const handleResetState = useCallback(() => { + localStorage.removeItem(stateStorageKey); + setFields(initialFields); + setPivotState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + setSortConfig(null); + setColumnWidths({}); + setSelectedCell(null); + setSelectionRange(null); + }, [stateStorageKey, initialFields]); + + // ํ•„๋“œ ์ˆจ๊ธฐ๊ธฐ/ํ‘œ์‹œ ์ƒํƒœ + const [hiddenFields, setHiddenFields] = useState>(new Set()); + + const toggleFieldVisibility = useCallback((fieldName: string) => { + setHiddenFields((prev) => { + const newSet = new Set(prev); + if (newSet.has(fieldName)) { + newSet.delete(fieldName); + } else { + newSet.add(fieldName); + } + return newSet; + }); + }, []); + + // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ์ œ์™ธํ•œ ํ™œ์„ฑ ํ•„๋“œ๋“ค + const visibleFields = useMemo(() => { + return fields.filter((f) => !hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ๋ชฉ๋ก + const hiddenFieldsList = useMemo(() => { + return fields.filter((f) => hiddenFields.has(f.field)); + }, [fields, hiddenFields]); + + // ๋ชจ๋“  ํ•„๋“œ ํ‘œ์‹œ + const showAllFields = useCallback(() => { + setHiddenFields(new Set()); + }, []); // ==================== ๋ Œ๋”๋ง ==================== @@ -818,7 +1133,27 @@ export const PivotGridComponent: React.FC = ({ case "Escape": e.preventDefault(); setSelectedCell(null); + setSelectionRange(null); break; + case "c": + // Ctrl+C: ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + copySelectionToClipboard(); + } + return; + case "a": + // Ctrl+A: ์ „์ฒด ์„ ํƒ + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setSelectionRange({ + startRow: 0, + startCol: 0, + endRow: visibleFlatRows.length - 1, + endCol: flatColumns.length - 1, + }); + } + return; default: return; } @@ -828,9 +1163,85 @@ export const PivotGridComponent: React.FC = ({ } }; - // ์…€ ํด๋ฆญ์œผ๋กœ ์„ ํƒ - const handleCellSelect = (rowIndex: number, colIndex: number) => { - setSelectedCell({ rowIndex, colIndex }); + // ์…€ ํด๋ฆญ์œผ๋กœ ์„ ํƒ (Shift+ํด๋ฆญ์œผ๋กœ ๋ฒ”์œ„ ์„ ํƒ) + const handleCellSelect = (rowIndex: number, colIndex: number, shiftKey: boolean = false) => { + if (shiftKey && selectedCell) { + // Shift+ํด๋ฆญ: ๋ฒ”์œ„ ์„ ํƒ + setSelectionRange({ + startRow: Math.min(selectedCell.rowIndex, rowIndex), + startCol: Math.min(selectedCell.colIndex, colIndex), + endRow: Math.max(selectedCell.rowIndex, rowIndex), + endCol: Math.max(selectedCell.colIndex, colIndex), + }); + } else { + // ์ผ๋ฐ˜ ํด๋ฆญ: ๋‹จ์ผ ์„ ํƒ + setSelectedCell({ rowIndex, colIndex }); + setSelectionRange(null); + } + }; + + // ์…€์ด ์„ ํƒ ๋ฒ”์œ„ ๋‚ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + const isCellInRange = (rowIndex: number, colIndex: number): boolean => { + if (selectionRange) { + return ( + rowIndex >= selectionRange.startRow && + rowIndex <= selectionRange.endRow && + colIndex >= selectionRange.startCol && + colIndex <= selectionRange.endCol + ); + } + if (selectedCell) { + return selectedCell.rowIndex === rowIndex && selectedCell.colIndex === colIndex; + } + return false; + }; + + // ์—ด ํฌ๊ธฐ ์กฐ์ ˆ ์‹œ์ž‘ + const handleResizeStart = (colIdx: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setResizingColumn(colIdx); + setResizeStartX(e.clientX); + setResizeStartWidth(columnWidths[colIdx] || 100); + }; + + // ํด๋ฆฝ๋ณด๋“œ์— ์„ ํƒ ์˜์—ญ ๋ณต์‚ฌ + const copySelectionToClipboard = () => { + const range = selectionRange || (selectedCell ? { + startRow: selectedCell.rowIndex, + startCol: selectedCell.colIndex, + endRow: selectedCell.rowIndex, + endCol: selectedCell.colIndex, + } : null); + + if (!range) return; + + const lines: string[] = []; + + for (let rowIdx = range.startRow; rowIdx <= range.endRow; rowIdx++) { + const row = visibleFlatRows[rowIdx]; + if (!row) continue; + + const rowValues: string[] = []; + for (let colIdx = range.startCol; colIdx <= range.endCol; colIdx++) { + const col = flatColumns[colIdx]; + if (!col) continue; + + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + const cellValue = values.map((v) => v.formattedValue || v.value || "").join(", "); + rowValues.push(cellValue); + } + lines.push(rowValues.join("\t")); + } + + const text = lines.join("\n"); + navigator.clipboard.writeText(text).then(() => { + // ๋ณต์‚ฌ ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ (์„ ํƒ์ ) + console.log("ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ๋จ:", text); + }).catch((err) => { + console.error("ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ ์‹คํŒจ:", err); + }); }; // ์ •๋ ฌ ํ† ๊ธ€ @@ -974,8 +1385,101 @@ export const PivotGridComponent: React.FC = ({ > + + )} + + + + + + + + {/* ์ˆจ๊ฒจ์ง„ ํ•„๋“œ ํ‘œ์‹œ ๋“œ๋กญ๋‹ค์šด */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ ์ˆจ๊ฒจ์ง„ ํ•„๋“œ +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )}
handleSort(dataFields[0].field) : undefined} >
{col.caption || "(์ „์ฒด)"} {dataFields.length === 1 && }
+ {/* ์—ด ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค */} +
handleResizeStart(idx, e)} + />
+ ์ด๊ณ„ +
= ({ /> +
+ + +
+ +
+ + +
+ +
+ + + updateConfig({ totals: { ...config.totals, showRowTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) + } + /> +
+
= ({ />
+
+ + + updateConfig({ style: { ...config.style, mergeCells: v } }) + } + /> +
+
= ({ } />
+ +
+ + + updateConfig({ saveState: v }) + } + /> +
@@ -593,6 +669,126 @@ export const PivotGridConfigPanel: React.FC = ({ + + {/* ์กฐ๊ฑด๋ถ€ ์„œ์‹ */} +
+ +
+ {(config.style?.conditionalFormats || []).map((rule, index) => ( +
+ + + {rule.type === "colorScale" && ( +
+ { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: e.target.value, maxColor: rule.colorScale?.maxColor || "#00ff00" } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="์ตœ์†Œ๊ฐ’ ์ƒ‰์ƒ" + /> + โ†’ + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, colorScale: { ...rule.colorScale, minColor: rule.colorScale?.minColor || "#ff0000", maxColor: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="์ตœ๋Œ€๊ฐ’ ์ƒ‰์ƒ" + /> +
+ )} + + {rule.type === "dataBar" && ( + { + const newFormats = [...(config.style?.conditionalFormats || [])]; + newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; + updateConfig({ style: { ...config.style, conditionalFormats: newFormats } }); + }} + className="w-6 h-6 rounded cursor-pointer" + title="๋ฐ” ์ƒ‰์ƒ" + /> + )} + + {rule.type === "iconSet" && ( + + )} + + +
+ ))} + + +
+
)} diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index ec194a12..de4a8948 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ { value: "percentDifferenceFromPrevious", label: "์ด์ „ ๋Œ€๋น„ % ์ฐจ์ด" }, ]; +const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [ + { value: "none", label: "๊ทธ๋ฃน ์—†์Œ" }, + { value: "year", label: "๋…„" }, + { value: "quarter", label: "๋ถ„๊ธฐ" }, + { value: "month", label: "์›”" }, + { value: "week", label: "์ฃผ" }, + { value: "day", label: "์ผ" }, +]; + const DATA_TYPE_ICONS: Record = { string: , number: , diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e711a255..87ba2414 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -90,6 +90,10 @@ export interface PivotFieldConfig { // ๊ณ„์ธต ๊ด€๋ จ displayFolder?: string; // ํ•„๋“œ ์„ ํƒ๊ธฐ์—์„œ ํด๋” ๊ตฌ์กฐ isMeasure?: boolean; // ์ธก์ •๊ฐ’ ์ „์šฉ ํ•„๋“œ (data ์˜์—ญ๋งŒ ๊ฐ€๋Šฅ) + + // ๊ณ„์‚ฐ ํ•„๋“œ + isCalculated?: boolean; // ๊ณ„์‚ฐ ํ•„๋“œ ์—ฌ๋ถ€ + calculateFormula?: string; // ๊ณ„์‚ฐ ์ˆ˜์‹ (์˜ˆ: "[Sales] / [Quantity]") } // ==================== ๋ฐ์ดํ„ฐ ์†Œ์Šค ์„ค์ • ==================== @@ -140,11 +144,13 @@ export interface PivotTotalsConfig { showRowGrandTotals?: boolean; // ํ–‰ ์ดํ•ฉ๊ณ„ ํ‘œ์‹œ showRowTotals?: boolean; // ํ–‰ ์†Œ๊ณ„ ํ‘œ์‹œ rowTotalsPosition?: "first" | "last"; // ์†Œ๊ณ„ ์œ„์น˜ + rowGrandTotalPosition?: "top" | "bottom"; // ํ–‰ ์ด๊ณ„ ์œ„์น˜ (์ƒ๋‹จ/ํ•˜๋‹จ) // ์—ด ์ดํ•ฉ๊ณ„ showColumnGrandTotals?: boolean; // ์—ด ์ดํ•ฉ๊ณ„ ํ‘œ์‹œ showColumnTotals?: boolean; // ์—ด ์†Œ๊ณ„ ํ‘œ์‹œ columnTotalsPosition?: "first" | "last"; // ์†Œ๊ณ„ ์œ„์น˜ + columnGrandTotalPosition?: "left" | "right"; // ์—ด ์ด๊ณ„ ์œ„์น˜ (์ขŒ์ธก/์šฐ์ธก) } // ํ•„๋“œ ์„ ํƒ๊ธฐ ์„ค์ • @@ -214,6 +220,7 @@ export interface PivotStyleConfig { alternateRowColors?: boolean; highlightTotals?: boolean; // ์ดํ•ฉ๊ณ„ ๊ฐ•์กฐ conditionalFormats?: ConditionalFormatRule[]; // ์กฐ๊ฑด๋ถ€ ์„œ์‹ ๊ทœ์น™ + mergeCells?: boolean; // ๊ฐ™์€ ๊ฐ’ ์…€ ๋ณ‘ํ•ฉ } // ==================== ๋‚ด๋ณด๋‚ด๊ธฐ ์„ค์ • ==================== From bcf512d2b5a7d00e1277046aa72c466cc37c4b8e Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 9 Jan 2026 15:58:16 +0900 Subject: [PATCH 05/19] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj From f8fb7d687e3b660a9dc67757599f9dcab836dcd9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 9 Jan 2026 17:42:33 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20SelectedItemsDetailInput=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20DynamicCo?= =?UTF-8?q?mponentRenderer=EC=97=90=20selected-items-detail-input=20groupe?= =?UTF-8?q?dData=20=EC=A0=84=EB=8B=AC=20=EC=B6=94=EA=B0=80=20SelectedItems?= =?UTF-8?q?DetailInput=EC=97=90=EC=84=9C=20groupedData=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20ScreenModal=20editData=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20formData/selectedD?= =?UTF-8?q?ata=20=EB=B6=84=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20TextInput=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=20=EC=8A=A4=ED=82=B5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/ScreenModal.tsx | 16 +++++++++---- .../lib/registry/DynamicComponentRenderer.tsx | 8 ++++--- .../SelectedItemsDetailInputComponent.tsx | 24 +++++++++++++------ .../text-input/TextInputComponent.tsx | 16 ++++++++++++- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index f7926f43..44685dc0 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -175,13 +175,21 @@ export const ScreenModal: React.FC = ({ className }) => { if (editData) { console.log("๐Ÿ“ [ScreenModal] ์ˆ˜์ • ๋ฐ์ดํ„ฐ ์„ค์ •:", editData); - // ๐Ÿ†• ๋ฐฐ์—ด์ธ ๊ฒฝ์šฐ (๊ทธ๋ฃน ๋ ˆ์ฝ”๋“œ) vs ๋‹จ์ผ ๊ฐ์ฒด ์ฒ˜๋ฆฌ + // ๐Ÿ†• ๋ฐฐ์—ด์ธ ๊ฒฝ์šฐ ๋‘ ๊ฐ€์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์„ค์ •: + // 1. formData: ์ฒซ ๋ฒˆ์งธ ์š”์†Œ(๊ฐ์ฒด) - ์ผ๋ฐ˜ ์ž…๋ ฅ ํ•„๋“œ์šฉ (TextInput ๋“ฑ) + // 2. selectedData: ์ „์ฒด ๋ฐฐ์—ด - ๋‹ค์ค‘ ํ•ญ๋ชฉ ์ปดํฌ๋„ŒํŠธ์šฉ (SelectedItemsDetailInput ๋“ฑ) if (Array.isArray(editData)) { - console.log(`๐Ÿ“ [ScreenModal] ๊ทธ๋ฃน ๋ ˆ์ฝ”๋“œ ${editData.length}๊ฐœ ์„ค์ •`); - setFormData(editData as any); // ๋ฐฐ์—ด ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ (SelectedItemsDetailInput์—์„œ ์ฒ˜๋ฆฌ) - setOriginalData(editData[0] || null); // ์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์›๋ณธ์œผ๋กœ ์ €์žฅ + const firstRecord = editData[0] || {}; + console.log(`๐Ÿ“ [ScreenModal] ๊ทธ๋ฃน ๋ ˆ์ฝ”๋“œ ${editData.length}๊ฐœ ์„ค์ •:`, { + formData: "์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ (์ผ๋ฐ˜ ์ž…๋ ฅ ํ•„๋“œ์šฉ)", + selectedData: "์ „์ฒด ๋ฐฐ์—ด (๋‹ค์ค‘ ํ•ญ๋ชฉ ์ปดํฌ๋„ŒํŠธ์šฉ)", + }); + setFormData(firstRecord); // ๐Ÿ”ง ์ผ๋ฐ˜ ์ž…๋ ฅ ํ•„๋“œ์šฉ (๊ฐ์ฒด) + setSelectedData(editData); // ๐Ÿ”ง ๋‹ค์ค‘ ํ•ญ๋ชฉ ์ปดํฌ๋„ŒํŠธ์šฉ (๋ฐฐ์—ด) - groupedData๋กœ ์ „๋‹ฌ๋จ + setOriginalData(firstRecord); // ์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ๋ฅผ ์›๋ณธ์œผ๋กœ ์ €์žฅ } else { setFormData(editData); + setSelectedData([editData]); // ๐Ÿ”ง ๋‹จ์ผ ๊ฐ์ฒด๋„ ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ €์žฅ setOriginalData(editData); // ๐Ÿ†• ์›๋ณธ ๋ฐ์ดํ„ฐ ์ €์žฅ (UPDATE ํŒ๋‹จ์šฉ) } } else { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 6166317f..3d8b2d4e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -281,10 +281,12 @@ export const DynamicComponentRenderer: React.FC = // ์ปดํฌ๋„ŒํŠธ์˜ columnName์— ํ•ด๋‹นํ•˜๋Š” formData ๊ฐ’ ์ถ”์ถœ const fieldName = (component as any).columnName || component.id; - // modal-repeater-table์€ ๋ฐฐ์—ด ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ ๋นˆ ๋ฐฐ์—ด๋กœ ์ดˆ๊ธฐํ™” + // ๋‹ค์ค‘ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‹ค๋ฃจ๋Š” ์ปดํฌ๋„ŒํŠธ๋Š” ๋ฐฐ์—ด ๋ฐ์ดํ„ฐ๋กœ ์ดˆ๊ธฐํ™” let currentValue; - if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") { - // EditModal์—์„œ ์ „๋‹ฌ๋œ groupedData๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ + if (componentType === "modal-repeater-table" || + componentType === "repeat-screen-modal" || + componentType === "selected-items-detail-input") { + // EditModal/ScreenModal์—์„œ ์ „๋‹ฌ๋œ groupedData๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ currentValue = props.groupedData || formData?.[fieldName] || []; } else { currentValue = formData?.[fieldName] || ""; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 19073e39..285c655d 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -42,6 +42,8 @@ export const SelectedItemsDetailInputComponent: React.FC { + // ๐Ÿ†• groupedData ์ถ”์ถœ (DynamicComponentRenderer์—์„œ ์ „๋‹ฌ) + const groupedData = (props as any).groupedData || (props as any)._groupedData; // ๐Ÿ†• URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ dataSourceId ์ฝ๊ธฐ const searchParams = useSearchParams(); const urlDataSourceId = searchParams?.get("dataSourceId") || undefined; @@ -225,24 +227,32 @@ export const SelectedItemsDetailInputComponent: React.FC { - // ๐Ÿ†• ์ˆ˜์ • ๋ชจ๋“œ: formData์—์„œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ (URL์— mode=edit์ด ์žˆ์œผ๋ฉด) + // ๐Ÿ†• ์ˆ˜์ • ๋ชจ๋“œ: groupedData ๋˜๋Š” formData์—์„œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ (URL์— mode=edit์ด ์žˆ์œผ๋ฉด) const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get("mode"); - if (mode === "edit" && formData) { + // ๐Ÿ”ง ๋ฐ์ดํ„ฐ ์†Œ์Šค ์šฐ์„ ์ˆœ์œ„: groupedData > formData (๋ฐฐ์—ด) > formData (๊ฐ์ฒด) + const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0 + ? groupedData + : formData; + + if (mode === "edit" && sourceData) { // ๋ฐฐ์—ด์ธ์ง€ ๋‹จ์ผ ๊ฐ์ฒด์ธ์ง€ ํ™•์ธ - const isArray = Array.isArray(formData); - const dataArray = isArray ? formData : [formData]; + const isArray = Array.isArray(sourceData); + const dataArray = isArray ? sourceData : [sourceData]; if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { - console.warn("โš ๏ธ [SelectedItemsDetailInput] formData๊ฐ€ ๋น„์–ด์žˆ์Œ"); + console.warn("โš ๏ธ [SelectedItemsDetailInput] ๋ฐ์ดํ„ฐ๊ฐ€ ๋น„์–ด์žˆ์Œ"); return; } console.log( `๐Ÿ“ [SelectedItemsDetailInput] ์ˆ˜์ • ๋ชจ๋“œ - ${isArray ? "๊ทธ๋ฃน ๋ ˆ์ฝ”๋“œ" : "๋‹จ์ผ ๋ ˆ์ฝ”๋“œ"} (${dataArray.length}๊ฐœ)`, ); - console.log("๐Ÿ“ [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2)); + console.log("๐Ÿ“ [SelectedItemsDetailInput] ๋ฐ์ดํ„ฐ ์†Œ์Šค:", { + fromGroupedData: groupedData && Array.isArray(groupedData) && groupedData.length > 0, + dataArray: JSON.stringify(dataArray, null, 2), + }); const groups = componentConfig.fieldGroups || []; const additionalFields = componentConfig.additionalFields || []; @@ -423,7 +433,7 @@ export const SelectedItemsDetailInputComponent: React.FC = ({ // ์ˆจ๊น€ ์ƒํƒœ (props์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’ ์šฐ์„  ์‚ฌ์šฉ) const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false; + // ์ˆ˜์ • ๋ชจ๋“œ ์—ฌ๋ถ€ ํ™•์ธ (originalData๊ฐ€ ์žˆ์œผ๋ฉด ์ˆ˜์ • ๋ชจ๋“œ) + const originalData = props.originalData || (props as any)._originalData; + const isEditMode = originalData && Object.keys(originalData).length > 0; + // ์ž๋™์ƒ์„ฑ๋œ ๊ฐ’ ์ƒํƒœ const [autoGeneratedValue, setAutoGeneratedValue] = useState(""); @@ -99,6 +103,16 @@ export const TextInputComponent: React.FC = ({ return; } + // ๐Ÿ†• ์ˆ˜์ • ๋ชจ๋“œ์ผ ๋•Œ๋Š” ์ฑ„๋ฒˆ ๊ทœ์น™ ์Šคํ‚ต (๊ธฐ์กด ๊ฐ’ ์œ ์ง€) + if (isEditMode) { + console.log("โญ๏ธ ์ˆ˜์ • ๋ชจ๋“œ - ์ฑ„๋ฒˆ ๊ทœ์น™ ์Šคํ‚ต:", { + columnName: component.columnName, + originalValue: originalData?.[component.columnName], + }); + hasGeneratedRef.current = true; // ์ƒ์„ฑ ์™„๋ฃŒ๋กœ ํ‘œ์‹œํ•˜์—ฌ ์žฌ์‹คํ–‰ ๋ฐฉ์ง€ + return; + } + if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") { // ํผ ๋ฐ์ดํ„ฐ์— ์ด๋ฏธ ๊ฐ’์ด ์žˆ์œผ๋ฉด ์ž๋™์ƒ์„ฑํ•˜์ง€ ์•Š์Œ const currentFormValue = formData?.[component.columnName]; @@ -171,7 +185,7 @@ export const TextInputComponent: React.FC = ({ }; generateAutoValue(); - }, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive]); + }, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive, isEditMode]); // ์‹ค์ œ ํ™”๋ฉด์—์„œ ์ˆจ๊น€ ์ฒ˜๋ฆฌ๋œ ์ปดํฌ๋„ŒํŠธ๋Š” ๋ Œ๋”๋งํ•˜์ง€ ์•Š์Œ if (isHidden && !isDesignMode) { From 3677c77da01224b531289df20d1e7414f9906b34 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 9 Jan 2026 17:56:48 +0900 Subject: [PATCH 07/19] =?UTF-8?q?=EB=8B=A8=EC=9D=BC=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ExcelUploadModal.tsx | 65 ++- .../config-panels/ButtonConfigPanel.tsx | 468 ++++++++++++------ frontend/lib/utils/buttonActions.ts | 14 +- 3 files changed, 402 insertions(+), 145 deletions(-) diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 6785eac8..6eda1594 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -84,6 +84,11 @@ export interface ExcelUploadModalProps { }; // ๐Ÿ†• ๋งˆ์Šคํ„ฐ-๋””ํ…Œ์ผ ์—‘์…€ ์—…๋กœ๋“œ ์„ค์ • masterDetailExcelConfig?: MasterDetailExcelConfig; + // ๐Ÿ†• ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ฑ„๋ฒˆ ์„ค์ • + numberingRuleId?: string; + numberingTargetColumn?: string; + // ๐Ÿ†• ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ ์„ค์ • + afterUploadFlows?: Array<{ flowId: string; order: number }>; } interface ColumnMapping { @@ -103,6 +108,11 @@ export const ExcelUploadModal: React.FC = ({ isMasterDetail = false, masterDetailRelation, masterDetailExcelConfig, + // ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ฑ„๋ฒˆ ์„ค์ • + numberingRuleId, + numberingTargetColumn, + // ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ ์„ค์ • + afterUploadFlows, }) => { const [currentStep, setCurrentStep] = useState(1); @@ -695,13 +705,48 @@ export const ExcelUploadModal: React.FC = ({ } } else { // ๊ธฐ์กด ๋‹จ์ผ ํ…Œ์ด๋ธ” ์—…๋กœ๋“œ ๋กœ์ง + console.log("๐Ÿ“Š ๋‹จ์ผ ํ…Œ์ด๋ธ” ์—…๋กœ๋“œ ์‹œ์ž‘:", { + tableName, + uploadMode, + numberingRuleId, + numberingTargetColumn, + dataCount: filteredData.length, + }); + let successCount = 0; let failCount = 0; + // ๐Ÿ†• ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ฑ„๋ฒˆ ์„ค์ • ํ™•์ธ + const hasNumbering = numberingRuleId && numberingTargetColumn; + console.log("๐Ÿ“Š ์ฑ„๋ฒˆ ์„ค์ •:", { hasNumbering, numberingRuleId, numberingTargetColumn }); + for (const row of filteredData) { try { + let dataToSave = { ...row }; + + // ๐Ÿ†• ์ฑ„๋ฒˆ ์ ์šฉ: ๊ฐ ํ–‰๋งˆ๋‹ค ์ฑ„๋ฒˆ API ํ˜ธ์ถœ + if (hasNumbering && uploadMode === "insert") { + try { + const { apiClient } = await import("@/lib/api/client"); + console.log(`๐Ÿ“Š ์ฑ„๋ฒˆ API ํ˜ธ์ถœ: /numbering-rules/${numberingRuleId}/allocate`); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`); + console.log(`๐Ÿ“Š ์ฑ„๋ฒˆ API ์‘๋‹ต:`, numberingResponse.data); + // ์‘๋‹ต ๊ตฌ์กฐ: { success: true, data: { generatedCode: "..." } } + const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; + if (numberingResponse.data?.success && generatedCode) { + dataToSave[numberingTargetColumn] = generatedCode; + console.log(`โœ… ์ฑ„๋ฒˆ ์ ์šฉ: ${numberingTargetColumn} = ${generatedCode}`); + } else { + console.warn(`โš ๏ธ ์ฑ„๋ฒˆ ์‹คํŒจ: ์‘๋‹ต์— ์ฝ”๋“œ ์—†์Œ`, numberingResponse.data); + } + } catch (numError) { + console.error("์ฑ„๋ฒˆ ์˜ค๋ฅ˜:", numError); + // ์ฑ„๋ฒˆ ์‹คํŒจ ์‹œ์—๋„ ๊ณ„์† ์ง„ํ–‰ (์ฑ„๋ฒˆ ์ปฌ๋Ÿผ๋งŒ ๋น„์›Œ๋‘ ) + } + } + if (uploadMode === "insert") { - const formData = { screenId: 0, tableName, data: row }; + const formData = { screenId: 0, tableName, data: dataToSave }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; @@ -714,6 +759,24 @@ export const ExcelUploadModal: React.FC = ({ } } + // ๐Ÿ†• ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ + if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) { + console.log("๐Ÿ”„ ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰:", afterUploadFlows); + try { + const { apiClient } = await import("@/lib/api/client"); + // ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ + const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order); + for (const flow of sortedFlows) { + await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, { + sourceData: { tableName, uploadedCount: successCount }, + }); + console.log(`โœ… ์ œ์–ด ์‹คํ–‰ ์™„๋ฃŒ: flowId=${flow.flowId}`); + } + } catch (controlError) { + console.error("์ œ์–ด ์‹คํ–‰ ์˜ค๋ฅ˜:", controlError); + } + } + if (successCount > 0) { toast.success( `${successCount}๊ฐœ ํ–‰์ด ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${failCount > 0 ? ` (์‹คํŒจ: ${failCount}๊ฐœ)` : ""}` diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 8d8c4df9..eabeb59f 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -5,7 +5,6 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; @@ -2026,7 +2025,12 @@ export const ButtonConfigPanel: React.FC = ({ {/* ์—‘์…€ ์—…๋กœ๋“œ ์•ก์…˜ ์„ค์ • */} {(component.componentConfig?.action?.type || "save") === "excel_upload" && ( - + )} {/* ๋ฐ”์ฝ”๋“œ ์Šค์บ” ์•ก์…˜ ์„ค์ • */} @@ -3311,7 +3315,6 @@ const MasterDetailExcelUploadConfig: React.FC<{ onUpdateProperty: (path: string, value: any) => void; allComponents: ComponentData[]; }> = ({ config, onUpdateProperty, allComponents }) => { - const [numberingRules, setNumberingRules] = useState([]); const [relationInfo, setRelationInfo] = useState<{ masterTable: string; detailTable: string; @@ -3319,7 +3322,6 @@ const MasterDetailExcelUploadConfig: React.FC<{ detailFkColumn: string; } | null>(null); const [loading, setLoading] = useState(false); - const [numberingRuleOpen, setNumberingRuleOpen] = useState(false); const [masterColumns, setMasterColumns] = useState< Array<{ columnName: string; @@ -3357,22 +3359,6 @@ const MasterDetailExcelUploadConfig: React.FC<{ const masterTable = splitPanelInfo?.leftPanel?.tableName || ""; const detailTable = splitPanelInfo?.rightPanel?.tableName || ""; - // ์ฑ„๋ฒˆ ๊ทœ์น™ ๋กœ๋“œ - useEffect(() => { - const loadNumberingRules = async () => { - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get("/numbering-rules"); - if (response.data?.success && response.data?.data) { - setNumberingRules(response.data.data); - } - } catch (error) { - console.error("์ฑ„๋ฒˆ ๊ทœ์น™ ๋กœ๋“œ ์‹คํŒจ:", error); - } - }; - loadNumberingRules(); - }, []); - // ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ๋กœ๋“œ useEffect(() => { if (!masterTable) { @@ -3547,86 +3533,12 @@ const MasterDetailExcelUploadConfig: React.FC<{ )} - {/* ์ฑ„๋ฒˆ ๊ทœ์น™ ์„ ํƒ - ์œ ์ผํ•˜๊ฒŒ ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •ํ•˜๋Š” ํ•ญ๋ชฉ */} + {/* ๋งˆ์Šคํ„ฐ ํ‚ค ์ž๋™ ์ƒ์„ฑ ์•ˆ๋‚ด */} {relationInfo && ( -
- - - - - - - - - - ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์Œ - - { - updateMasterDetailConfig({ numberingRuleId: undefined }); - setNumberingRuleOpen(false); - }} - className="text-xs" - > - - ์ฑ„๋ฒˆ ์—†์Œ (์ˆ˜๋™ ์ž…๋ ฅ) - - {numberingRules - .filter((rule) => rule.table_name === masterTable || !rule.table_name) - .map((rule, idx) => { - const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); - const ruleName = rule.rule_name || rule.ruleName || "(์ด๋ฆ„ ์—†์Œ)"; - return ( - { - updateMasterDetailConfig({ numberingRuleId: ruleId }); - setNumberingRuleOpen(false); - }} - className="text-xs" - > - - {ruleName} - - ); - })} - - - - - -

- ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ”์˜ {relationInfo.masterKeyColumn} ๊ฐ’์„ ์ž๋™ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค -

-
+

+ ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ”์˜ {relationInfo.masterKeyColumn} ๊ฐ’์€ ์œ„์—์„œ ์„ค์ •ํ•œ ์ฑ„๋ฒˆ ๊ทœ์น™์œผ๋กœ ์ž๋™ + ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. +

)} {/* ๋งˆ์Šคํ„ฐ ํ•„๋“œ ์„ ํƒ - ์‚ฌ์šฉ์ž๊ฐ€ ์—‘์…€ ์—…๋กœ๋“œ ์‹œ ์ž…๋ ฅํ•  ํ•„๋“œ */} @@ -3726,14 +3638,6 @@ const MasterDetailExcelUploadConfig: React.FC<{

์ฐธ์กฐ ํ…Œ์ด๋ธ”์—์„œ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ‘œ์‹œํ•  ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š”.

)} - - {/* ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ ์„ค์ • */} - )} @@ -3741,23 +3645,181 @@ const MasterDetailExcelUploadConfig: React.FC<{ }; /** - * ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ ์„ค์ • ์ปดํฌ๋„ŒํŠธ - * ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์ œ์–ด๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ์ง€์› + * ์—‘์…€ ์—…๋กœ๋“œ ์ฑ„๋ฒˆ ๊ทœ์น™ ์„ค์ • (๋‹จ์ผ ํ…Œ์ด๋ธ”/๋งˆ์Šคํ„ฐ-๋””ํ…Œ์ผ ๋ชจ๋‘ ์‚ฌ์šฉ ๊ฐ€๋Šฅ) */ -const AfterUploadControlConfig: React.FC<{ - config: any; - onUpdateProperty: (path: string, value: any) => void; - masterDetailConfig: any; - updateMasterDetailConfig: (updates: any) => void; -}> = ({ masterDetailConfig, updateMasterDetailConfig }) => { - const [nodeFlows, setNodeFlows] = useState< - Array<{ flowId: number; flowName: string; flowDescription?: string }> - >([]); +const ExcelNumberingRuleConfig: React.FC<{ + config: { numberingRuleId?: string; numberingTargetColumn?: string }; + updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void; + tableName?: string; // ๋‹จ์ผ ํ…Œ์ด๋ธ”์ธ ๊ฒฝ์šฐ ํ…Œ์ด๋ธ”๋ช… + hasSplitPanel?: boolean; // ๋ถ„ํ•  ํŒจ๋„ ์—ฌ๋ถ€ (๋งˆ์Šคํ„ฐ-๋””ํ…Œ์ผ) +}> = ({ config, updateConfig, tableName, hasSplitPanel }) => { + const [numberingRules, setNumberingRules] = useState([]); + const [ruleSelectOpen, setRuleSelectOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [tableColumns, setTableColumns] = useState>([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // ์ฑ„๋ฒˆ ๊ทœ์น™ ๋ชฉ๋ก ๋กœ๋“œ + useEffect(() => { + const loadNumberingRules = async () => { + setIsLoading(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get("/numbering-rules"); + if (response.data?.success && response.data?.data) { + setNumberingRules(response.data.data); + } + } catch (error) { + console.error("์ฑ„๋ฒˆ ๊ทœ์น™ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); + } finally { + setIsLoading(false); + } + }; + + loadNumberingRules(); + }, []); + + // ๋‹จ์ผ ํ…Œ์ด๋ธ”์ธ ๊ฒฝ์šฐ ์ปฌ๋Ÿผ ๋ชฉ๋ก ๋กœ๋“œ + useEffect(() => { + if (!tableName || hasSplitPanel) { + setTableColumns([]); + return; + } + + const loadColumns = async () => { + setColumnsLoading(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data?.success && response.data?.data?.columns) { + const cols = response.data.data.columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + })); + setTableColumns(cols); + } + } catch (error) { + console.error("์ปฌ๋Ÿผ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:", error); + } finally { + setColumnsLoading(false); + } + }; + + loadColumns(); + }, [tableName, hasSplitPanel]); + + const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId)); + + return ( +
+ +

+ ์—…๋กœ๋“œ ์‹œ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•  ์ฝ”๋“œ/๋ฒˆํ˜ธ์˜ ์ฑ„๋ฒˆ ๊ทœ์น™์„ ์„ ํƒํ•˜์„ธ์š”. +

+ + + + + + + + + + ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์Œ + + { + updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined }); + setRuleSelectOpen(false); + }} + className="text-xs" + > + + ์ฑ„๋ฒˆ ์—†์Œ + + {numberingRules.map((rule, idx) => { + const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); + const ruleName = rule.rule_name || rule.ruleName || "(์ด๋ฆ„ ์—†์Œ)"; + return ( + { + updateConfig({ numberingRuleId: ruleId }); + setRuleSelectOpen(false); + }} + className="text-xs" + > + + {ruleName} + + ); + })} + + + + + + + {/* ๋‹จ์ผ ํ…Œ์ด๋ธ”์ด๊ณ  ์ฑ„๋ฒˆ ๊ทœ์น™์ด ์„ ํƒ๋œ ๊ฒฝ์šฐ, ์ ์šฉํ•  ์ปฌ๋Ÿผ ์„ ํƒ */} + {config.numberingRuleId && !hasSplitPanel && tableName && ( +
+ + +

์ฑ„๋ฒˆ ๊ฐ’์ด ์ž…๋ ฅ๋  ์ปฌ๋Ÿผ์„ ์„ ํƒํ•˜์„ธ์š”.

+
+ )} + + {/* ๋ถ„ํ•  ํŒจ๋„์ธ ๊ฒฝ์šฐ ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ */} + {config.numberingRuleId && hasSplitPanel && ( +

๋งˆ์Šคํ„ฐ-๋””ํ…Œ์ผ ๊ตฌ์กฐ์—์„œ๋Š” ๋งˆ์Šคํ„ฐ ํ‚ค ์ปฌ๋Ÿผ์— ์ž๋™ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.

+ )} +
+ ); +}; + +/** + * ์—‘์…€ ์—…๋กœ๋“œ ํ›„ ์ œ์–ด ์‹คํ–‰ ์„ค์ • (๋‹จ์ผ ํ…Œ์ด๋ธ”/๋งˆ์Šคํ„ฐ-๋””ํ…Œ์ผ ๋ชจ๋‘ ์‚ฌ์šฉ ๊ฐ€๋Šฅ) + */ +const ExcelAfterUploadControlConfig: React.FC<{ + config: { afterUploadFlows?: Array<{ flowId: string; order: number }> }; + updateConfig: (updates: { afterUploadFlows?: Array<{ flowId: string; order: number }> }) => void; +}> = ({ config, updateConfig }) => { + const [nodeFlows, setNodeFlows] = useState>([]); const [flowSelectOpen, setFlowSelectOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - // ์„ ํƒ๋œ ์ œ์–ด ๋ชฉ๋ก (๋ฐฐ์—ด๋กœ ๊ด€๋ฆฌ) - const selectedFlows: Array<{ flowId: string; order: number }> = masterDetailConfig.afterUploadFlows || []; + const selectedFlows = config.afterUploadFlows || []; // ๋…ธ๋“œ ํ”Œ๋กœ์šฐ ๋ชฉ๋ก ๋กœ๋“œ useEffect(() => { @@ -3779,53 +3841,39 @@ const AfterUploadControlConfig: React.FC<{ loadNodeFlows(); }, []); - // ์ œ์–ด ์ถ”๊ฐ€ const addFlow = (flowId: string) => { if (selectedFlows.some((f) => f.flowId === flowId)) return; const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }]; - updateMasterDetailConfig({ afterUploadFlows: newFlows }); + updateConfig({ afterUploadFlows: newFlows }); setFlowSelectOpen(false); }; - // ์ œ์–ด ์ œ๊ฑฐ const removeFlow = (flowId: string) => { - const newFlows = selectedFlows - .filter((f) => f.flowId !== flowId) - .map((f, idx) => ({ ...f, order: idx + 1 })); - updateMasterDetailConfig({ afterUploadFlows: newFlows }); + const newFlows = selectedFlows.filter((f) => f.flowId !== flowId).map((f, idx) => ({ ...f, order: idx + 1 })); + updateConfig({ afterUploadFlows: newFlows }); }; - // ์ˆœ์„œ ๋ณ€๊ฒฝ (์œ„๋กœ) const moveUp = (index: number) => { if (index === 0) return; const newFlows = [...selectedFlows]; [newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]]; - updateMasterDetailConfig({ - afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), - }); + updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) }); }; - // ์ˆœ์„œ ๋ณ€๊ฒฝ (์•„๋ž˜๋กœ) const moveDown = (index: number) => { if (index === selectedFlows.length - 1) return; const newFlows = [...selectedFlows]; [newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]]; - updateMasterDetailConfig({ - afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), - }); + updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) }); }; - // ์„ ํƒ๋˜์ง€ ์•Š์€ ํ”Œ๋กœ์šฐ๋งŒ ํ•„ํ„ฐ๋ง const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId))); return (
-

- ์—‘์…€ ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ํ•  ์ œ์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”. -

+

์—‘์…€ ์—…๋กœ๋“œ ์™„๋ฃŒ ํ›„ ์ˆœ์„œ๋Œ€๋กœ ์‹คํ–‰ํ•  ์ œ์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

- {/* ์„ ํƒ๋œ ์ œ์–ด ๋ชฉ๋ก */} {selectedFlows.length > 0 && (
{selectedFlows.map((selected, index) => { @@ -3852,7 +3900,12 @@ const AfterUploadControlConfig: React.FC<{ > -
@@ -3861,7 +3914,6 @@ const AfterUploadControlConfig: React.FC<{
)} - {/* ์ œ์–ด ์ถ”๊ฐ€ ๋ฒ„ํŠผ */}