diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 013b2034..ed2576cd 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -50,6 +50,9 @@ export class EntityJoinController { // search가 문자열인 경우 JSON 파싱 searchConditions = typeof search === "string" ? JSON.parse(search) : search; + + // 🔍 디버그: 파싱된 검색 조건 로깅 + logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2)); } catch (error) { logger.warn("검색 조건 파싱 오류:", error); searchConditions = {}; diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 46b4d799..f3e65199 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Trash2, Plus } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react"; import { ColumnFilter, DataFilterConfig } from "@/types/screen-management"; import { UnifiedColumnInfo } from "@/types/table-management"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; @@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps { menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요) } +/** + * 접을 수 있는 필터 항목 컴포넌트 + */ +interface FilterItemCollapsibleProps { + filter: ColumnFilter; + index: number; + filterSummary: string; + onRemove: () => void; + children: React.ReactNode; +} + +const FilterItemCollapsible: React.FC = ({ + filter, + index, + filterSummary, + onRemove, + children, +}) => { + const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로 + + return ( + +
+ +
+ {/* 상단: 필터 번호 + 삭제 버튼 */} +
+
+ {isOpen ? ( + + ) : ( + + )} + 필터 {index + 1} +
+ +
+ {/* 하단: 필터 요약 (전체 너비 사용) */} +
+ + {filterSummary} + +
+
+
+ {children} +
+
+ ); +}; + /** * 데이터 필터 설정 패널 * 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용 @@ -36,13 +98,13 @@ export function DataFilterConfigPanel({ menuObjid, sampleColumns: columns.slice(0, 3), }); - + const [localConfig, setLocalConfig] = useState( config || { enabled: false, filters: [], matchType: "all", - } + }, ); // 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록) @@ -52,7 +114,7 @@ export function DataFilterConfigPanel({ useEffect(() => { if (config) { setLocalConfig(config); - + // 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드 config.filters?.forEach((filter) => { if (filter.valueType === "category" && filter.columnName) { @@ -69,7 +131,7 @@ export function DataFilterConfigPanel({ return; // 이미 로드되었거나 로딩 중이면 스킵 } - setLoadingCategories(prev => ({ ...prev, [columnName]: true })); + setLoadingCategories((prev) => ({ ...prev, [columnName]: true })); try { console.log("🔍 카테고리 값 로드 시작:", { @@ -82,7 +144,7 @@ export function DataFilterConfigPanel({ tableName, columnName, false, // includeInactive - menuObjid // 🆕 메뉴 OBJID 전달 + menuObjid, // 🆕 메뉴 OBJID 전달 ); console.log("📦 카테고리 값 로드 응답:", response); @@ -92,16 +154,16 @@ export function DataFilterConfigPanel({ value: item.valueCode, label: item.valueLabel, })); - + console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length }); - setCategoryValues(prev => ({ ...prev, [columnName]: values })); + setCategoryValues((prev) => ({ ...prev, [columnName]: values })); } else { console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response); } } catch (error) { console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error); } finally { - setLoadingCategories(prev => ({ ...prev, [columnName]: false })); + setLoadingCategories((prev) => ({ ...prev, [columnName]: false })); } }; @@ -145,9 +207,7 @@ export function DataFilterConfigPanel({ const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => { const newConfig = { ...localConfig, - filters: localConfig.filters.map((filter) => - filter.id === filterId ? { ...filter, [field]: value } : filter - ), + filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)), }; setLocalConfig(newConfig); onConfigChange(newConfig); @@ -178,7 +238,7 @@ export function DataFilterConfigPanel({ <> {/* 테이블명 표시 */} {tableName && ( -
+
테이블: {tableName}
)} @@ -200,235 +260,127 @@ export function DataFilterConfigPanel({ )} {/* 필터 목록 */} -
- {localConfig.filters.map((filter, index) => ( -
-
- - 필터 {index + 1} - - -
+
+ {localConfig.filters.map((filter, index) => { + // 연산자 표시 텍스트 + const operatorLabels: Record = { + equals: "=", + not_equals: "!=", + greater_than: ">", + less_than: "<", + greater_than_or_equal: ">=", + less_than_or_equal: "<=", + between: "BETWEEN", + in: "IN", + not_in: "NOT IN", + contains: "LIKE", + starts_with: "시작", + ends_with: "끝", + is_null: "IS NULL", + is_not_null: "IS NOT NULL", + date_range_contains: "기간 내", + }; - {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} - {filter.operator !== "date_range_contains" && ( -
- - -
- )} + // 컬럼 라벨 찾기 + const columnLabel = + columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName; - {/* 연산자 선택 */} -
- - -
+ // 필터 요약 텍스트 생성 + const filterSummary = filter.columnName + ? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${ + filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value + ? ` ${filter.value}` + : "" + }` + : "설정 필요"; - {/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */} - {filter.operator === "date_range_contains" && ( - <> -
-

- 💡 날짜 범위 필터링 규칙: -
• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터 -
• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터 -
• 둘 다 있으면 → 기간 내 데이터만 -

-
+ return ( + handleRemoveFilter(filter.id)} + > + {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} + {filter.operator !== "date_range_contains" && (
- + -
-
- - -
- - )} + const column = columns.find((col) => col.columnName === value); - {/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */} - {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && ( -
- - +
+ )} + + {/* 연산자 선택 */} +
+ +
- )} - {/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */} - {filter.operator !== "is_null" && - filter.operator !== "is_not_null" && - !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && ( -
- - {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} - {filter.valueType === "category" && categoryValues[filter.columnName] ? ( + {/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */} + {filter.operator === "date_range_contains" && ( + <> +
+

+ 💡 날짜 범위 필터링 규칙: +
• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터 +
• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터 +
• 둘 다 있으면 → 기간 내 데이터만 +

+
+
+ + +
+
+ + +
+ + )} + + {/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */} + {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && ( +
+ - ) : filter.operator === "in" || filter.operator === "not_in" ? ( - { - const values = e.target.value.split(",").map((v) => v.trim()); - handleFilterChange(filter.id, "value", values); - }} - placeholder="쉼표로 구분 (예: 값1, 값2, 값3)" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> - ) : filter.operator === "between" ? ( - { - const values = e.target.value.split("~").map((v) => v.trim()); - handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]); - }} - placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> - ) : ( - handleFilterChange(filter.id, "value", e.target.value)} - placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> +
+ )} + + {/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */} + {filter.operator !== "is_null" && + filter.operator !== "is_not_null" && + !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && ( +
+ + {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} + {filter.valueType === "category" && categoryValues[filter.columnName] ? ( + + ) : filter.operator === "in" || filter.operator === "not_in" ? ( + { + const values = e.target.value.split(",").map((v) => v.trim()); + handleFilterChange(filter.id, "value", values); + }} + placeholder="쉼표로 구분 (예: 값1, 값2, 값3)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : filter.operator === "between" ? ( + { + const values = e.target.value.split("~").map((v) => v.trim()); + handleFilterChange( + filter.id, + "value", + values.length === 2 ? values : [values[0] || "", ""], + ); + }} + placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : ( + handleFilterChange(filter.id, "value", e.target.value)} + placeholder={ + filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력" + } + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + )} +

+ {filter.valueType === "category" && categoryValues[filter.columnName] + ? "카테고리 값을 선택하세요" + : filter.operator === "in" || filter.operator === "not_in" + ? "여러 값은 쉼표(,)로 구분하세요" + : filter.operator === "between" + ? "시작과 종료 값을 ~로 구분하세요" + : filter.operator === "date_range_contains" + ? "기간 내에 포함되는지 확인할 날짜를 선택하세요" + : "필터링할 값을 입력하세요"} +

+
)} -

- {filter.valueType === "category" && categoryValues[filter.columnName] - ? "카테고리 값을 선택하세요" - : filter.operator === "in" || filter.operator === "not_in" - ? "여러 값은 쉼표(,)로 구분하세요" - : filter.operator === "between" - ? "시작과 종료 값을 ~로 구분하세요" - : filter.operator === "date_range_contains" - ? "기간 내에 포함되는지 확인할 날짜를 선택하세요" - : "필터링할 값을 입력하세요"} -

-
- )} - - {/* date_range_contains의 dynamic 타입 안내 */} - {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( -
-

- ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다. -

-
- )} -
- ))} + + {/* date_range_contains의 dynamic 타입 안내 */} + {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( +
+

오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.

+
+ )} + + ); + })}
{/* 필터 추가 버튼 */} {columns.length === 0 && ( -

- 테이블을 먼저 선택해주세요 -

+

테이블을 먼저 선택해주세요

)} )}
); } - 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"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운) + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index e7904a95..4f4595ff 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,9 +36,66 @@ import { LayoutGrid, FileSpreadsheet, BarChart3, + Filter, + 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; +}; + // ==================== 서브 컴포넌트 ==================== // 행 헤더 셀 @@ -45,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; @@ -63,6 +123,7 @@ const RowHeaderCell: React.FC = ({ row.isExpanded && "bg-muted/70" )} style={{ paddingLeft: `${8 + indentSize}px` }} + rowSpan={rowSpan > 1 ? rowSpan : undefined} >
{row.hasChildren && ( @@ -88,7 +149,8 @@ const RowHeaderCell: React.FC = ({ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; - onClick?: () => void; + isSelected?: boolean; + onClick?: (e?: React.MouseEvent) => void; onDoubleClick?: () => void; conditionalStyle?: CellFormatStyle; } @@ -96,6 +158,7 @@ interface DataCellProps { const DataCell: React.FC = ({ values, isTotal = false, + isSelected = false, onClick, onDoubleClick, conditionalStyle, @@ -104,6 +167,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 +177,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} @@ -122,19 +189,26 @@ const DataCell: React.FC = ({ ); } + // 툴팁 내용 생성 + const tooltipContent = values.map((v) => + `${v.field || "값"}: ${v.formattedValue || v.value}` + ).join("\n"); + // 단일 데이터 필드인 경우 if (values.length === 1) { return ( {/* Data Bar */} {hasDataBar && ( @@ -164,11 +238,13 @@ 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} onDoubleClick={onDoubleClick} + title={`${val.field || "값"}: ${val.formattedValue || val.value}`} > {hasDataBar && (
= ({ 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 [selectionRange, setSelectionRange] = useState<{ + startRow: number; + startCol: number; + endRow: number; + endCol: number; + } | null>(null); + const tableRef = useRef(null); + + // 정렬 상태 + const [sortConfig, setSortConfig] = useState<{ + 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(() => { @@ -252,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 || []; @@ -281,6 +416,7 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + // 필터 영역 필드 const filterFields = useMemo( () => fields @@ -318,25 +454,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 +544,102 @@ 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(); + }, []); + + // 열 크기 조절 중 + 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 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: sortedFlatRows.length, + itemHeight: ROW_HEIGHT, + containerHeight: containerHeight, + overscan: 10, + }); + + // 가상 스크롤 적용된 행 데이터 + const visibleFlatRows = useMemo(() => { + if (!enableVirtualScroll) return sortedFlatRows; + return sortedFlatRows.slice(virtualScroll.startIndex, virtualScroll.endIndex + 1); + }, [enableVirtualScroll, sortedFlatRows, virtualScroll.startIndex, virtualScroll.endIndex]); + // 조건부 서식 스타일 계산 헬퍼 const getCellConditionalStyle = useCallback( (value: number | undefined, field: string): CellFormatStyle => { @@ -567,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()); + }, []); // ==================== 렌더링 ==================== @@ -587,9 +995,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 +1054,221 @@ 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); + 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; + } + + if (newRowIndex !== rowIndex || newColIndex !== colIndex) { + setSelectedCell({ rowIndex: newRowIndex, colIndex: newColIndex }); + } + }; + + // 셀 클릭으로 선택 (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); + }); + }; + + // 정렬 토글 + 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}건`})
@@ -761,8 +1385,101 @@ export const PivotGridComponent: React.FC = ({ > + + )} + + + + + + + + {/* 숨겨진 필드 표시 드롭다운 */} + {hiddenFieldsList.length > 0 && ( +
+ +
+
+ 숨겨진 필드 +
+
+ {hiddenFieldsList.map((field) => ( + + ))} +
+
+ +
+
+
+ )}
+ {/* 필터 바 - 필터 영역에 필드가 있을 때만 표시 */} + {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={ + + } + /> + ); + })} +
+
+ )} + {/* 피벗 테이블 */} -
- +
+
{/* 열 헤더 */} - {/* 좌상단 코너 (행 필드 라벨) */} + {/* 좌상단 코너 (행 필드 라벨 + 필터) */} {/* 열 헤더 셀 */} @@ -803,15 +1606,71 @@ export const PivotGridComponent: React.FC = ({ ))} + + {/* 열 필드 필터 (헤더 왼쪽에 표시) */} + {columnFields.length > 0 && ( + + )} {/* 행 총계 헤더 */} {totals?.showRowGrandTotals && ( @@ -839,10 +1698,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,62 +1728,124 @@ export const PivotGridComponent: React.FC = ({
- {flatRows.map((row, rowIdx) => ( - - {/* 행 헤더 */} - + {/* 열 총계 행 (상단 위치) */} + {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( + + - {/* 데이터 셀 */} - {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 && ( + {flatColumns.map((col, colIdx) => ( + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + )} - ))} + )} + + {/* 가상 스크롤 상단 여백 */} + {enableVirtualScroll && virtualScroll.offsetTop > 0 && ( + + + )} + + {(() => { + // 셀 병합 정보 계산 + const mergeInfo = calculateMergeCells(visibleFlatRows, style?.mergeCells || false); + + 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 && ( + + )} - {/* 열 총계 행 */} - {totals?.showColumnGrandTotals && ( + {/* 데이터 셀 */} + {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 = isCellInRange(rowIdx, colIdx); + + return ( + { + handleCellSelect(rowIdx, colIdx, e?.shiftKey || false); + if (onCellClick) { + handleCellClick(row.path, col.path, values); + } + }} + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ); + }); + })()} + + {/* 가상 스크롤 하단 여백 */} + {enableVirtualScroll && ( + + + )} + + {/* 열 총계 행 (하단 위치 - 기본값) */} + {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 && 항목} +
handleSort(dataFields[0].field) : undefined} > - {col.caption || "(전체)"} +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
+ {/* 열 리사이즈 핸들 */} +
handleResizeStart(idx, e)} + />
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={ + + } + /> + ))} +
+
+ 총계 +
+
+
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,484 @@ 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 && ( -
-
- + {/* 고급 설정 */} + {showAdvanced && ( +
+ {/* 표시 설정 */} +
+ + +
+
+ + + updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) + } + /> +
+ +
+
- -
- - - updateConfig({ - chart: { - ...config.chart, - enabled: true, - type: config.chart?.type || "bar", - position: config.chart?.position || "bottom", - height: Number(e.target.value), - }, - }) + +
+ + +
+ +
+ + + updateConfig({ totals: { ...config.totals, showRowTotals: v } }) } - 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, showColumnTotals: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, alternateRowColors: v } }) + } + /> +
+ +
+ + + updateConfig({ style: { ...config.style, mergeCells: v } }) + } + /> +
+ +
+ + + updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) + } + /> +
+ +
+ + + updateConfig({ saveState: 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" + /> +
+
+
+ + {/* 조건부 서식 */} +
+ +
+ {(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" && ( + + )} + + +
+ ))} + + +
-
- - - - {/* 조건부 서식 설정 */} -
- - -
-
- - 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/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/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"; 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; // 같은 값 셀 병합 } // ==================== 내보내기 설정 ==================== diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index 77eadca0..4a8f66a5 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -298,11 +298,16 @@ export const RackStructureComponent: React.FC = ({ warehouseName: fieldMapping.warehouseNameField ? formData[fieldMapping.warehouseNameField] : undefined, - // 카테고리 값은 라벨로 변환 + // 카테고리 값은 라벨로 변환 (화면 표시용) floor: getCategoryLabel(rawFloor?.toString()), zone: getCategoryLabel(rawZone), locationType: getCategoryLabel(rawLocationType), status: getCategoryLabel(rawStatus), + // 카테고리 코드 원본값 (DB 쿼리/저장용) + floorCode: rawFloor?.toString(), + zoneCode: rawZone?.toString(), + locationTypeCode: rawLocationType?.toString(), + statusCode: rawStatus?.toString(), }; console.log("🏗️ [RackStructure] context 생성:", { @@ -399,8 +404,12 @@ export const RackStructureComponent: React.FC = ({ // 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지) const warehouseCodeForQuery = context.warehouseCode; - const floorForQuery = context.floor; // 라벨 값 (예: "1층") - const zoneForQuery = context.zone; // 라벨 값 (예: "A구역") + // DB 쿼리 시에는 카테고리 코드 사용 (코드로 통일) + const floorForQuery = (context as any).floorCode || context.floor; + const zoneForQuery = (context as any).zoneCode || context.zone; + // 화면 표시용 라벨 + const floorLabel = context.floor; + const zoneLabel = context.zone; // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) useEffect(() => { @@ -426,7 +435,7 @@ export const RackStructureComponent: React.FC = ({ // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) const searchParams = { - warehouse_id: { value: warehouseCodeForQuery, operator: "equals" }, + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, floor: { value: floorForQuery, operator: "equals" }, zone: { value: zoneForQuery, operator: "equals" }, }; @@ -597,18 +606,20 @@ export const RackStructureComponent: React.FC = ({ for (let level = 1; level <= cond.levels; level++) { const { code, name } = generateLocationCode(row, level); // 테이블 컬럼명과 동일하게 생성 + // DB 저장 시에는 카테고리 코드 사용 (코드로 통일) + const ctxAny = context as any; locations.push({ row_num: String(row), level_num: String(level), location_code: code, location_name: name, - location_type: context?.locationType || "선반", - status: context?.status || "사용", - // 추가 필드 (테이블 컬럼명과 동일) + location_type: ctxAny?.locationTypeCode || context?.locationType || "선반", + status: ctxAny?.statusCode || context?.status || "사용", + // 추가 필드 (테이블 컬럼명과 동일) - 카테고리 코드 사용 warehouse_code: context?.warehouseCode, warehouse_name: context?.warehouseName, - floor: context?.floor, - zone: context?.zone, + floor: ctxAny?.floorCode || context?.floor, + zone: ctxAny?.zoneCode || context?.zone, }); } } @@ -930,13 +941,14 @@ export const RackStructureComponent: React.FC = ({ {idx + 1} {loc.location_code} {loc.location_name} - {loc.floor || context?.floor || "1"} - {loc.zone || context?.zone || "A"} + {/* 미리보기에서는 카테고리 코드를 라벨로 변환하여 표시 */} + {getCategoryLabel(loc.floor) || context?.floor || "1"} + {getCategoryLabel(loc.zone) || context?.zone || "A"} {loc.row_num.padStart(2, "0")} {loc.level_num} - {loc.location_type} + {getCategoryLabel(loc.location_type) || loc.location_type} - ))} diff --git a/frontend/lib/registry/components/rack-structure/types.ts b/frontend/lib/registry/components/rack-structure/types.ts index 8670d4a0..76214972 100644 --- a/frontend/lib/registry/components/rack-structure/types.ts +++ b/frontend/lib/registry/components/rack-structure/types.ts @@ -72,10 +72,15 @@ export interface RackStructureContext { warehouseId?: string; // 창고 ID warehouseCode?: string; // 창고 코드 (예: WH001) warehouseName?: string; // 창고명 (예: 제1창고) - floor?: string; // 층 (예: 1) - zone?: string; // 구역 (예: A) - locationType?: string; // 위치 유형 (예: 선반) - status?: string; // 사용 여부 (예: 사용) + floor?: string; // 층 라벨 (예: 1층) - 화면 표시용 + zone?: string; // 구역 라벨 (예: A구역) - 화면 표시용 + locationType?: string; // 위치 유형 라벨 (예: 선반) + status?: string; // 사용 여부 라벨 (예: 사용) + // 카테고리 코드 (DB 저장/쿼리용) + floorCode?: string; // 층 카테고리 코드 (예: CATEGORY_767659DCUF) + zoneCode?: string; // 구역 카테고리 코드 (예: CATEGORY_82925656Q8) + locationTypeCode?: string; // 위치 유형 카테고리 코드 + statusCode?: string; // 사용 여부 카테고리 코드 } // 컴포넌트 Props diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index c0327303..2aefb047 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -285,11 +285,14 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // onChange 호출하여 부모에게 알림 if (onChange && items.length > 0) { + // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 + const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); const dataWithMeta = items.map((item: any) => ({ ...item, _targetTable: targetTable, _originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달 _existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드) + _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 })); onChange(dataWithMeta); } @@ -388,10 +391,13 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // onChange 호출 (effectiveTargetTable 사용) if (onChange) { if (items.length > 0) { + // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 + const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); const dataWithMeta = items.map((item: any) => ({ ...item, _targetTable: effectiveTargetTable, _existingRecord: !!item.id, + _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 })); onChange(dataWithMeta); } else { @@ -673,26 +679,25 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화 const handleRepeaterChange = useCallback( (newValue: any[]) => { - // 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가 - let valueWithMeta = newValue; + // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 + const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); + + // 🆕 모든 항목에 메타데이터 추가 + let valueWithMeta = newValue.map((item: any) => ({ + ...item, + _targetTable: effectiveTargetTable || targetTable, + _existingRecord: !!item.id, + _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 + })); - if (isRightPanel && effectiveTargetTable) { - valueWithMeta = newValue.map((item: any) => { - const itemWithMeta = { - ...item, - _targetTable: effectiveTargetTable, - }; - - // 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가 - if (fkColumn && fkValue && item._isNewItem) { - itemWithMeta[fkColumn] = fkValue; - console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { - fkColumn, - fkValue, - }); + // 🆕 분할 패널에서 우측인 경우, FK 값 추가 + if (isRightPanel && fkColumn && fkValue) { + valueWithMeta = valueWithMeta.map((item: any) => { + if (item._isNewItem) { + console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { fkColumn, fkValue }); + return { ...item, [fkColumn]: fkValue }; } - - return itemWithMeta; + return item; }); } @@ -754,6 +759,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => screenContext?.updateFormData, isRightPanel, effectiveTargetTable, + targetTable, fkColumn, fkValue, fieldName, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 4a21596e..71503e91 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1026,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC // 추가 dataFilter 적용 let filteredData = result.data || []; const dataFilter = componentConfig.rightPanel?.dataFilter; - if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { + // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) + const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; + if (dataFilter?.enabled && filterConditions.length > 0) { filteredData = filteredData.filter((item: any) => { - return dataFilter.conditions.every((cond: any) => { - const value = item[cond.column]; + return filterConditions.every((cond: any) => { + // columnName 또는 column 지원 + const columnName = cond.columnName || cond.column; + const value = item[columnName]; const condValue = cond.value; switch (cond.operator) { case "equals": @@ -1038,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC return value !== condValue; case "contains": return String(value).includes(String(condValue)); + case "is_null": + case "NULL": + return value === null || value === undefined || value === ""; + case "is_not_null": + case "NOT NULL": + return value !== null && value !== undefined && value !== ""; default: return true; } @@ -1137,8 +1147,28 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 추가 탭 데이터 로딩 함수 const loadTabData = useCallback( async (tabIndex: number, leftItem: any) => { + console.log(`📥 loadTabData 호출됨: tabIndex=${tabIndex}`, { + leftItem: leftItem ? Object.keys(leftItem) : null, + additionalTabs: componentConfig.rightPanel?.additionalTabs?.length, + isDesignMode, + }); + const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; - if (!tabConfig || !leftItem || isDesignMode) return; + + console.log(`📥 tabConfig:`, { + tabIndex, + configIndex: tabIndex - 1, + tabConfig: tabConfig ? { + tableName: tabConfig.tableName, + relation: tabConfig.relation, + dataFilter: tabConfig.dataFilter + } : null, + }); + + if (!tabConfig || !leftItem || isDesignMode) { + console.log(`⚠️ loadTabData 중단:`, { hasTabConfig: !!tabConfig, hasLeftItem: !!leftItem, isDesignMode }); + return; + } const tabTableName = tabConfig.tableName; if (!tabTableName) return; @@ -1150,6 +1180,14 @@ export const SplitPanelLayoutComponent: React.FC const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; + console.log(`🔑 [추가탭 ${tabIndex}] 조인 키 분석:`, { + hasRelation: !!tabConfig.relation, + keys, + leftColumn, + rightColumn, + willUseJoin: !!(leftColumn && rightColumn), + }); + let resultData: any[] = []; if (leftColumn && rightColumn) { @@ -1161,14 +1199,22 @@ export const SplitPanelLayoutComponent: React.FC // 복합키 keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) + searchConditions[key.rightColumn] = { + value: leftItem[key.leftColumn], + operator: "equals", + }; } }); } else { // 단일키 const leftValue = leftItem[leftColumn]; if (leftValue !== undefined) { - searchConditions[rightColumn] = leftValue; + // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) + searchConditions[rightColumn] = { + value: leftValue, + operator: "equals", + }; } } @@ -1183,33 +1229,68 @@ export const SplitPanelLayoutComponent: React.FC resultData = result.data || []; } else { // 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭) + console.log(`📋 [추가탭 ${tabIndex}] 조인 없이 전체 데이터 조회: ${tabTableName}`); const { entityJoinApi } = await import("@/lib/api/entityJoin"); const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { enableEntityJoin: true, size: 1000, }); resultData = result.data || []; + console.log(`📋 [추가탭 ${tabIndex}] 전체 데이터 조회 결과:`, resultData.length); } // 데이터 필터 적용 const dataFilter = tabConfig.dataFilter; - if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { + // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) + const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; + + console.log(`🔍 [추가탭 ${tabIndex}] 필터 설정:`, { + enabled: dataFilter?.enabled, + filterConditions, + dataBeforeFilter: resultData.length, + }); + + if (dataFilter?.enabled && filterConditions.length > 0) { + const beforeCount = resultData.length; resultData = resultData.filter((item: any) => { - return dataFilter.conditions.every((cond: any) => { - const value = item[cond.column]; + return filterConditions.every((cond: any) => { + // columnName 또는 column 지원 + const columnName = cond.columnName || cond.column; + const value = item[columnName]; const condValue = cond.value; + + let result = true; switch (cond.operator) { case "equals": - return value === condValue; + result = value === condValue; + break; case "notEquals": - return value !== condValue; + result = value !== condValue; + break; case "contains": - return String(value).includes(String(condValue)); + result = String(value).includes(String(condValue)); + break; + case "is_null": + case "NULL": + result = value === null || value === undefined || value === ""; + break; + case "is_not_null": + case "NOT NULL": + result = value !== null && value !== undefined && value !== ""; + break; default: - return true; + result = true; } + + // 첫 5개 항목만 로그 출력 + if (resultData.indexOf(item) < 5) { + console.log(` 필터 체크: ${columnName}=${value}, operator=${cond.operator}, result=${result}`); + } + + return result; }); }); + console.log(`🔍 [추가탭 ${tabIndex}] 필터 적용 후: ${beforeCount} → ${resultData.length}`); } // 중복 제거 적용 @@ -1281,6 +1362,12 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 탭 변경 핸들러 const handleTabChange = useCallback( (newTabIndex: number) => { + console.log(`🔄 탭 변경: ${activeTabIndex} → ${newTabIndex}`, { + selectedLeftItem: !!selectedLeftItem, + tabsData: Object.keys(tabsData), + hasTabData: !!tabsData[newTabIndex], + }); + setActiveTabIndex(newTabIndex); // 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드 @@ -1291,14 +1378,15 @@ export const SplitPanelLayoutComponent: React.FC loadRightData(selectedLeftItem); } } else { - // 추가 탭: 해당 탭 데이터가 없으면 로드 - if (!tabsData[newTabIndex]) { - loadTabData(newTabIndex, selectedLeftItem); - } + // 추가 탭: 항상 새로 로드 (필터 설정 변경 반영을 위해) + console.log(`🔄 추가 탭 ${newTabIndex} 데이터 로드 (항상 새로고침)`); + loadTabData(newTabIndex, selectedLeftItem); } + } else { + console.log(`⚠️ 좌측 항목이 선택되지 않아 탭 데이터를 로드하지 않음`); } }, - [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], + [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, activeTabIndex], ); // 우측 항목 확장/축소 토글 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 9810388f..048fb385 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -237,7 +237,12 @@ const AdditionalTabConfigPanel: React.FC = ({ // 탭 업데이트 헬퍼 const updateTab = (updates: Partial) => { const newTabs = [...(config.rightPanel?.additionalTabs || [])]; - newTabs[tabIndex] = { ...tab, ...updates }; + // undefined 값도 명시적으로 덮어쓰기 위해 Object.assign 대신 직접 처리 + const updatedTab = { ...tab }; + Object.keys(updates).forEach((key) => { + (updatedTab as any)[key] = (updates as any)[key]; + }); + newTabs[tabIndex] = updatedTab; updateRightPanel({ additionalTabs: newTabs }); }; @@ -393,21 +398,31 @@ const AdditionalTabConfigPanel: React.FC = ({
{ - updateTab({ - relation: { - ...tab.relation, - type: "join", - keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }], - }, - }); + if (value === "__none__") { + // 선택 안 함 - 조인 키 제거 + updateTab({ + relation: undefined, + }); + } else { + updateTab({ + relation: { + ...tab.relation, + type: "join", + keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }], + }, + }); + } }} > + + 선택 안 함 (전체 데이터) + {tabColumns.map((col) => ( {col.columnLabel || col.columnName} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index c7f39c39..cc82e386 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -690,6 +690,151 @@ export class ButtonActionExecutor { console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); } + // 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리 + // formData에 JSON 배열 문자열이 저장된 경우 처리 (반복_필드_그룹 등) + const repeaterJsonKeys = Object.keys(context.formData).filter((key) => { + const value = context.formData[key]; + if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) && parsed.length > 0 && parsed[0]._targetTable; + } catch { + return false; + } + } + return false; + }); + + if (repeaterJsonKeys.length > 0) { + console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys); + + // 🆕 상단 폼 데이터(마스터 정보) 추출 + // RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보 + const masterFields: Record = {}; + Object.keys(context.formData).forEach((fieldKey) => { + // 제외 조건 + if (fieldKey.startsWith("comp_")) return; + if (fieldKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) return; + if (fieldKey.endsWith("_label") || fieldKey.endsWith("_value_label")) return; + + const value = context.formData[fieldKey]; + + // JSON 배열 문자열 제외 (RepeaterFieldGroup 데이터) + if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return; + + // 객체 타입인 경우 (범용_폼_모달 등) 내부 필드를 펼쳐서 추가 + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + Object.entries(value).forEach(([innerKey, innerValue]) => { + if (innerKey.endsWith("_label") || innerKey.endsWith("_value_label")) return; + if (innerValue !== undefined && innerValue !== null && innerValue !== "") { + masterFields[innerKey] = innerValue; + } + }); + return; + } + + // 유효한 값만 포함 + if (value !== undefined && value !== null && value !== "") { + masterFields[fieldKey] = value; + } + }); + + console.log("📋 [handleSave] 상단 마스터 정보 (모든 품목에 적용):", masterFields); + + for (const key of repeaterJsonKeys) { + try { + const parsedData = JSON.parse(context.formData[key]); + const repeaterTargetTable = parsedData[0]?._targetTable; + + if (!repeaterTargetTable) { + console.warn(`⚠️ [handleSave] RepeaterFieldGroup targetTable 없음 (key: ${key})`); + continue; + } + + console.log(`📦 [handleSave] RepeaterFieldGroup 저장 시작: ${repeaterTargetTable}, ${parsedData.length}건`); + + // 🆕 품목 고유 필드 목록 (RepeaterFieldGroup 설정에서 가져옴) + // 첫 번째 아이템의 _repeaterFields에서 추출 + const repeaterFields: string[] = parsedData[0]?._repeaterFields || []; + const itemOnlyFields = new Set([...repeaterFields, 'id']); // id는 항상 포함 + + console.log("📋 [handleSave] RepeaterFieldGroup 품목 필드:", repeaterFields); + + for (const item of parsedData) { + // 메타 필드 제거 + const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item; + + // 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반) + const itemOnlyData: Record = {}; + Object.keys(itemData).forEach((field) => { + if (itemOnlyFields.has(field)) { + itemOnlyData[field] = itemData[field]; + } + }); + + // 🔧 마스터 정보 + 품목 고유 정보 병합 + // masterFields: 상단 폼에서 수정한 최신 마스터 정보 + // itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등) + const dataWithMeta: Record = { + ...masterFields, // 상단 마스터 정보 (최신) + ...itemOnlyData, // 품목 고유 필드만 + created_by: context.userId, + updated_by: context.userId, + company_code: context.companyCode, + }; + + // 불필요한 필드 제거 + Object.keys(dataWithMeta).forEach((field) => { + if (field.endsWith("_label") || field.endsWith("_value_label") || field.endsWith("_numberingRuleId")) { + delete dataWithMeta[field]; + } + }); + + // 새 레코드 vs 기존 레코드 판단 + const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined; + + console.log(`📦 [handleSave] 저장할 데이터 (${isNewRecord ? 'INSERT' : 'UPDATE'}):`, { + id: item.id, + dataWithMeta, + }); + + if (isNewRecord) { + // INSERT - DynamicFormApi 사용하여 제어관리 실행 + delete dataWithMeta.id; + + const insertResult = await DynamicFormApi.saveFormData({ + screenId: context.screenId || 0, + tableName: repeaterTargetTable, + data: dataWithMeta as Record, + }); + console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data); + } else if (item.id && _existingRecord === true) { + // UPDATE - 기존 레코드 + const originalData = { id: item.id }; + const updatedData = { ...dataWithMeta, id: item.id }; + + const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, { + originalData, + updatedData, + }); + console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data); + } + } + } catch (err) { + console.error(`❌ [handleSave] RepeaterFieldGroup 저장 실패 (key: ${key}):`, err); + } + } + + // RepeaterFieldGroup 저장 완료 후 새로고침 + console.log("✅ [handleSave] RepeaterFieldGroup 저장 완료"); + context.onRefresh?.(); + context.onFlowRefresh?.(); + window.dispatchEvent(new CustomEvent("closeEditModal")); + window.dispatchEvent(new CustomEvent("saveSuccessInModal")); + + return true; + } + // 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 // 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장 const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData); @@ -1467,11 +1612,12 @@ export class ButtonActionExecutor { console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone }); try { + // search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨) const existingResponse = await DynamicFormApi.getTableData(tableName, { - filters: { - warehouse_code: warehouseCode, - floor: floor, - zone: zone, + search: { + warehouse_code: { value: warehouseCode, operator: "equals" }, + floor: { value: floor, operator: "equals" }, + zone: { value: zone, operator: "equals" }, }, page: 1, pageSize: 1000,