diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 7ff62629..4b4a3855 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4055,7 +4055,6 @@ export default function ScreenDesigner({ // 컴포넌트별 gridColumns 설정 및 크기 계산 let componentSize = component.defaultSize; - const isCardDisplay = component.id === "card-display"; const isTableList = component.id === "table-list"; // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 @@ -4063,9 +4062,7 @@ export default function ScreenDesigner({ let gridColumns = 1; // 기본값 // 특수 컴포넌트 - if (isCardDisplay) { - gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67% - } else if (isTableList) { + if (isTableList) { gridColumns = currentGridColumns; // 테이블은 전체 너비 } else { // 웹타입별 적절한 그리드 컬럼 수 설정 @@ -4095,7 +4092,6 @@ export default function ScreenDesigner({ // 표시 컴포넌트 (DISPLAY 카테고리) "label-basic": 2 / 12, // 라벨 (16.67%) "text-display": 3 / 12, // 텍스트 표시 (25%) - "card-display": 8 / 12, // 카드 (66.67%) "badge-basic": 1 / 12, // 배지 (8.33%) "alert-basic": 6 / 12, // 알림 (50%) "divider-basic": 1, // 구분선 (100%) diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 59e1e7b5..14a22e0d 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -139,7 +139,6 @@ export function ComponentsPanel({ "button-primary", // → v2-button-primary "split-panel-layout", // → v2-split-panel-layout "aggregation-widget", // → v2-aggregation-widget - "card-display", // → v2-card-display "table-list", // → v2-table-list "text-display", // → v2-text-display "divider-line", // → v2-divider-line @@ -176,14 +175,11 @@ export function ComponentsPanel({ // ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats` "v2-aggregation-widget", // → stats "v2-status-count", // → stats - "v2-card-display", // → stats // aggregation-widget, card-display 는 기존 상단에서 이미 숨김 // form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정. "field-example-1", // legacy form-layout 의 실제 id (숨김 유지) // ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table` "v2-table-list", // → table (displayMode='table') - "v2-table-grouped", // → table (displayMode='grouped') - "v2-pivot-grid", // → table (displayMode='pivot') "v2-split-panel-layout", // → table (displayMode='split') // table-list, split-panel-layout, split-panel-layout2, modal-repeater-table, // simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김 @@ -206,7 +202,6 @@ export function ComponentsPanel({ "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "repeat-container", // → v2-repeat-container "repeat-screen-modal", // → v2-repeat-screen-modal - "pivot-grid", // → v2-pivot-grid "table-search-widget", // → v2-table-search-widget "tabs", // → v2-tabs "tabs-widget", // → v2-tabs-widget diff --git a/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx b/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx deleted file mode 100644 index c8537ae0..00000000 --- a/frontend/components/v2/config-panels/V2CardDisplayConfigPanel.tsx +++ /dev/null @@ -1,789 +0,0 @@ -"use client"; - -/** - * V2CardDisplay 설정 패널 - * 토스식 단계별 UX: 테이블 선택 -> 컬럼 매핑 -> 카드 스타일 -> 고급 설정(접힘) - */ - -import React, { useState, useEffect, useMemo } from "react"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { Badge } from "@/components/ui/badge"; -import { - Settings, - ChevronDown, - Database, - ChevronsUpDown, - Check, - Plus, - Trash2, - Loader2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { tableManagementApi } from "@/lib/api/tableManagement"; - -// ─── 한 행당 카드 수 카드 정의 ─── -const CARDS_PER_ROW_OPTIONS = [ - { value: 1, label: "1개" }, - { value: 2, label: "2개" }, - { value: 3, label: "3개" }, - { value: 4, label: "4개" }, - { value: 5, label: "5개" }, - { value: 6, label: "6개" }, -] as const; - -interface EntityJoinColumn { - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; - suggestedLabel: string; -} - -interface JoinTable { - tableName: string; - currentDisplayColumn: string; - joinConfig?: { sourceColumn: string }; - availableColumns: Array<{ - columnName: string; - columnLabel: string; - dataType: string; - description?: string; - }>; -} - -interface V2CardDisplayConfigPanelProps { - config: Record; - onChange: (config: Record) => void; - screenTableName?: string; - tableColumns?: any[]; -} - -export const V2CardDisplayConfigPanel: React.FC = ({ - config, - onChange, - screenTableName, - tableColumns = [], -}) => { - const [advancedOpen, setAdvancedOpen] = useState(false); - const [tableComboboxOpen, setTableComboboxOpen] = useState(false); - const [allTables, setAllTables] = useState>([]); - const [loadingTables, setLoadingTables] = useState(false); - const [availableColumns, setAvailableColumns] = useState([]); - const [loadingColumns, setLoadingColumns] = useState(false); - const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: EntityJoinColumn[]; - joinTables: JoinTable[]; - }>({ availableColumns: [], joinTables: [] }); - const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); - - const targetTableName = useMemo(() => { - if (config.useCustomTable && config.customTableName) { - return config.customTableName; - } - return config.tableName || screenTableName; - }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); - - const updateConfig = (field: string, value: any) => { - const newConfig = { ...config, [field]: value }; - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) - ); - } - }; - - const updateNestedConfig = (path: string, value: any) => { - const keys = path.split("."); - const newConfig = { ...config }; - let current = newConfig; - for (let i = 0; i < keys.length - 1; i++) { - if (!current[keys[i]]) current[keys[i]] = {}; - current[keys[i]] = { ...current[keys[i]] }; - current = current[keys[i]]; - } - current[keys[keys.length - 1]] = value; - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) - ); - } - }; - - // 테이블 목록 로드 - useEffect(() => { - const loadAllTables = async () => { - setLoadingTables(true); - try { - const response = await tableManagementApi.getTableList(); - if (response.success && response.data) { - setAllTables( - response.data.map((t: any) => ({ - tableName: t.tableName || t.table_name, - displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, - })) - ); - } - } catch { - /* 무시 */ - } finally { - setLoadingTables(false); - } - }; - loadAllTables(); - }, []); - - // 컬럼 로드 - useEffect(() => { - const loadColumns = async () => { - if (!targetTableName) { - setAvailableColumns([]); - return; - } - if (!config.useCustomTable && tableColumns && tableColumns.length > 0) { - setAvailableColumns(tableColumns); - return; - } - setLoadingColumns(true); - try { - const result = await tableManagementApi.getColumnList(targetTableName); - if (result.success && result.data?.columns) { - setAvailableColumns( - result.data.columns.map((col: any) => ({ - columnName: col.columnName, - columnLabel: col.displayName || col.columnLabel || col.columnName, - dataType: col.dataType, - })) - ); - } - } catch { - setAvailableColumns([]); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [targetTableName, config.useCustomTable, tableColumns]); - - // 엔티티 조인 컬럼 로드 - useEffect(() => { - const fetchEntityJoinColumns = async () => { - if (!targetTableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } - setLoadingEntityJoins(true); - try { - const result = await entityJoinApi.getEntityJoinColumns(targetTableName); - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], - }); - } catch { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); - } - }; - fetchEntityJoinColumns(); - }, [targetTableName]); - - const handleTableSelect = (selectedTable: string, isScreenTable: boolean) => { - const newConfig = isScreenTable - ? { ...config, useCustomTable: false, customTableName: undefined, tableName: selectedTable, columnMapping: { displayColumns: [] } } - : { ...config, useCustomTable: true, customTableName: selectedTable, tableName: selectedTable, columnMapping: { displayColumns: [] } }; - onChange(newConfig); - setTableComboboxOpen(false); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) - ); - } - }; - - const getSelectedTableDisplay = () => { - if (!targetTableName) return "테이블을 선택하세요"; - const found = allTables.find((t) => t.tableName === targetTableName); - return found?.displayName || targetTableName; - }; - - const handleColumnSelect = (path: string, columnName: string) => { - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === columnName - ); - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName); - if (!exists) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - const newJoinColumnConfig = { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }; - const newConfig = { - ...config, - columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName }, - joinColumns: [...joinColumnsConfig, newJoinColumnConfig], - }; - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) - ); - } - return; - } - } - updateNestedConfig(path, columnName); - }; - - // 표시 컬럼 관리 - const addDisplayColumn = () => { - const current = config.columnMapping?.displayColumns || []; - updateNestedConfig("columnMapping.displayColumns", [...current, ""]); - }; - - const removeDisplayColumn = (index: number) => { - const current = [...(config.columnMapping?.displayColumns || [])]; - current.splice(index, 1); - updateNestedConfig("columnMapping.displayColumns", current); - }; - - const updateDisplayColumn = (index: number, value: string) => { - const current = [...(config.columnMapping?.displayColumns || [])]; - current[index] = value; - - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === value - ); - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value); - if (!exists) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - const newConfig = { - ...config, - columnMapping: { ...config.columnMapping, displayColumns: current }, - joinColumns: [ - ...joinColumnsConfig, - { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }, - ], - }; - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { detail: { config: newConfig } }) - ); - } - return; - } - } - updateNestedConfig("columnMapping.displayColumns", current); - }; - - // 테이블별 조인 컬럼 그룹화 - const joinColumnsByTable: Record = {}; - entityJoinColumns.availableColumns.forEach((col) => { - if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = []; - joinColumnsByTable[col.tableName].push(col); - }); - - const currentTableColumns = config.useCustomTable - ? availableColumns - : tableColumns.length > 0 - ? tableColumns - : availableColumns; - - // 컬럼 선택 Select 렌더링 - const renderColumnSelect = ( - value: string, - onChangeHandler: (value: string) => void, - placeholder: string = "컬럼 선택" - ) => ( - - ); - - return ( -
- {/* ─── 1단계: 테이블 선택 ─── */} -
-

데이터 소스

- - - - - - - - - - 테이블을 찾을 수 없어요 - - {screenTableName && ( - - handleTableSelect(screenTableName, true)} - className="text-xs" - > - - - {allTables.find((t) => t.tableName === screenTableName)?.displayName || - screenTableName} - - - )} - - {allTables - .filter((t) => t.tableName !== screenTableName) - .map((table) => ( - handleTableSelect(table.tableName, false)} - className="text-xs" - > - - - {table.displayName} - - ))} - - - - - - {config.useCustomTable && ( -

- 화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시해요 -

- )} -
- - {/* ─── 2단계: 컬럼 매핑 ─── */} - {(currentTableColumns.length > 0 || loadingColumns) && ( -
-

컬럼 매핑

- - {(loadingEntityJoins || loadingColumns) && ( -
- - {loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."} -
- )} - -
-
- 타이틀 -
- {renderColumnSelect( - config.columnMapping?.titleColumn || "", - (value) => handleColumnSelect("columnMapping.titleColumn", value) - )} -
-
- -
- 서브타이틀 -
- {renderColumnSelect( - config.columnMapping?.subtitleColumn || "", - (value) => handleColumnSelect("columnMapping.subtitleColumn", value) - )} -
-
- -
- 설명 -
- {renderColumnSelect( - config.columnMapping?.descriptionColumn || "", - (value) => handleColumnSelect("columnMapping.descriptionColumn", value) - )} -
-
- -
- 이미지 -
- {renderColumnSelect( - config.columnMapping?.imageColumn || "", - (value) => handleColumnSelect("columnMapping.imageColumn", value) - )} -
-
-
- - {/* 표시 컬럼 */} -
-
- 추가 표시 컬럼 - -
- - {(config.columnMapping?.displayColumns || []).length > 0 ? ( -
- {(config.columnMapping?.displayColumns || []).map( - (column: string, index: number) => ( -
-
- {renderColumnSelect(column, (value) => - updateDisplayColumn(index, value) - )} -
- -
- ) - )} -
- ) : ( -
- 추가 버튼으로 표시할 컬럼을 추가해요 -
- )} -
-
- )} - - {/* ─── 3단계: 카드 레이아웃 ─── */} -
-

카드 레이아웃

-
-
- 한 행당 카드 수 - -
- -
- 카드 간격 (px) - updateConfig("cardSpacing", parseInt(e.target.value))} - className="h-7 w-[100px] text-xs" - /> -
-
-
- - {/* ─── 4단계: 표시 요소 토글 ─── */} -
-

표시 요소

-
-
-
-

타이틀

-

카드 상단 제목

-
- updateNestedConfig("cardStyle.showTitle", checked)} - /> -
- -
-
-

서브타이틀

-

제목 아래 보조 텍스트

-
- - updateNestedConfig("cardStyle.showSubtitle", checked) - } - /> -
- -
-
-

설명

-

카드 본문 텍스트

-
- - updateNestedConfig("cardStyle.showDescription", checked) - } - /> -
- -
-
-

이미지

-

카드 이미지 영역

-
- - updateNestedConfig("cardStyle.showImage", checked) - } - /> -
- -
-
-

액션 버튼

-

- 상세보기, 편집, 삭제 버튼 -

-
- - updateNestedConfig("cardStyle.showActions", checked) - } - /> -
- - {(config.cardStyle?.showActions ?? true) && ( -
-
- 상세보기 - - updateNestedConfig("cardStyle.showViewButton", checked) - } - /> -
-
- 편집 - - updateNestedConfig("cardStyle.showEditButton", checked) - } - /> -
-
- 삭제 - - updateNestedConfig("cardStyle.showDeleteButton", checked) - } - /> -
-
- )} -
-
- - {/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */} - - - - - -
-
- 설명 최대 길이 - - updateNestedConfig( - "cardStyle.maxDescriptionLength", - parseInt(e.target.value) - ) - } - className="h-7 w-[100px] text-xs" - /> -
- -
-
-

비활성화

-

- 카드 상호작용을 비활성화해요 -

-
- updateConfig("disabled", checked)} - /> -
- -
-
-

읽기 전용

-

- 데이터 수정을 막아요 -

-
- updateConfig("readonly", checked)} - /> -
-
-
-
-
- ); -}; - -V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel"; - -export default V2CardDisplayConfigPanel; diff --git a/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx b/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx deleted file mode 100644 index efbc2d36..00000000 --- a/frontend/components/v2/config-panels/V2PivotGridConfigPanel.tsx +++ /dev/null @@ -1,804 +0,0 @@ -"use client"; - -/** - * V2 피벗 그리드 설정 패널 - * 토스식 단계별 UX: 테이블 선택(Combobox) -> 필드 배치(AreaDropZone) -> 고급 설정(Collapsible) - */ - -import React, { useState, useEffect, useCallback } from "react"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { - Rows, Columns, Calculator, X, Plus, GripVertical, - Check, ChevronsUpDown, ChevronDown, ChevronUp, - Settings, Database, Info, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { tableTypeApi } from "@/lib/api/screen"; -import type { - PivotGridComponentConfig, - PivotFieldConfig, - PivotAreaType, - AggregationType, - FieldDataType, - ConditionalFormatRule, -} from "@/lib/registry/components/v2-pivot-grid/types"; - -interface TableInfo { - tableName: string; - displayName: string; -} - -interface ColumnInfo { - column_name: string; - data_type: string; - column_comment?: string; -} - -interface V2PivotGridConfigPanelProps { - config: PivotGridComponentConfig; - onChange: (config: PivotGridComponentConfig) => void; -} - -function mapDbTypeToFieldType(dbType: string): FieldDataType { - const type = dbType.toLowerCase(); - 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")) return "date"; - if (type.includes("bool")) return "boolean"; - return "string"; -} - -/* ─── 영역 드롭존 서브 컴포넌트 ─── */ - -interface AreaDropZoneProps { - area: PivotAreaType; - label: string; - description: string; - icon: React.ReactNode; - fields: PivotFieldConfig[]; - columns: ColumnInfo[]; - onAddField: (column: ColumnInfo) => void; - onRemoveField: (index: number) => void; - onUpdateField: (index: number, updates: Partial) => void; - color: string; -} - -const AreaDropZone: React.FC = ({ - area, - label, - description, - icon, - fields, - columns, - onAddField, - onRemoveField, - onUpdateField, - color, -}) => { - const [isExpanded, setIsExpanded] = useState(true); - - const availableColumns = columns.filter( - (col) => !fields.some((f) => f.field === col.column_name) - ); - - return ( -
-
setIsExpanded(!isExpanded)} - > -
- {icon} - {label} - {fields.length} -
- {isExpanded ? : } -
-

{description}

- - {isExpanded && ( -
- {fields.length > 0 ? ( -
- {fields.map((field, idx) => ( -
- - - {field.caption || field.field} - - {area === "data" && ( - - )} - -
- ))} -
- ) : ( -
- 아래에서 컬럼을 선택하세요 -
- )} - - {availableColumns.length > 0 && ( - - )} -
- )} -
- ); -}; - -const STYLE_DEFAULTS: { theme: "default"; headerStyle: "default"; cellPadding: "normal"; borderStyle: "light" } = { - theme: "default", - headerStyle: "default", - cellPadding: "normal", - borderStyle: "light", -}; - -/* ─── 메인 컴포넌트 ─── */ - -export const V2PivotGridConfigPanel: React.FC = ({ - config, - onChange, -}) => { - const [tables, setTables] = useState([]); - const [columns, setColumns] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); - const [loadingColumns, setLoadingColumns] = useState(false); - const [tableOpen, setTableOpen] = useState(false); - const [advancedOpen, setAdvancedOpen] = useState(false); - - useEffect(() => { - const loadTables = async () => { - setLoadingTables(true); - try { - const tableList = await tableTypeApi.getTables(); - setTables( - tableList.map((t: any) => ({ - tableName: t.tableName, - displayName: t.tableLabel || t.displayName || t.tableName, - })) - ); - } catch { - /* ignore */ - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - useEffect(() => { - if (!config.dataSource?.tableName) { - setColumns([]); - return; - } - const loadColumns = async () => { - setLoadingColumns(true); - try { - const columnList = await tableTypeApi.getColumns(config.dataSource!.tableName!); - setColumns( - 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, - })) - ); - } catch { - /* ignore */ - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [config.dataSource?.tableName]); - - const updateConfig = useCallback( - (updates: Partial) => { - const newConfig = { ...config, ...updates }; - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: newConfig }, - }) - ); - } - }, - [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) => - f.area === area && f.areaIndex === index ? { ...f, ...updates } : f - ); - updateConfig({ fields: newFields }); - }; - - const getFieldsByArea = (area: PivotAreaType) => - (config.fields || []).filter((f) => f.area === area).sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const handleTableChange = (tableName: string) => { - updateConfig({ - dataSource: { ...config.dataSource, type: "table", tableName }, - fields: [], - }); - setTableOpen(false); - }; - - return ( -
- {/* ─── 안내 ─── */} -
-
- -
-

피벗 테이블 설정 방법

-
    -
  1. 데이터를 가져올 테이블을 선택
  2. -
  3. 행 그룹에 그룹화 컬럼 추가 (예: 지역, 부서)
  4. -
  5. 열 그룹에 가로 펼칠 컬럼 추가 (예: 월, 분기)
  6. -
  7. 에 집계할 숫자 컬럼 추가 (예: 매출, 수량)
  8. -
-
-
-
- - {/* ─── 1단계: 테이블 선택 ─── */} -
-
- -

테이블 선택

-
-

- 피벗 분석에 사용할 데이터 테이블을 골라요 -

-
- - - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {tables.map((table) => ( - handleTableChange(table.tableName)} - className="text-xs" - > - -
- {table.displayName} - {table.displayName !== table.tableName && ( - {table.tableName} - )} -
-
- ))} -
-
-
-
-
- - {/* ─── 2단계: 필드 배치 ─── */} - {config.dataSource?.tableName && ( -
-
- -

필드 배치

- {loadingColumns && ( - (컬럼 로딩 중...) - )} -
-

- 각 영역에 컬럼을 추가하여 피벗 구조를 만들어요 -

- -
- } - 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-primary/20 bg-primary/5" - /> - } - 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" - /> -
-
- )} - - {/* ─── 3단계: 고급 설정 (Collapsible) ─── */} - - - - - -
- {/* 총계 설정 */} -
-

총계 설정

-
-
- 행 총계 - - updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) - } - /> -
-
- 열 총계 - - updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) - } - /> -
-
- 행 총계 위치 - -
-
- 열 총계 위치 - -
-
- 행 소계 - - updateConfig({ totals: { ...config.totals, showRowTotals: v } }) - } - /> -
-
- 열 소계 - - updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) - } - /> -
-
-
- - {/* 스타일 설정 */} -
-

스타일 설정

-
-
-

줄무늬 배경

-

행마다 번갈아 배경색을 적용해요

-
- - updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, alternateRowColors: v } }) - } - /> -
-
-
-

셀 병합

-

같은 값을 가진 인접 셀을 병합해요

-
- - updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, mergeCells: v } }) - } - /> -
-
- - {/* 크기 설정 */} -
-

크기 설정

-
-
- 높이 - updateConfig({ height: e.target.value })} - placeholder="400px" - className="h-7 text-xs" - /> -
-
- 최대 높이 - updateConfig({ maxHeight: e.target.value })} - placeholder="600px" - className="h-7 text-xs" - /> -
-
-
- - {/* 기능 설정 */} -
-

기능 설정

-
-
-

CSV 내보내기

-

데이터를 CSV 파일로 내보낼 수 있어요

-
- - updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) - } - /> -
-
-
-

전체 확장/축소

-

모든 그룹을 한번에 열거나 닫을 수 있어요

-
- updateConfig({ allowExpandAll: v })} - /> -
-
-
-

필터링

-

필드별 필터를 사용할 수 있어요

-
- updateConfig({ allowFiltering: v })} - /> -
-
-
-

요약값 기준 정렬

-

집계 결과를 클릭해서 정렬할 수 있어요

-
- updateConfig({ allowSortingBySummary: v })} - /> -
-
-
-

텍스트 줄바꿈

-

긴 텍스트를 셀 안에서 줄바꿈해요

-
- updateConfig({ wordWrapEnabled: v })} - /> -
-
- - {/* 조건부 서식 */} -
-

조건부 서식

-
- {(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: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } }); - }} - className="h-6 w-6 cursor-pointer rounded" - title="최소값 색상" - /> - - { - const newFormats = [...(config.style?.conditionalFormats || [])]; - newFormats[index] = { - ...rule, - colorScale: { - ...rule.colorScale, - minColor: rule.colorScale?.minColor || "#ff0000", - maxColor: e.target.value, - }, - }; - updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } }); - }} - className="h-6 w-6 cursor-pointer rounded" - title="최대값 색상" - /> -
- )} - - {rule.type === "dataBar" && ( - { - const newFormats = [...(config.style?.conditionalFormats || [])]; - newFormats[index] = { ...rule, dataBar: { color: e.target.value } }; - updateConfig({ style: { ...STYLE_DEFAULTS, ...config.style, conditionalFormats: newFormats } }); - }} - className="h-6 w-6 cursor-pointer rounded" - title="바 색상" - /> - )} - - {rule.type === "iconSet" && ( - - )} - - -
- ))} - - -
-
-
-
-
-
- ); -}; - -export default V2PivotGridConfigPanel; diff --git a/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx b/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx deleted file mode 100644 index 8ceacd5f..00000000 --- a/frontend/components/v2/config-panels/V2TableGroupedConfigPanel.tsx +++ /dev/null @@ -1,771 +0,0 @@ -"use client"; - -/** - * V2TableGrouped 설정 패널 - * 토스식 단계별 UX: 데이터 소스 -> 그룹화 설정 -> 컬럼 선택 -> 표시 설정(접힘) -> 연동 설정(접힘) - * 기존 TableGroupedConfigPanel의 모든 기능을 자체 UI로 완전 구현 - */ - -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Separator } from "@/components/ui/separator"; -import { - Table2, - Database, - Layers, - Columns3, - Check, - ChevronsUpDown, - Settings, - ChevronDown, - Loader2, - Link2, - Plus, - Trash2, - FoldVertical, - ArrowUpDown, - CheckSquare, - LayoutGrid, - Type, - Hash, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { tableTypeApi } from "@/lib/api/screen"; -import type { TableGroupedConfig, LinkedFilterConfig } from "@/lib/registry/components/v2-table-grouped/types"; -import type { ColumnConfig } from "@/lib/registry/components/v2-table-list/types"; -import { - groupHeaderStyleOptions, - checkboxModeOptions, - sortDirectionOptions, -} from "@/lib/registry/components/v2-table-grouped/config"; - -// ─── 섹션 헤더 컴포넌트 ─── -function SectionHeader({ icon: Icon, title, description }: { - icon: React.ComponentType<{ className?: string }>; - title: string; - description?: string; -}) { - return ( -
-
- -

{title}

-
- {description &&

{description}

} -
- ); -} - -// ─── 수평 Switch Row (토스 패턴) ─── -function SwitchRow({ label, description, checked, onCheckedChange }: { - label: string; - description?: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; -}) { - return ( -
-
-

{label}

- {description &&

{description}

} -
- -
- ); -} - -// ─── 수평 라벨 + 컨트롤 Row ─── -function LabeledRow({ label, description, children }: { - label: string; - description?: string; - children: React.ReactNode; -}) { - return ( -
-
-

{label}

- {description &&

{description}

} -
- {children} -
- ); -} - -// ─── 그룹 헤더 스타일 카드 ─── -const HEADER_STYLE_CARDS = [ - { value: "default", icon: LayoutGrid, title: "기본", description: "표준 그룹 헤더" }, - { value: "compact", icon: FoldVertical, title: "컴팩트", description: "간결한 헤더" }, - { value: "card", icon: Layers, title: "카드", description: "카드 스타일 헤더" }, -] as const; - -interface V2TableGroupedConfigPanelProps { - config: TableGroupedConfig; - onChange: (newConfig: Partial) => void; -} - -export const V2TableGroupedConfigPanel: React.FC = ({ - config, - onChange, -}) => { - // componentConfigChanged 이벤트 발행 래퍼 - const handleChange = useCallback((newConfig: Partial) => { - onChange(newConfig); - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("componentConfigChanged", { - detail: { config: { ...config, ...newConfig } }, - }) - ); - } - }, [onChange, config]); - - const updateConfig = useCallback((updates: Partial) => { - handleChange({ ...config, ...updates }); - }, [handleChange, config]); - - const updateGroupConfig = useCallback((updates: Partial) => { - handleChange({ - ...config, - groupConfig: { ...config.groupConfig, ...updates }, - }); - }, [handleChange, config]); - - // ─── 상태 ─── - const [tables, setTables] = useState>([]); - const [tableColumns, setTableColumns] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); - const [loadingColumns, setLoadingColumns] = useState(false); - const [tableComboboxOpen, setTableComboboxOpen] = useState(false); - - // Collapsible 상태 - const [displayOpen, setDisplayOpen] = useState(false); - const [linkedOpen, setLinkedOpen] = useState(false); - - // ─── 실제 사용할 테이블 이름 ─── - const targetTableName = useMemo(() => { - if (config.useCustomTable && config.customTableName) { - return config.customTableName; - } - return config.selectedTable; - }, [config.useCustomTable, config.customTableName, config.selectedTable]); - - // ─── 테이블 목록 로드 ─── - useEffect(() => { - const loadTables = async () => { - setLoadingTables(true); - try { - const tableList = await tableTypeApi.getTables(); - if (tableList && Array.isArray(tableList)) { - setTables( - tableList.map((t: any) => ({ - tableName: t.tableName || t.table_name, - displayName: t.displayName || t.display_name || t.tableName || t.table_name, - })) - ); - } - } catch (err) { - console.error("테이블 목록 로드 실패:", err); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - // ─── 선택된 테이블의 컬럼 로드 ─── - useEffect(() => { - if (!targetTableName) { - setTableColumns([]); - return; - } - - const loadColumns = async () => { - setLoadingColumns(true); - try { - const columns = await tableTypeApi.getColumns(targetTableName); - if (columns && Array.isArray(columns)) { - const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({ - columnName: col.column_name || col.columnName, - displayName: col.display_name || col.displayName || col.column_name || col.columnName, - visible: true, - sortable: true, - searchable: false, - align: "left" as const, - order: idx, - })); - setTableColumns(cols); - - if (!config.columns || config.columns.length === 0) { - updateConfig({ columns: cols }); - } - } - } catch (err) { - console.error("컬럼 로드 실패:", err); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetTableName]); - - // ─── 테이블 변경 핸들러 ─── - const handleTableChange = useCallback((newTableName: string) => { - if (newTableName === config.selectedTable) return; - updateConfig({ selectedTable: newTableName, columns: [] }); - setTableComboboxOpen(false); - }, [config.selectedTable, updateConfig]); - - // ─── 컬럼 가시성 토글 ─── - const toggleColumnVisibility = useCallback((columnName: string) => { - const updatedColumns = (config.columns || []).map((col) => - col.columnName === columnName ? { ...col, visible: !col.visible } : col - ); - updateConfig({ columns: updatedColumns }); - }, [config.columns, updateConfig]); - - // ─── 합계 컬럼 토글 ─── - const toggleSumColumn = useCallback((columnName: string) => { - const currentSumCols = config.groupConfig?.summary?.sumColumns || []; - const newSumCols = currentSumCols.includes(columnName) - ? currentSumCols.filter((c) => c !== columnName) - : [...currentSumCols, columnName]; - - updateGroupConfig({ - summary: { - ...config.groupConfig?.summary, - sumColumns: newSumCols, - }, - }); - }, [config.groupConfig?.summary, updateGroupConfig]); - - // ─── 연결 필터 관리 ─── - const addLinkedFilter = useCallback(() => { - const newFilter: LinkedFilterConfig = { - sourceComponentId: "", - sourceField: "value", - targetColumn: "", - enabled: true, - }; - updateConfig({ - linkedFilters: [...(config.linkedFilters || []), newFilter], - }); - }, [config.linkedFilters, updateConfig]); - - const removeLinkedFilter = useCallback((index: number) => { - const filters = [...(config.linkedFilters || [])]; - filters.splice(index, 1); - updateConfig({ linkedFilters: filters }); - }, [config.linkedFilters, updateConfig]); - - const updateLinkedFilter = useCallback((index: number, updates: Partial) => { - const filters = [...(config.linkedFilters || [])]; - filters[index] = { ...filters[index], ...updates }; - updateConfig({ linkedFilters: filters }); - }, [config.linkedFilters, updateConfig]); - - // ─── 렌더링 ─── - return ( -
- {/* ═══════════════════════════════════════ */} - {/* 1단계: 데이터 소스 (테이블 선택) */} - {/* ═══════════════════════════════════════ */} -
- - - - updateConfig({ useCustomTable: checked })} - /> - - {config.useCustomTable ? ( - updateConfig({ customTableName: e.target.value })} - placeholder="테이블명을 직접 입력하세요" - className="h-8 text-xs" - /> - ) : ( - - - - - - { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - > - - - 테이블을 찾을 수 없습니다. - - {tables.map((table) => ( - handleTableChange(table.tableName)} - className="text-xs" - > - -
- {table.displayName} - {table.displayName !== table.tableName && ( - {table.tableName} - )} -
-
- ))} -
-
-
-
-
- )} -
- - {/* ═══════════════════════════════════════ */} - {/* 2단계: 그룹화 설정 */} - {/* ═══════════════════════════════════════ */} - {targetTableName && ( -
- - - - {/* 그룹화 기준 컬럼 */} - - - - - {/* 그룹 라벨 형식 */} -
-
- - 그룹 라벨 형식 -
- updateGroupConfig({ groupLabelFormat: e.target.value })} - placeholder="{value} ({컬럼명})" - className="h-7 text-xs" - /> -

- {"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값 -

-
- - updateGroupConfig({ defaultExpanded: checked })} - /> - - {/* 그룹 정렬 */} - - - - - - updateGroupConfig({ - summary: { ...config.groupConfig?.summary, showCount: checked }, - }) - } - /> - - {/* 합계 컬럼 */} - {tableColumns.length > 0 && ( -
-
- - 합계 표시 컬럼 -
-

그룹별 합계를 계산할 컬럼을 선택하세요

-
- {tableColumns.map((col) => { - const isChecked = config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false; - return ( -
toggleSumColumn(col.columnName)} - > - toggleSumColumn(col.columnName)} - className="pointer-events-none h-3.5 w-3.5" - /> - {col.displayName || col.columnName} -
- ); - })} -
-
- )} -
- )} - - {/* 테이블 미선택 안내 */} - {!targetTableName && ( -
- -

테이블이 선택되지 않았습니다

-

위 데이터 소스에서 테이블을 선택하세요

-
- )} - - {/* ═══════════════════════════════════════ */} - {/* 3단계: 컬럼 선택 */} - {/* ═══════════════════════════════════════ */} - {targetTableName && (config.columns || tableColumns).length > 0 && ( -
- c.visible !== false).length}개 표시)`} - description="표시할 컬럼을 선택하세요" - /> - - -
- {(config.columns || tableColumns).map((col) => { - const isVisible = col.visible !== false; - return ( -
toggleColumnVisibility(col.columnName)} - > - toggleColumnVisibility(col.columnName)} - className="pointer-events-none h-3.5 w-3.5" - /> - - {col.displayName || col.columnName} -
- ); - })} -
-
- )} - - {/* ═══════════════════════════════════════ */} - {/* 4단계: 그룹 헤더 스타일 (카드 선택) */} - {/* ═══════════════════════════════════════ */} - {targetTableName && ( -
- - - -
- {HEADER_STYLE_CARDS.map((card) => { - const Icon = card.icon; - const isSelected = (config.groupHeaderStyle || "default") === card.value; - return ( - - ); - })} -
-
- )} - - {/* ═══════════════════════════════════════ */} - {/* 5단계: 표시 설정 (기본 접힘) */} - {/* ═══════════════════════════════════════ */} - - - - - -
- - {/* 체크박스 */} -
-
- - 체크박스 -
- - updateConfig({ showCheckbox: checked })} - /> - - {config.showCheckbox && ( -
- - - -
- )} -
- - - - {/* UI 옵션 */} - updateConfig({ showExpandAllButton: checked })} - /> - - updateConfig({ rowClickable: checked })} - /> - - - - {/* 높이 및 메시지 */} - - updateConfig({ maxHeight: parseInt(e.target.value) || 600 })} - min={200} - max={2000} - className="h-7 w-[100px] text-xs" - /> - - -
- 빈 데이터 메시지 - updateConfig({ emptyMessage: e.target.value })} - placeholder="데이터가 없습니다." - className="h-7 text-xs" - /> -
-
-
-
- - {/* ═══════════════════════════════════════ */} - {/* 6단계: 연동 설정 (기본 접힘) */} - {/* ═══════════════════════════════════════ */} - - - - - -
-
-

- 다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다 -

- -
- - {(config.linkedFilters || []).length === 0 ? ( -
- -

연결된 필터가 없습니다

-
- ) : ( -
- {(config.linkedFilters || []).map((filter, idx) => ( -
-
- 필터 #{idx + 1} -
- updateLinkedFilter(idx, { enabled: checked })} - /> - -
-
- -
- 소스 컴포넌트 ID - updateLinkedFilter(idx, { sourceComponentId: e.target.value })} - placeholder="예: search-filter-1" - className="h-6 text-xs" - /> -
- -
- 소스 필드 - updateLinkedFilter(idx, { sourceField: e.target.value })} - placeholder="value" - className="h-6 text-xs" - /> -
- -
- 대상 컬럼 - -
-
- ))} -
- )} -
-
-
-
- ); -}; - -V2TableGroupedConfigPanel.displayName = "V2TableGroupedConfigPanel"; - -export default V2TableGroupedConfigPanel; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 51b09b8c..8d04528b 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -380,10 +380,9 @@ export const DynamicComponentRenderer: React.FC = "slider-basic": "input", "radio-basic": "input", "toggle-switch": "input", // stats "v2-aggregation-widget": "stats", "aggregation-widget": "stats", - "v2-status-count": "stats", "v2-card-display": "stats", "card-display": "stats", + "v2-status-count": "stats", // table "v2-table-list": "table", "table-list": "table", - "v2-table-grouped": "table", "v2-pivot-grid": "table", // container "v2-tabs-widget": "container", "v2-section-card": "container", "v2-section-paper": "container", "v2-repeat-container": "container", diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx deleted file mode 100644 index d92538a6..00000000 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ /dev/null @@ -1,1315 +0,0 @@ -"use client"; - -import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; -import { ComponentRendererProps } from "@/types/component"; -import { CardDisplayConfig } from "./types"; -import { tableTypeApi } from "@/lib/api/screen"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { getFullImageUrl, apiClient } from "@/lib/api/client"; -import { filterDOMProps } from "@/lib/utils/domPropsFilter"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; -import { useModalDataStore } from "@/stores/modalDataStore"; -import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; - -export interface CardDisplayComponentProps extends ComponentRendererProps { - config?: CardDisplayConfig; - tableData?: any[]; - tableColumns?: any[]; -} - -/** - * CardDisplay 컴포넌트 - * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 - */ -export const CardDisplayComponent: React.FC = ({ - component, - isDesignMode = false, - isSelected = false, - isInteractive = false, - onClick, - onDragStart, - onDragEnd, - config, - className, - style, - formData, - onFormDataChange, - screenId, - tableName, - tableData = [], - tableColumns = [], - ...props -}) => { - // 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음) - const screenContext = useScreenContextOptional(); - const splitPanelContext = useSplitPanelContext(); - const splitPanelPosition = screenContext?.split_panel_position; - - // TableOptions Context (검색 필터 위젯 연동용) - let tableOptionsContext: ReturnType | null = null; - try { - tableOptionsContext = useTableOptions(); - } catch (e) { - // Context가 없으면 (디자이너 모드) 무시 - } - - // 테이블 데이터 상태 관리 - const [loadedTableData, setLoadedTableData] = useState([]); - const [loadedTableColumns, setLoadedTableColumns] = useState([]); - const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 - const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 - const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 - - // 필터 상태 (검색 필터 위젯에서 전달받은 필터) - const [filters, setFiltersInternal] = useState([]); - - // 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가) - const [refreshKey, setRefreshKey] = useState(0); - - // refreshCardDisplay 이벤트 리스너 - useEffect(() => { - const handleRefreshCardDisplay = () => { - console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침"); - setRefreshKey((prev) => prev + 1); - }; - - window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay); - - return () => { - window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay); - }; - }, []); - - // 필터 상태 변경 래퍼 - const setFilters = useCallback((newFilters: TableFilter[]) => { - setFiltersInternal(newFilters); - }, []); - - // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) - const [columnMeta, setColumnMeta] = useState< - Record - >({}); - const [categoryMappings, setCategoryMappings] = useState< - Record> - >({}); - - // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) - const [selectedRows, setSelectedRows] = useState>(new Set()); - - // 상세보기 모달 상태 - const [viewModalOpen, setViewModalOpen] = useState(false); - const [selectedData, setSelectedData] = useState(null); - - // 편집 모달 상태 - const [editModalOpen, setEditModalOpen] = useState(false); - const [editData, setEditData] = useState(null); - - // 카드 액션 핸들러 - const handleCardView = (data: any) => { - // console.log("👀 상세보기 클릭:", data); - setSelectedData(data); - setViewModalOpen(true); - }; - - const handleCardEdit = (data: any) => { - // console.log("✏️ 편집 클릭:", data); - setEditData({ ...data }); // 복사본 생성 - setEditModalOpen(true); - }; - - // 삭제 핸들러 - const handleCardDelete = async (data: any, index: number) => { - // 사용자 확인 - if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) { - return; - } - - try { - const tableNameToUse = tableName || component.componentConfig?.tableName; - if (!tableNameToUse) { - alert("테이블 정보가 없습니다."); - return; - } - - // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) - const deleteData = [data]; - - - // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) - // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 - // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 - const response = await apiClient.request({ - method: 'DELETE', - url: `/table-management/tables/${tableNameToUse}/delete`, - data: deleteData, - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.data.success) { - alert("삭제되었습니다."); - - // 로컬 상태에서 삭제된 항목 제거 - setLoadedTableData(prev => prev.filter((item, idx) => idx !== index)); - - // 선택된 항목이면 선택 해제 - const cardKey = getCardKey(data, index); - if (selectedRows.has(cardKey)) { - const newSelectedRows = new Set(selectedRows); - newSelectedRows.delete(cardKey); - setSelectedRows(newSelectedRows); - } - } else { - alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); - } - } catch (error: any) { - const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; - alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); - } - }; - - // 편집 폼 데이터 변경 핸들러 - const handleEditFormChange = (key: string, value: string) => { - setEditData((prev: any) => ({ - ...prev, - [key]: value - })); - }; - - // 편집 저장 핸들러 - const handleEditSave = async () => { - // console.log("💾 편집 저장:", editData); - - try { - // TODO: 실제 API 호출로 데이터 업데이트 - // await tableTypeApi.updateTableData(tableName, editData); - - // console.log("✅ 편집 저장 완료"); - alert("✅ 저장되었습니다!"); - - // 모달 닫기 - setEditModalOpen(false); - setEditData(null); - - // 데이터 새로고침 (필요시) - // loadTableData(); - - } catch (error) { - alert("저장에 실패했습니다."); - } - }; - - // 테이블 데이터 로딩 - useEffect(() => { - const loadTableData = async () => { - // 디자인 모드에서는 테이블 데이터를 로드하지 않음 - if (isDesignMode) { - setLoading(false); - setInitialLoadDone(true); - return; - } - - // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) - // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 - const isRightPanelEarly = splitPanelPosition === "right"; - const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData && - Object.keys(splitPanelContext.selectedLeftData).length > 0; - - if (isRightPanelEarly && !hasSelectedLeftDataEarly) { - // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) - // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 - if (!initialLoadDone) { - setLoadedTableData([]); - } - setLoading(false); - setInitialLoadDone(true); - return; - } - - // tableName 확인 (props에서 전달받은 tableName 사용) - const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 - - if (!tableNameToUse) { - setLoading(false); - setInitialLoadDone(true); - return; - } - - // 연결 필터 확인 (분할 패널 내부일 때) - let linkedFilterValues: Record = {}; - let hasLinkedFiltersConfigured = false; - let hasSelectedLeftData = false; - - if (splitPanelContext) { - // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) - const linkedFiltersConfig = splitPanelContext.linkedFilters || []; - hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || - filter.targetColumn === tableNameToUse - ); - - // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = splitPanelContext.selectedLeftData && - Object.keys(splitPanelContext.selectedLeftData).length > 0; - - linkedFilterValues = splitPanelContext.getLinkedFilterValues(); - // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) - // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 - const tableSpecificFilters: Record = {}; - for (const [key, value] of Object.entries(linkedFilterValues)) { - // key가 "테이블명.컬럼명" 형식인 경우 - if (key.includes(".")) { - const [tblName, columnName] = key.split("."); - if (tblName === tableNameToUse) { - // 연결 필터는 코드 값이므로 equals 연산자 사용 - tableSpecificFilters[columnName] = { value, operator: "equals" }; - hasLinkedFiltersConfigured = true; - } - } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) - tableSpecificFilters[key] = { value, operator: "equals" }; - } - } - linkedFilterValues = tableSpecificFilters; - - } - - // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 - // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 - // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 - const isRightPanelFromContext = splitPanelPosition === "right"; - const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId - ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" - : false; - const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; - const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - - - if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { - setLoadedTableData([]); - setLoading(false); - setInitialLoadDone(true); - return; - } - - try { - setLoading(true); - - // API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함) - const apiParams: Record = { - page: 1, - size: 50, // 카드 표시용으로 적당한 개수 - search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, - }; - - // 조인 컬럼 설정 가져오기 (componentConfig에서) - const joinColumnsConfig = component.componentConfig?.joinColumns || []; - const entityJoinColumns = joinColumnsConfig - .filter((col: any) => col.isJoinColumn) - .map((col: any) => ({ - columnName: col.columnName, - sourceColumn: col.sourceColumn, - referenceTable: col.referenceTable, - referenceColumn: col.referenceColumn, - displayColumn: col.referenceColumn, - label: col.label, - joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가 - sourceTable: tableNameToUse, // 기준 테이블 - })); - - // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 - // 조인 컬럼이 있으면 entityJoinApi 사용 - let dataResponse; - if (entityJoinColumns.length > 0) { - console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns); - dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, { - ...apiParams, - additionalJoinColumns: entityJoinColumns, - }); - } else { - dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams); - } - - const [columnsResponse, inputTypesResponse] = await Promise.all([ - tableTypeApi.getColumns(tableNameToUse), - tableTypeApi.getColumnInputTypes(tableNameToUse), - ]); - - setLoadedTableData(dataResponse.data); - setLoadedTableColumns(columnsResponse); - - // 컬럼 메타 정보 설정 (inputType 포함) - const meta: Record = {}; - inputTypesResponse.forEach((item: any) => { - meta[item.column_name] = { - web_type: item.web_type, - inputType: item.input_type, - codeCategory: item.code_category, - }; - }); - setColumnMeta(meta); - - // 카테고리 타입 컬럼 찾기 및 매핑 로드 - const categoryColumns = Object.entries(meta) - .filter(([_, m]) => m.inputType === "category") - .map(([columnName]) => columnName); - - - if (categoryColumns.length > 0) { - const mappings: Record> = {}; - - for (const columnName of categoryColumns) { - try { - const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`); - - - if (response.data.success && response.data.data) { - const mapping: Record = {}; - response.data.data.forEach((item: any) => { - // API 응답 형식: valueCode, valueLabel (camelCase) - const code = item.value_code || item.category_code || item.code || item.value; - const label = item.value_label || item.category_name || item.name || item.label || code; - // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) - const rawColor = item.color ?? item.badge_color; - const color = (rawColor && rawColor !== "none") ? rawColor : undefined; - mapping[code] = { label, color }; - }); - mappings[columnName] = mapping; - } - } catch (error) { - // 카테고리 매핑 로드 실패 시 무시 - } - } - - setCategoryMappings(mappings); - } - } catch (error) { - setLoadedTableData([]); - setLoadedTableColumns([]); - } finally { - setLoading(false); - setInitialLoadDone(true); - } - }; - - loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]); - - // 컴포넌트 설정 (기본값 보장) - const componentConfig = { - cardsPerRow: 3, // 기본값 3 (한 행당 카드 수) - cardSpacing: 16, - cardStyle: { - showTitle: true, - showSubtitle: true, - showDescription: true, - showImage: false, - showActions: true, - maxDescriptionLength: 100, - imagePosition: "top", - imageSize: "medium", - }, - columnMapping: {}, - dataSource: "table", - staticData: [], - ...config, - ...component.config, - ...component.componentConfig, - } as CardDisplayConfig; - - // 컴포넌트 기본 스타일 - const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - position: "relative", - backgroundColor: "transparent", - }; - - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) - // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 - - if (isDesignMode) { - componentStyle.border = "1px dashed hsl(var(--border))"; - componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; - } - - // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) - const isRightPanelForDisplay = splitPanelPosition === "right" || - (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); - const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; - const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && - Object.keys(selectedLeftDataForDisplay).length > 0; - - // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 - useEffect(() => { - if (hasSelectedLeftDataForDisplay) { - setHasEverSelectedLeftData(true); - } - }, [hasSelectedLeftDataForDisplay]); - - // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 - // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 - const shouldHideDataForRightPanel = isRightPanelForDisplay && - !hasEverSelectedLeftData && - !hasSelectedLeftDataForDisplay; - - // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) - const displayData = useMemo(() => { - // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 - if (shouldHideDataForRightPanel) { - return []; - } - - // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) - if (loadedTableData.length > 0) { - return loadedTableData; - } - - // props로 전달받은 테이블 데이터가 있으면 사용 - if (tableData.length > 0) { - return tableData; - } - - if (componentConfig.staticData && componentConfig.staticData.length > 0) { - return componentConfig.staticData; - } - - // 데이터가 없으면 빈 배열 반환 - return []; - }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); - - // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) - const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; - - // 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언) - const getCardKey = useCallback((data: any, index: number): string => { - return String(data.id || data.objid || data.ID || index); - }, []); - - // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) - const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => { - // 단일 선택: 새로운 Set 생성 (기존 선택 초기화) - const newSelectedRows = new Set(); - - if (checked) { - // 선택 시 해당 카드만 선택 - newSelectedRows.add(cardKey); - } - // checked가 false면 빈 Set (선택 해제) - - setSelectedRows(newSelectedRows); - - // 선택된 카드 데이터 계산 - const selectedRowsData = displayData.filter((item, index) => - newSelectedRows.has(getCardKey(item, index)) - ); - - // onFormDataChange 호출 - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData, - }); - } - - // modalDataStore에 선택된 데이터 저장 - const tableNameToUse = componentConfig.dataSource?.tableName || tableName; - if (tableNameToUse && selectedRowsData.length > 0) { - const modalItems = selectedRowsData.map((row, idx) => ({ - id: getCardKey(row, idx), - originalData: row, - additionalData: {}, - })); - useModalDataStore.getState().setData(tableNameToUse, modalItems); - } else if (tableNameToUse && selectedRowsData.length === 0) { - useModalDataStore.getState().clearData(tableNameToUse); - } - - // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - if (checked) { - splitPanelContext.setSelectedLeftData(data); - } else { - splitPanelContext.setSelectedLeftData(null); - } - } - }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); - - const handleCardClick = useCallback((data: any, index: number) => { - const cardKey = getCardKey(data, index); - const isCurrentlySelected = selectedRows.has(cardKey); - - // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택 - handleCardSelection(cardKey, data, !isCurrentlySelected); - - if (componentConfig.onCardClick) { - componentConfig.onCardClick(data); - } - }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]); - - // DataProvidable 인터페이스 구현 (테이블 리스트와 동일) - const dataProvider = useMemo(() => ({ - componentId: component.id, - componentType: "card-display" as const, - - getSelectedData: () => { - const selectedData = displayData.filter((item, index) => - selectedRows.has(getCardKey(item, index)) - ); - return selectedData; - }, - - getAllData: () => { - return displayData; - }, - - clearSelection: () => { - setSelectedRows(new Set()); - }, - }), [component.id, displayData, selectedRows, getCardKey]); - - // ScreenContext에 데이터 제공자로 등록 - useEffect(() => { - if (screenContext && component.id) { - screenContext.registerDataProvider(component.id, dataProvider); - - return () => { - screenContext.unregisterDataProvider(component.id); - }; - } - }, [screenContext, component.id, dataProvider]); - - // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) - const tableId = `card-display-${component.id}`; - const tableNameToUse = tableName || component.componentConfig?.tableName || ''; - const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; - - // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) - const loadedTableDataRef = useRef(loadedTableData); - const categoryMappingsRef = useRef(categoryMappings); - - useEffect(() => { - loadedTableDataRef.current = loadedTableData; - }, [loadedTableData]); - - useEffect(() => { - categoryMappingsRef.current = categoryMappings; - }, [categoryMappings]); - - // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) - // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) - const mountCountRef = useRef(0); - - useEffect(() => { - mountCountRef.current += 1; - const currentMount = mountCountRef.current; - - if (!tableNameToUse || isDesignMode) return; - - // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 - const isRightPanel = splitPanelPosition === "right" || - (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); - const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - const hasSelectedLeftData = splitPanelContext?.selectedLeftData && - Object.keys(splitPanelContext.selectedLeftData).length > 0; - - // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) - if (isRightPanel && !hasSelectedLeftData) { - // 데이터를 지우지 않고 로딩만 false로 설정 - setLoading(false); - return; - } - - // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) - // 필터 변경이 아닌 경우 스킵 - if (currentMount <= 2 && filters.length === 0) { - return; - } - - const loadFilteredData = async () => { - try { - // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) - - // 필터 값을 검색 파라미터로 변환 - const searchParams: Record = {}; - filters.forEach(filter => { - if (filter.value !== undefined && filter.value !== null && filter.value !== '') { - searchParams[filter.columnName] = filter.value; - } - }); - - // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) - const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { - page: 1, - size: 50, - search: searchParams, - }); - - setLoadedTableData(dataResponse.data); - - // 데이터 건수 업데이트 - if (tableOptionsContext) { - tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); - } - } catch (error) { - // 필터 적용 실패 시 무시 - } - }; - - // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) - loadFilteredData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); - - // 컬럼 고유 값 조회 함수 (select 타입 필터용) - const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { - if (!tableNameToUse) return []; - - try { - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValues = new Set(); - loadedTableDataRef.current.forEach(row => { - const value = row[columnName]; - if (value !== null && value !== undefined && value !== '') { - uniqueValues.add(String(value)); - } - }); - - // 카테고리 매핑이 있으면 라벨 적용 - const mapping = categoryMappingsRef.current[columnName]; - return Array.from(uniqueValues).map(value => ({ - value, - label: mapping?.[value]?.label || value, - })); - } catch (error) { - return []; - } - }, [tableNameToUse]); - - // TableOptionsContext에 등록 - // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) - const registerTableRef = useRef(tableOptionsContext?.registerTable); - const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); - - // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) - const setFiltersRef = useRef(setFiltersInternal); - const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); - - useEffect(() => { - registerTableRef.current = tableOptionsContext?.registerTable; - unregisterTableRef.current = tableOptionsContext?.unregisterTable; - }, [tableOptionsContext]); - - useEffect(() => { - setFiltersRef.current = setFiltersInternal; - }, [setFiltersInternal]); - - useEffect(() => { - getColumnUniqueValuesRef.current = getColumnUniqueValues; - }, [getColumnUniqueValues]); - - // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) - const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.column_name)); - - useEffect(() => { - if (!registerTableRef.current || !unregisterTableRef.current) return; - if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; - - // 컬럼 정보를 TableColumn 형식으로 변환 - const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ - column_name: col.column_name, - column_label: col.display_name || col.column_label || col.column_name, - input_type: columnMeta[col.column_name]?.inputType || 'text', - visible: true, - width: 200, - sortable: true, - filterable: true, - })); - - // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 - const onFilterChangeWrapper = (newFilters: TableFilter[]) => { - setFiltersRef.current(newFilters); - }; - - const getColumnUniqueValuesWrapper = async (columnName: string) => { - return getColumnUniqueValuesRef.current(columnName); - }; - - const registration = { - table_id: tableId, - label: tableLabel, - table_name: tableNameToUse, - columns, - data_count: loadedTableData.length, - onFilterChange: onFilterChangeWrapper, - onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 - onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 - getColumnUniqueValues: getColumnUniqueValuesWrapper, - }; - - registerTableRef.current(registration); - - const unregister = unregisterTableRef.current; - const currentTableId = tableId; - - return () => { - unregister(currentTableId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isDesignMode, - tableId, - tableNameToUse, - tableLabel, - columnsKey, // 컬럼 변경 시에만 재등록 - ]); - - // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 - // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) - if (shouldHideDataForRightPanel) { - return ( -
-
-
좌측에서 항목을 선택해주세요
-
선택한 항목의 관련 데이터가 여기에 표시됩니다
-
-
- ); - } - - // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 - // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) - if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { - return ( -
-
테이블 데이터를 로드하는 중...
-
- ); - } - - // 컨테이너 스타일 - 통일된 디자인 시스템 적용 - const containerStyle: React.CSSProperties = { - display: "grid", - gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수) - gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시 - gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격 - padding: "16px", // 패딩 - width: "100%", - height: "100%", - background: "transparent", // 배경색 제거 - overflow: "auto", - borderRadius: "0", // 라운드 제거 - }; - - // 카드 스타일 - 컴팩트한 디자인 - const cardStyle: React.CSSProperties = { - backgroundColor: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - padding: "16px", - boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", - transition: "all 0.2s ease", - overflow: "hidden", - display: "flex", - flexDirection: "column", - position: "relative", - cursor: isDesignMode ? "pointer" : "default", - width: "100%", // 전체 너비 차지 - }; - - // 텍스트 자르기 함수 - const truncateText = (text: string, maxLength: number) => { - if (!text) return ""; - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + "..."; - }; - - // 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환) - const getColumnValueAsString = (data: any, columnName?: string): string => { - if (!columnName) return ""; - const value = data[columnName]; - if (value === null || value === undefined || value === "") return ""; - - // 카테고리 타입인 경우 매핑된 라벨 반환 - const meta = columnMeta[columnName]; - if (meta?.inputType === "category") { - const mapping = categoryMappings[columnName]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - return categoryData?.label || valueStr; - } - - return String(value); - }; - - // 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시) - const getColumnValue = (data: any, columnName?: string): React.ReactNode => { - if (!columnName) return ""; - const value = data[columnName]; - if (value === null || value === undefined || value === "") return ""; - - // 카테고리 타입인 경우 매핑된 라벨과 배지로 표시 - const meta = columnMeta[columnName]; - if (meta?.inputType === "category") { - const mapping = categoryMappings[columnName]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - const displayLabel = categoryData?.label || valueStr; - const displayColor = categoryData?.color; - - // 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음) - if (!displayColor || displayColor === "none") { - return displayLabel; - } - - return ( - - {displayLabel} - - ); - } - - return String(value); - }; - - // 컬럼명을 라벨로 변환하는 헬퍼 함수 - const getColumnLabel = (columnName: string) => { - if (!actualTableColumns || actualTableColumns.length === 0) { - // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 - return formatColumnName(columnName); - } - const column = actualTableColumns.find( - (col) => col.column_name === columnName - ); - const label = column?.display_name || column?.column_label || column?.label; - return label || formatColumnName(columnName); - }; - - // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) - const formatColumnName = (columnName: string) => { - // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 - return columnName - .replace(/_/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()); - }; - - // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 - const getAutoFallbackValue = (data: any, type: "title" | "subtitle" | "description") => { - const keys = Object.keys(data); - switch (type) { - case "title": - // 이름 관련 필드 우선 검색 - return data.name || data.title || data.label || data[keys[0]] || "제목 없음"; - case "subtitle": - // 직책, 부서, 카테고리 관련 필드 검색 - return data.position || data.role || data.department || data.category || data.type || ""; - case "description": - // 설명, 내용 관련 필드 검색 - return data.description || data.content || data.summary || data.memo || ""; - default: - return ""; - } - }; - - // 이벤트 핸들러 - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(); - }; - - // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) - const safeDomProps = filterDOMProps(props); - - return ( - <> - -
-
- {displayData.length === 0 ? ( -
- 표시할 데이터가 없습니다. -
- ) : ( - displayData.map((data, index) => { - // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) - const titleValue = - getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); - - const subtitleValue = - getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || - getAutoFallbackValue(data, "subtitle"); - - const descriptionValue = - getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) || - getAutoFallbackValue(data, "description"); - - // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 - const imageColumn = componentConfig.columnMapping?.imageColumn || - Object.keys(data).find(key => { - const lowerKey = key.toLowerCase(); - return lowerKey.includes('image') || lowerKey.includes('photo') || - lowerKey.includes('avatar') || lowerKey.includes('thumbnail') || - lowerKey.includes('picture') || lowerKey.includes('img'); - }); - - // 이미지 값 가져오기 (직접 접근 + 폴백) - const imageValue = imageColumn - ? data[imageColumn] - : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""); - - // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 - const shouldShowImage = componentConfig.cardStyle?.showImage !== false; - - // 이미지 URL 생성 (TableListComponent와 동일한 로직 사용) - const imageUrl = imageValue ? getFullImageUrl(imageValue) : ""; - - const cardKey = getCardKey(data, index); - const isCardSelected = selectedRows.has(cardKey); - - return ( -
handleCardClick(data, index)} - > - {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} - {shouldShowImage && ( -
- {imageUrl ? ( - {titleValue { - // 이미지 로드 실패 시 기본 아이콘으로 대체 - e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E"; - }} - /> - ) : ( -
- 👤 -
- )} -
- )} - - {/* 우측 컨텐츠 영역 */} -
- {/* 타이틀 + 서브타이틀 */} - {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( -
- {componentConfig.cardStyle?.showTitle && ( -

{titleValue}

- )} - {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( - {subtitleValue} - )} -
- )} - - {/* 추가 표시 컬럼들 - 가로 배치 */} - {componentConfig.columnMapping?.displayColumns && - componentConfig.columnMapping.displayColumns.length > 0 && ( -
- {componentConfig.columnMapping.displayColumns.map((columnName, idx) => { - const value = getColumnValue(data, columnName); - if (!value) return null; - - return ( -
- {getColumnLabel(columnName)}: - {value} -
- ); - })} -
- )} - - {/* 카드 설명 */} - {componentConfig.cardStyle?.showDescription && descriptionValue && ( -
-

- {truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)} -

-
- )} - - {/* 카드 액션 - 설정에 따라 표시 */} - {(componentConfig.cardStyle?.showActions ?? true) && ( -
- {(componentConfig.cardStyle?.showViewButton ?? true) && ( - - )} - {(componentConfig.cardStyle?.showEditButton ?? true) && ( - - )} - {(componentConfig.cardStyle?.showDeleteButton ?? false) && ( - - )} -
- )} -
-
- ); - }) - )} -
-
- - {/* 상세보기 모달 */} - - - - - 📋 - 상세 정보 - - - - {selectedData && ( -
-
- {Object.entries(selectedData) - .filter(([key, value]) => value !== null && value !== undefined && value !== '') - .map(([key, value]) => { - // 카테고리 타입인 경우 배지로 표시 - const meta = columnMeta[key]; - let displayValue: React.ReactNode = String(value); - - if (meta?.inputType === "category") { - const mapping = categoryMappings[key]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - const displayLabel = categoryData?.label || valueStr; - const displayColor = categoryData?.color; - - // 색상이 있고 "none"이 아닌 경우에만 배지로 표시 - if (displayColor && displayColor !== "none") { - displayValue = ( - - {displayLabel} - - ); - } else { - // 배지 없음: 일반 텍스트로 표시 - displayValue = displayLabel; - } - } - - return ( -
-
- {getColumnLabel(key)} -
-
- {displayValue} -
-
- ); - }) - } -
- -
- -
-
- )} -
-
- - {/* 편집 모달 */} - - - - - ✏️ - 데이터 편집 - - - - {editData && ( -
-
- {Object.entries(editData) - .filter(([key, value]) => value !== null && value !== undefined) - .map(([key, value]) => ( -
- - handleEditFormChange(key, e.target.value)} - className="w-full" - placeholder={`${key} 입력`} - /> -
- )) - } -
- -
- - -
-
- )} -
-
- - ); -}; diff --git a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx deleted file mode 100644 index bbd71caa..00000000 --- a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx +++ /dev/null @@ -1,732 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo } from "react"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { tableManagementApi } from "@/lib/api/tableManagement"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface CardDisplayConfigPanelProps { - config: any; - onChange: (config: any) => void; - screenTableName?: string; - tableColumns?: any[]; -} - -interface EntityJoinColumn { - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; - suggestedLabel: string; -} - -interface JoinTable { - tableName: string; - currentDisplayColumn: string; - joinConfig?: { - sourceColumn: string; - }; - availableColumns: Array<{ - columnName: string; - columnLabel: string; - dataType: string; - description?: string; - }>; -} - -/** - * CardDisplay 설정 패널 - * 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원 - */ -export const CardDisplayConfigPanel: React.FC = ({ - config, - onChange, - screenTableName, - tableColumns = [], -}) => { - // 테이블 선택 상태 - const [tableComboboxOpen, setTableComboboxOpen] = useState(false); - const [allTables, setAllTables] = useState>([]); - const [loadingTables, setLoadingTables] = useState(false); - const [availableColumns, setAvailableColumns] = useState([]); - const [loadingColumns, setLoadingColumns] = useState(false); - - // 엔티티 조인 컬럼 상태 - const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: EntityJoinColumn[]; - joinTables: JoinTable[]; - }>({ availableColumns: [], joinTables: [] }); - const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); - - // 현재 사용할 테이블명 - const targetTableName = useMemo(() => { - if (config.useCustomTable && config.customTableName) { - return config.customTableName; - } - return config.tableName || screenTableName; - }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); - - // 전체 테이블 목록 로드 - useEffect(() => { - const loadAllTables = async () => { - setLoadingTables(true); - try { - const response = await tableManagementApi.getTableList(); - if (response.success && response.data) { - setAllTables(response.data.map((t: any) => ({ - tableName: t.tableName || t.table_name, - displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, - }))); - } - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadAllTables(); - }, []); - - // 선택된 테이블의 컬럼 로드 - useEffect(() => { - const loadColumns = async () => { - if (!targetTableName) { - setAvailableColumns([]); - return; - } - - // 커스텀 테이블이 아니면 props로 받은 tableColumns 사용 - if (!config.useCustomTable && tableColumns && tableColumns.length > 0) { - setAvailableColumns(tableColumns); - return; - } - - setLoadingColumns(true); - try { - const result = await tableManagementApi.getColumnList(targetTableName); - if (result.success && result.data?.columns) { - setAvailableColumns(result.data.columns.map((col: any) => ({ - columnName: col.columnName, - columnLabel: col.displayName || col.columnLabel || col.columnName, - dataType: col.dataType, - }))); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - setAvailableColumns([]); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [targetTableName, config.useCustomTable, tableColumns]); - - // 엔티티 조인 컬럼 정보 가져오기 - useEffect(() => { - const fetchEntityJoinColumns = async () => { - if (!targetTableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } - - setLoadingEntityJoins(true); - try { - const result = await entityJoinApi.getEntityJoinColumns(targetTableName); - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], - }); - } catch (error) { - console.error("Entity 조인 컬럼 조회 오류:", error); - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); - } - }; - - fetchEntityJoinColumns(); - }, [targetTableName]); - - // 테이블 선택 핸들러 - const handleTableSelect = (tableName: string, isScreenTable: boolean) => { - if (isScreenTable) { - // 화면 기본 테이블 선택 - onChange({ - ...config, - useCustomTable: false, - customTableName: undefined, - tableName: tableName, - columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화 - }); - } else { - // 다른 테이블 선택 - onChange({ - ...config, - useCustomTable: true, - customTableName: tableName, - tableName: tableName, - columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화 - }); - } - setTableComboboxOpen(false); - }; - - // 현재 선택된 테이블 표시명 가져오기 - const getSelectedTableDisplay = () => { - if (!targetTableName) return "테이블을 선택하세요"; - const found = allTables.find(t => t.tableName === targetTableName); - return found?.displayName || targetTableName; - }; - - const handleChange = (key: string, value: any) => { - onChange({ ...config, [key]: value }); - }; - - const handleNestedChange = (path: string, value: any) => { - const keys = path.split("."); - let newConfig = { ...config }; - let current = newConfig; - - for (let i = 0; i < keys.length - 1; i++) { - if (!current[keys[i]]) { - current[keys[i]] = {}; - } - current = current[keys[i]]; - } - - current[keys[keys.length - 1]] = value; - onChange(newConfig); - }; - - // 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트 - const handleColumnSelect = (path: string, columnName: string) => { - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === columnName - ); - - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const existingJoinColumn = joinColumnsConfig.find( - (jc: any) => jc.columnName === columnName - ); - - if (!existingJoinColumn) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - - const newJoinColumnConfig = { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }; - - onChange({ - ...config, - columnMapping: { - ...config.columnMapping, - [path.split(".")[1]]: columnName, - }, - joinColumns: [...joinColumnsConfig, newJoinColumnConfig], - }); - return; - } - } - - handleNestedChange(path, columnName); - }; - - // 표시 컬럼 추가 - const addDisplayColumn = () => { - const currentColumns = config.columnMapping?.displayColumns || []; - const newColumns = [...currentColumns, ""]; - handleNestedChange("columnMapping.displayColumns", newColumns); - }; - - // 표시 컬럼 삭제 - const removeDisplayColumn = (index: number) => { - const currentColumns = [...(config.columnMapping?.displayColumns || [])]; - currentColumns.splice(index, 1); - handleNestedChange("columnMapping.displayColumns", currentColumns); - }; - - // 표시 컬럼 값 변경 - const updateDisplayColumn = (index: number, value: string) => { - const currentColumns = [...(config.columnMapping?.displayColumns || [])]; - currentColumns[index] = value; - - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === value - ); - - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const existingJoinColumn = joinColumnsConfig.find( - (jc: any) => jc.columnName === value - ); - - if (!existingJoinColumn) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - - const newJoinColumnConfig = { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }; - - onChange({ - ...config, - columnMapping: { - ...config.columnMapping, - displayColumns: currentColumns, - }, - joinColumns: [...joinColumnsConfig, newJoinColumnConfig], - }); - return; - } - } - - handleNestedChange("columnMapping.displayColumns", currentColumns); - }; - - // 테이블별로 조인 컬럼 그룹화 - const joinColumnsByTable: Record = {}; - entityJoinColumns.availableColumns.forEach((col) => { - if (!joinColumnsByTable[col.tableName]) { - joinColumnsByTable[col.tableName] = []; - } - joinColumnsByTable[col.tableName].push(col); - }); - - // 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props) - const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns); - - // 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI) - const renderColumnSelect = ( - value: string, - onChangeHandler: (value: string) => void, - placeholder: string = "컬럼을 선택하세요" - ) => { - return ( - - ); - }; - - return ( -
-
카드 디스플레이 설정
- - {/* 테이블 선택 */} -
- - - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {/* 화면 기본 테이블 */} - {screenTableName && ( - - handleTableSelect(screenTableName, true)} - className="text-xs" - > - - - {allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName} - - - )} - - {/* 전체 테이블 */} - - {allTables - .filter(t => t.tableName !== screenTableName) - .map((table) => ( - handleTableSelect(table.tableName, false)} - className="text-xs" - > - - - {table.displayName} - - ))} - - - - - - {config.useCustomTable && ( -

- 화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다. -

- )} -
- - {/* 테이블이 선택된 경우 컬럼 매핑 설정 */} - {(currentTableColumns.length > 0 || loadingColumns) && ( -
-
컬럼 매핑
- - {(loadingEntityJoins || loadingColumns) && ( -
- {loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."} -
- )} - -
- - {renderColumnSelect( - config.columnMapping?.titleColumn || "", - (value) => handleColumnSelect("columnMapping.titleColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.subtitleColumn || "", - (value) => handleColumnSelect("columnMapping.subtitleColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.descriptionColumn || "", - (value) => handleColumnSelect("columnMapping.descriptionColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.imageColumn || "", - (value) => handleColumnSelect("columnMapping.imageColumn", value) - )} -
- - {/* 동적 표시 컬럼 추가 */} -
-
- - -
- -
- {(config.columnMapping?.displayColumns || []).map((column: string, index: number) => ( -
-
- {renderColumnSelect( - column, - (value) => updateDisplayColumn(index, value) - )} -
- -
- ))} - - {(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && ( -
- "컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요 -
- )} -
-
-
- )} - - {/* 카드 스타일 설정 */} -
-
카드 스타일
- -
-
- - handleChange("cardsPerRow", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
- -
- - handleChange("cardSpacing", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
-
- -
-
- handleNestedChange("cardStyle.showTitle", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showSubtitle", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showDescription", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showImage", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showActions", checked)} - /> - -
- - {/* 개별 버튼 설정 */} - {(config.cardStyle?.showActions ?? true) && ( -
-
- handleNestedChange("cardStyle.showViewButton", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showEditButton", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showDeleteButton", checked)} - /> - -
-
- )} -
- -
- - handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
-
- - {/* 공통 설정 */} -
-
공통 설정
- -
- handleChange("disabled", checked)} - /> - -
- -
- handleChange("readonly", checked)} - /> - -
-
-
- ); -}; diff --git a/frontend/lib/registry/components/card-display/CardDisplayRenderer.tsx b/frontend/lib/registry/components/card-display/CardDisplayRenderer.tsx deleted file mode 100644 index 79b0cea9..00000000 --- a/frontend/lib/registry/components/card-display/CardDisplayRenderer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import React from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { CardDisplayDefinition } from "./index"; -import { CardDisplayComponent } from "./CardDisplayComponent"; - -/** - * CardDisplay 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class CardDisplayRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = CardDisplayDefinition; - - render(): React.ReactElement { - return ; - } - - /** - * 컴포넌트별 특화 메서드들 - */ - - // text 타입 특화 속성 처리 - protected getCardDisplayProps() { - const baseProps = this.getWebTypeProps(); - - // text 타입에 특화된 추가 속성들 - return { - ...baseProps, - // 여기에 text 타입 특화 속성들 추가 - }; - } - - // 값 변경 처리 - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; - - // 포커스 처리 - protected handleFocus = () => { - // 포커스 로직 - }; - - // 블러 처리 - protected handleBlur = () => { - // 블러 로직 - }; -} - -// 자동 등록 실행 -CardDisplayRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/card-display/README.md b/frontend/lib/registry/components/card-display/README.md deleted file mode 100644 index e2811a52..00000000 --- a/frontend/lib/registry/components/card-display/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# CardDisplay 컴포넌트 - -테이블 데이터를 카드 형태로 표시하는 컴포넌트 - -## 개요 - -- **ID**: `card-display` -- **카테고리**: display -- **웹타입**: text -- **작성자**: 개발팀 -- **버전**: 1.0.0 - -## 특징 - -- ✅ 자동 등록 시스템 -- ✅ 타입 안전성 -- ✅ Hot Reload 지원 -- ✅ 설정 패널 제공 -- ✅ 반응형 디자인 - -## 사용법 - -### 기본 사용법 - -```tsx -import { CardDisplayComponent } from "@/lib/registry/components/card-display"; - - -``` - -### 설정 옵션 - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| placeholder | string | "" | 플레이스홀더 텍스트 | -| maxLength | number | 255 | 최대 입력 길이 | -| minLength | number | 0 | 최소 입력 길이 | -| disabled | boolean | false | 비활성화 여부 | -| required | boolean | false | 필수 입력 여부 | -| readonly | boolean | false | 읽기 전용 여부 | - -## 이벤트 - -- `onChange`: 값 변경 시 -- `onFocus`: 포커스 시 -- `onBlur`: 포커스 해제 시 -- `onClick`: 클릭 시 - -## 스타일링 - -컴포넌트는 다음과 같은 스타일 옵션을 제공합니다: - -- `variant`: "default" | "outlined" | "filled" -- `size`: "sm" | "md" | "lg" - -## 예시 - -```tsx -// 기본 예시 - -``` - -## 개발자 정보 - -- **생성일**: 2025-09-15 -- **CLI 명령어**: `node scripts/create-component.js card-display "카드 디스플레이" "테이블 데이터를 카드 형태로 표시하는 컴포넌트" display text` -- **경로**: `lib/registry/components/card-display/` - -## 관련 문서 - -- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) -- [개발자 문서](https://docs.example.com/components/card-display) diff --git a/frontend/lib/registry/components/card-display/index.ts b/frontend/lib/registry/components/card-display/index.ts deleted file mode 100644 index 689bf40e..00000000 --- a/frontend/lib/registry/components/card-display/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import React from "react"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import type { WebType } from "@/types/screen"; -import { CardDisplayComponent } from "./CardDisplayComponent"; -import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel"; -import { CardDisplayConfig } from "./types"; - -/** - * CardDisplay 컴포넌트 정의 - * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 - */ -export const CardDisplayDefinition = createComponentDefinition({ - id: "card-display", - name: "카드 디스플레이", - name_eng: "CardDisplay Component", - description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "text", - component: CardDisplayComponent, - default_config: { - cardsPerRow: 3, // 기본값 3 (한 행당 카드 수) - cardSpacing: 16, - cardStyle: { - showTitle: true, - showSubtitle: true, - showDescription: true, - showImage: false, - showActions: true, - maxDescriptionLength: 100, - imagePosition: "top", - imageSize: "medium", - }, - columnMapping: {}, - dataSource: "table", - staticData: [], - }, - default_size: { width: 800, height: 400 }, - config_panel: CardDisplayConfigPanel, - icon: "Grid3x3", - tags: ["card", "display", "table", "grid"], - version: "1.0.0", - author: "개발팀", - documentation: - "테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.", - hidden: true, // v2-card-display 사용으로 패널에서 숨김 -}); - -// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다 - -// 타입 내보내기 -export type { CardDisplayConfig } from "./types"; diff --git a/frontend/lib/registry/components/card-display/types.ts b/frontend/lib/registry/components/card-display/types.ts deleted file mode 100644 index 368e43cc..00000000 --- a/frontend/lib/registry/components/card-display/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { ComponentConfig } from "@/types/component"; - -/** - * 카드 스타일 설정 - */ -export interface CardStyleConfig { - showTitle?: boolean; - showSubtitle?: boolean; - showDescription?: boolean; - showImage?: boolean; - maxDescriptionLength?: number; - imagePosition?: "top" | "left" | "right"; - imageSize?: "small" | "medium" | "large"; - showActions?: boolean; // 액션 버튼 표시 여부 (전체) - showViewButton?: boolean; // 상세보기 버튼 표시 여부 - showEditButton?: boolean; // 편집 버튼 표시 여부 - showDeleteButton?: boolean; // 삭제 버튼 표시 여부 -} - -/** - * 컬럼 매핑 설정 - */ -export interface ColumnMappingConfig { - titleColumn?: string; - subtitleColumn?: string; - descriptionColumn?: string; - imageColumn?: string; - displayColumns?: string[]; - actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들 -} - -/** - * CardDisplay 컴포넌트 설정 타입 - */ -export interface CardDisplayConfig extends ComponentConfig { - // 카드 레이아웃 설정 - cardsPerRow?: number; - cardSpacing?: number; - - // 카드 스타일 설정 - cardStyle?: CardStyleConfig; - - // 컬럼 매핑 설정 - columnMapping?: ColumnMappingConfig; - - // 컴포넌트별 테이블 설정 - useCustomTable?: boolean; - customTableName?: string; - tableName?: string; - isReadOnly?: boolean; - - // 테이블 데이터 설정 - dataSource?: "static" | "table" | "api"; - tableId?: string; - staticData?: any[]; - - // 공통 설정 - disabled?: boolean; - required?: boolean; - readonly?: boolean; - helperText?: string; - - // 스타일 관련 - variant?: "default" | "outlined" | "filled"; - size?: "sm" | "md" | "lg"; - - // 이벤트 관련 - onChange?: (value: any) => void; - onCardClick?: (data: any) => void; - onCardHover?: (data: any) => void; -} - -/** - * CardDisplay 컴포넌트 Props 타입 - */ -export interface CardDisplayProps { - id?: string; - name?: string; - value?: any; - config?: CardDisplayConfig; - className?: string; - style?: React.CSSProperties; - - // 이벤트 핸들러 - onChange?: (value: any) => void; - onFocus?: () => void; - onBlur?: () => void; - onClick?: () => void; -} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index da53dcb4..5d41daee 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -70,7 +70,6 @@ import "./button-primary/ButtonPrimaryRenderer"; import "./text-display/TextDisplayRenderer"; import "./divider-line/DividerLineRenderer"; import "./table-list/TableListRenderer"; -import "./card-display/CardDisplayRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; import "./numbering-rule/NumberingRuleRenderer"; import "./table-search-widget"; @@ -80,7 +79,6 @@ import "./section-card/SectionCardRenderer"; import "./tabs/tabs-component"; import "./location-swap-selector/LocationSwapSelectorRenderer"; import "./rack-structure/RackStructureRenderer"; -import "./pivot-grid/PivotGridRenderer"; import "./aggregation-widget/AggregationWidgetRenderer"; import "./repeat-container/RepeatContainerRenderer"; @@ -91,11 +89,9 @@ import "./v2-repeater/V2RepeaterRenderer"; import "./v2-button-primary/ButtonPrimaryRenderer"; import "./v2-split-panel-layout/SplitPanelLayoutRenderer"; import "./v2-aggregation-widget/AggregationWidgetRenderer"; -import "./v2-card-display/CardDisplayRenderer"; import "./v2-numbering-rule/NumberingRuleRenderer"; import "./v2-table-list/TableListRenderer"; import "./v2-text-display/TextDisplayRenderer"; -import "./v2-pivot-grid/PivotGridRenderer"; import "./v2-divider-line/DividerLineRenderer"; // ============================================================ @@ -124,7 +120,6 @@ import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트 -import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블 import "./domain/v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 diff --git a/frontend/lib/registry/components/pivot-grid/PLAN.md b/frontend/lib/registry/components/pivot-grid/PLAN.md deleted file mode 100644 index 7b96ab38..00000000 --- a/frontend/lib/registry/components/pivot-grid/PLAN.md +++ /dev/null @@ -1,159 +0,0 @@ -# PivotGrid 컴포넌트 전체 구현 계획 - -## 개요 -DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현 - -## 현재 상태: ✅ 모든 기능 구현 완료! - ---- - -## 구현된 기능 목록 - -### 1. 기본 피벗 테이블 ✅ -- [x] 피벗 테이블 렌더링 -- [x] 행/열 확장/축소 -- [x] 합계/소계 표시 -- [x] 전체 확장/축소 버튼 - -### 2. 필드 패널 (드래그앤드롭) ✅ -- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터) -- [x] 각 영역에 배치된 필드 칩/태그 표시 -- [x] 필드 제거 버튼 (X) -- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용) -- [x] 영역 간 필드 이동 -- [x] 같은 영역 내 순서 변경 -- [x] 드래그 시 시각적 피드백 - -### 3. 필드 선택기 (모달) ✅ -- [x] 모달 열기/닫기 -- [x] 사용 가능한 필드 목록 -- [x] 필드 검색 기능 -- [x] 필드별 영역 선택 드롭다운 -- [x] 데이터 타입 아이콘 표시 -- [x] 집계 함수 선택 (데이터 영역) -- [x] 표시 모드 선택 (데이터 영역) - -### 4. 데이터 요약 (누계, % 모드) ✅ -- [x] 절대값 표시 (기본) -- [x] 행 총계 대비 % -- [x] 열 총계 대비 % -- [x] 전체 총계 대비 % -- [x] 행/열 방향 누계 -- [x] 이전 대비 차이 -- [x] 이전 대비 % 차이 - -### 5. 필터링 ✅ -- [x] 필터 팝업 컴포넌트 (FilterPopup) -- [x] 값 검색 기능 -- [x] 체크박스 기반 값 선택 -- [x] 포함/제외 모드 -- [x] 전체 선택/해제 -- [x] 선택된 항목 수 표시 - -### 6. Drill Down ✅ -- [x] 셀 더블클릭 시 상세 데이터 모달 -- [x] 원본 데이터 테이블 표시 -- [x] 검색 기능 -- [x] 정렬 기능 -- [x] 페이지네이션 -- [x] CSV/Excel 내보내기 - -### 7. Virtual Scrolling ✅ -- [x] useVirtualScroll 훅 (행) -- [x] useVirtualColumnScroll 훅 (열) -- [x] useVirtual2DScroll 훅 (행+열) -- [x] overscan 버퍼 지원 - -### 8. Excel 내보내기 ✅ -- [x] xlsx 라이브러리 사용 -- [x] 피벗 데이터 Excel 내보내기 -- [x] Drill Down 데이터 Excel 내보내기 -- [x] CSV 내보내기 (기본) -- [x] 스타일링 (헤더, 데이터, 총계) -- [x] 숫자 포맷 - -### 9. 차트 통합 ✅ -- [x] recharts 라이브러리 사용 -- [x] 막대 차트 -- [x] 누적 막대 차트 -- [x] 선 차트 -- [x] 영역 차트 -- [x] 파이 차트 -- [x] 범례 표시 -- [x] 커스텀 툴팁 -- [x] 차트 토글 버튼 - -### 10. 조건부 서식 (Conditional Formatting) ✅ -- [x] Color Scale (색상 그라데이션) -- [x] Data Bar (데이터 막대) -- [x] Icon Set (아이콘) -- [x] Cell Value (조건 기반 스타일) -- [x] ConfigPanel에서 설정 UI - -### 11. 상태 저장/복원 ✅ -- [x] usePivotState 훅 -- [x] localStorage/sessionStorage 지원 -- [x] 자동 저장 (디바운스) - -### 12. ConfigPanel 고도화 ✅ -- [x] 데이터 소스 설정 (테이블 선택) -- [x] 필드별 영역 설정 (행, 열, 데이터, 필터) -- [x] 총계 옵션 설정 -- [x] 스타일 설정 (테마, 교차 색상 등) -- [x] 내보내기 설정 (Excel/CSV) -- [x] 차트 설정 UI -- [x] 필드 선택기 설정 UI -- [x] 조건부 서식 설정 UI -- [x] 크기 설정 - ---- - -## 파일 구조 - -``` -pivot-grid/ -├── components/ -│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭) -│ ├── FieldChooser.tsx # 필드 선택기 모달 -│ ├── DrillDownModal.tsx # Drill Down 모달 -│ ├── FilterPopup.tsx # 필터 팝업 -│ ├── PivotChart.tsx # 차트 컴포넌트 -│ └── index.ts # 내보내기 -├── hooks/ -│ ├── useVirtualScroll.ts # 가상 스크롤 훅 -│ ├── usePivotState.ts # 상태 저장 훅 -│ └── index.ts # 내보내기 -├── utils/ -│ ├── aggregation.ts # 집계 함수 -│ ├── pivotEngine.ts # 피벗 엔진 -│ ├── exportExcel.ts # Excel 내보내기 -│ ├── conditionalFormat.ts # 조건부 서식 -│ └── index.ts # 내보내기 -├── types.ts # 타입 정의 -├── PivotGridComponent.tsx # 메인 컴포넌트 -├── PivotGridConfigPanel.tsx # 설정 패널 -├── PivotGridRenderer.tsx # 렌더러 -├── index.ts # 모듈 내보내기 -└── PLAN.md # 이 파일 -``` - ---- - -## 후순위 기능 (선택적) - -다음 기능들은 필요 시 추가 구현 가능: - -### 데이터 바인딩 확장 -- [ ] OLAP Data Source 연동 (복잡) -- [ ] GraphQL 연동 -- [ ] 실시간 데이터 업데이트 (WebSocket) - -### 고급 기능 -- [ ] 피벗 테이블 병합 (여러 데이터 소스) -- [ ] 계산 필드 (커스텀 수식) -- [ ] 데이터 정렬 옵션 강화 -- [ ] 그룹핑 옵션 (날짜 그룹핑 등) - ---- - -## 완료일: 2026-01-08 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx deleted file mode 100644 index 13ad9887..00000000 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ /dev/null @@ -1,2217 +0,0 @@ -"use client"; - -/** - * PivotGrid 메인 컴포넌트 - * 다차원 데이터 분석을 위한 피벗 테이블 - */ - -import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { - PivotGridProps, - PivotResult, - PivotFieldConfig, - PivotCellData, - PivotFlatRow, - PivotCellValue, - PivotGridState, -} from "./types"; -import { processPivotData, pathToKey } from "./utils/pivotEngine"; -import { exportPivotToExcel } from "./utils/exportExcel"; -import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; -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, - Download, - Settings, - RefreshCw, - Maximize2, - Minimize2, - LayoutGrid, - FileSpreadsheet, - BarChart3, - Filter, - ArrowUp, - ArrowDown, - ArrowUpDown, - Printer, - Save, - RotateCcw, - FileText, - Loader2, - Eye, - EyeOff, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; - -// ==================== 상수 ==================== - -const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용) - -// ==================== 유틸리티 함수 ==================== - -// 셀 병합 정보 계산 -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; -}; - -// ==================== 서브 컴포넌트 ==================== - -// 행 헤더 셀 -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; - - return ( - 1 ? rowSpan : undefined} - > -
- {row.hasChildren && ( - - )} - {!row.hasChildren && } - {row.caption} -
- - ); -}; - -// 데이터 셀 -interface DataCellProps { - values: PivotCellValue[]; - isTotal?: boolean; - isSelected?: boolean; - onClick?: (e?: React.MouseEvent) => void; - onDoubleClick?: () => void; - conditionalStyle?: CellFormatStyle; -} - -const DataCell: React.FC = ({ - values, - isTotal = false, - isSelected = false, - onClick, - onDoubleClick, - conditionalStyle, -}) => { - // 조건부 서식 스타일 계산 - 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 ( - - 0 - - ); - } - - // 툴팁 내용 생성 - const tooltipContent = values.map((v) => - `${v.field || "값"}: ${v.formattedValue || v.value}` - ).join("\n"); - - // 단일 데이터 필드인 경우 - if (values.length === 1) { - return ( - - {/* Data Bar */} - {hasDataBar && ( -
- )} - - {icon && {icon}} - {values[0].formattedValue || (values[0].value === 0 ? '0' : values[0].formattedValue)} - - - ); - } - - // 다중 데이터 필드인 경우 - return ( - <> - {values.map((val, idx) => ( - - {hasDataBar && ( -
- )} - - {icon && {icon}} - {val.formattedValue || (val.value === 0 ? '0' : val.formattedValue)} - - - ))} - - ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotGridComponent: React.FC = ({ - title, - fields: initialFields = [], - totals = { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }, - style = { - theme: "default", - headerStyle: "default", - cellPadding: "normal", - borderStyle: "light", - alternateRowColors: true, - highlightTotals: true, - }, - fieldChooser, - chart: chartConfig, - allowExpandAll = true, - height = "auto", - maxHeight, - exportConfig, - data: externalData, - onCellClick, - onCellDoubleClick, - onFieldDrop, - onExpandChange, -}) => { - // ==================== 상태 ==================== - - const [fields, setFields] = useState(initialFields); - // 초기 필드 설정 저장 (초기화용) - const initialFieldsRef = useRef(initialFields); - const [pivotState, setPivotState] = useState({ - expandedRowPaths: [], - expandedColumnPaths: [], - sortConfig: null, - filterConfig: {}, - }); - - // 🆕 초기 로드 시 자동 확장 (첫 레벨만) - const [isInitialExpanded, setIsInitialExpanded] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - 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); - - // 상태 저장 키 - const stateStorageKey = `pivot-state-${title || "default"}`; - const persistSettingKey = `pivot-persist-${title || "default"}`; - - // 상태 유지 설정 (체크박스용) - const [persistState, setPersistState] = useState(() => { - if (typeof window === "undefined") return true; - const saved = localStorage.getItem(persistSettingKey); - return saved !== null ? saved === "true" : true; // 기본값 true - }); - - // 복원 완료 여부 (initialFields 덮어쓰기 방지) - const [isStateRestored, setIsStateRestored] = useState(false); - - // 상태 복원 (localStorage) - 마운트 시 한 번만 실행 - useEffect(() => { - if (typeof window === "undefined") return; - - // 상태 유지가 꺼져 있으면 복원하지 않음 - if (!persistState) { - localStorage.removeItem(stateStorageKey); - setIsStateRestored(true); - return; - } - - try { - const savedState = localStorage.getItem(stateStorageKey); - if (!savedState) { - setIsStateRestored(true); - return; - } - - const parsed = JSON.parse(savedState); - - // 버전 체크 - 버전이 다르면 이전 상태 무시 - if (parsed.version !== PIVOT_STATE_VERSION) { - localStorage.removeItem(stateStorageKey); - setIsStateRestored(true); - return; - } - - // 필드 복원 시 유효성 검사 (중요!) - if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) { - // 저장된 필드가 현재 데이터와 호환되는지 확인 - const validFields = parsed.fields.filter((f: PivotFieldConfig) => - f && typeof f.field === "string" && typeof f.area === "string" - ); - - if (validFields.length > 0) { - setFields(validFields); - } - } - - // pivotState 복원 시 유효성 검사 (확장 경로 검증) - if (parsed.pivotState && typeof parsed.pivotState === "object") { - const restoredState: PivotGridState = { - // expandedRowPaths는 배열의 배열이어야 함 - expandedRowPaths: Array.isArray(parsed.pivotState.expandedRowPaths) - ? parsed.pivotState.expandedRowPaths.filter( - (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") - ) - : [], - // expandedColumnPaths도 동일하게 검증 - expandedColumnPaths: Array.isArray(parsed.pivotState.expandedColumnPaths) - ? parsed.pivotState.expandedColumnPaths.filter( - (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") - ) - : [], - sortConfig: parsed.pivotState.sortConfig || null, - filterConfig: parsed.pivotState.filterConfig || {}, - }; - setPivotState(restoredState); - } - - if (parsed.sortConfig) setSortConfig(parsed.sortConfig); - if (parsed.columnWidths && typeof parsed.columnWidths === "object") { - setColumnWidths(parsed.columnWidths); - } - } catch (e) { - console.warn("피벗 상태 복원 실패, localStorage 초기화:", e); - // 손상된 상태는 제거 - localStorage.removeItem(stateStorageKey); - } - - setIsStateRestored(true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 마운트 시 한 번만 실행 - - // 외부 fields 변경 시 동기화 (복원이 완료된 후에만, 저장된 상태가 없을 때만) - useEffect(() => { - if (!isStateRestored) return; // 복원 완료 전에는 무시 - - // 저장된 상태가 있으면 initialFields로 덮어쓰지 않음 - if (typeof window !== "undefined") { - const savedState = localStorage.getItem(stateStorageKey); - if (savedState) return; // 이미 저장된 상태가 있으면 무시 - } - - if (initialFields.length > 0) { - setFields(initialFields); - } - // persistState는 의존성에서 제외 - 체크박스 변경 시 현재 상태 유지 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialFields, isStateRestored, stateStorageKey]); - - // 상태 유지 설정 저장 + 켜질 때 현재 상태 즉시 저장 - useEffect(() => { - if (typeof window === "undefined") return; - localStorage.setItem(persistSettingKey, String(persistState)); - - // 상태 유지를 켜면 현재 상태를 즉시 저장 - if (persistState && isStateRestored && fields.length > 0) { - const stateToSave = { - version: PIVOT_STATE_VERSION, - fields, - pivotState, - sortConfig, - columnWidths, - }; - localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); - } - - // 상태 유지를 끄면 저장된 상태 삭제 - if (!persistState) { - localStorage.removeItem(stateStorageKey); - } - }, [persistState, persistSettingKey, isStateRestored, fields, pivotState, sortConfig, columnWidths, stateStorageKey]); - - // 상태 저장 (localStorage) - const saveStateToStorage = useCallback(() => { - if (typeof window === "undefined" || !persistState) return; - const stateToSave = { - version: PIVOT_STATE_VERSION, - fields, - pivotState, - sortConfig, - columnWidths, - }; - localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); - }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey, persistState]); - - // 상태 변경 시 자동 저장 (복원 완료 후에만) - useEffect(() => { - if (!persistState || !isStateRestored) return; - // 초기 로드 후에만 저장 (빈 필드일 때는 저장 안 함) - if (fields.length > 0) { - saveStateToStorage(); - } - }, [fields, pivotState, sortConfig, columnWidths, persistState, isStateRestored, saveStateToStorage]); - - // 데이터 - const data = externalData || []; - - // ==================== 필드 분류 ==================== - - const rowFields = useMemo( - () => - fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - const columnFields = useMemo( - () => - fields - .filter((f) => f.area === "column" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - const dataFields = useMemo( - () => - fields - .filter((f) => f.area === "data" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - // 필터 영역 필드 - const filterFields = useMemo( - () => { - const result = fields - .filter((f) => f.area === "filter" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - return result; - }, - [fields] - ); - - // 사용 가능한 필드 목록 (FieldChooser용) - const availableFields = useMemo(() => { - if (data.length === 0) return []; - - const sampleRow = data[0]; - return Object.keys(sampleRow).map((key) => { - const existingField = fields.find((f) => f.field === key); - const value = sampleRow[key]; - - // 데이터 타입 추론 - let dataType: "string" | "number" | "date" | "boolean" = "string"; - if (typeof value === "number") dataType = "number"; - else if (typeof value === "boolean") dataType = "boolean"; - else if (value instanceof Date) dataType = "date"; - else if (typeof value === "string") { - // 날짜 문자열 감지 - if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; - } - - return { - field: key, - caption: existingField?.caption || key, - dataType, - isSelected: existingField?.visible !== false, - currentArea: existingField?.area, - }; - }); - }, [data, fields]); - - // ==================== 필터 적용 ==================== - - const filteredData = useMemo(() => { - if (!data || data.length === 0) return data; - - // 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링 - const activeFilters = fields.filter( - (f) => f.filterValues && f.filterValues.length > 0 - ); - - if (activeFilters.length === 0) return data; - - const result = data.filter((row) => { - return activeFilters.every((filter) => { - const rawValue = row[filter.field]; - const filterValues = filter.filterValues || []; - const filterType = filter.filterType || "include"; - - // 타입 안전한 비교: 값을 문자열로 변환하여 비교 - const value = rawValue === null || rawValue === undefined - ? "(빈 값)" - : String(rawValue); - - if (filterType === "include") { - return filterValues.some((fv) => String(fv) === value); - } else { - return filterValues.every((fv) => String(fv) !== value); - } - }); - }); - - // 모든 데이터가 필터링되면 경고 (디버깅용) - if (result.length === 0 && data.length > 0) { - console.warn("⚠️ [PivotGrid] 필터로 인해 모든 데이터가 제거됨"); - } - - return result; - }, [data, fields]); - - // ==================== 피벗 처리 ==================== - - const pivotResult = useMemo(() => { - try { - if (!filteredData || filteredData.length === 0 || fields.length === 0) { - return null; - } - - // FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요 - // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) - if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { - return null; - } - - const result = processPivotData( - filteredData, - fields, - pivotState.expandedRowPaths, - pivotState.expandedColumnPaths - ); - - return result; - } catch (error) { - console.error("❌ [pivotResult] 피벗 처리 에러:", error); - return null; - } - }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); - - // 초기 로드 시 첫 레벨 자동 확장 - useEffect(() => { - try { - if (pivotResult && pivotResult.flatRows && pivotResult.flatRows.length > 0 && !isInitialExpanded) { - // 첫 레벨 행들의 경로 수집 (level 0인 행들) - const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren); - - // 첫 레벨 행이 있으면 자동 확장 - if (firstLevelRows.length > 0 && firstLevelRows.length < 100) { - const firstLevelPaths = firstLevelRows.map((row) => row.path); - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: firstLevelPaths, - })); - setIsInitialExpanded(true); - } else { - // 행이 너무 많으면 자동 확장 건너뛰기 - setIsInitialExpanded(true); - } - } - } catch (error) { - console.error("❌ [초기 확장] 에러:", error); - setIsInitialExpanded(true); - } - }, [pivotResult, isInitialExpanded]); - - // 조건부 서식용 전체 값 수집 - const allCellValues = useMemo(() => { - if (!pivotResult) return new Map(); - - const valuesByField = new Map(); - - // 데이터 매트릭스에서 모든 값 수집 - pivotResult.dataMatrix.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - // 행 총계 값 수집 - pivotResult.grandTotals.row.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - // 열 총계 값 수집 - pivotResult.grandTotals.column.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - 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 => { - if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { - return {}; - } - const allValues = allCellValues.get(field) || []; - return getConditionalStyle(value, field, style.conditionalFormats, allValues); - }, - [style?.conditionalFormats, allCellValues] - ); - - // ==================== 이벤트 핸들러 ==================== - - // 필드 변경 - const handleFieldsChange = useCallback( - (newFields: PivotFieldConfig[]) => { - setFields(newFields); - }, - [] - ); - - // 행 확장/축소 - const handleToggleRowExpand = useCallback( - (path: string[]) => { - setPivotState((prev) => { - const pathKey = pathToKey(path); - const existingIndex = prev.expandedRowPaths.findIndex( - (p) => pathToKey(p) === pathKey - ); - - let newPaths: string[][]; - if (existingIndex >= 0) { - newPaths = prev.expandedRowPaths.filter( - (_, i) => i !== existingIndex - ); - } else { - newPaths = [...prev.expandedRowPaths, path]; - } - - onExpandChange?.(newPaths); - - return { - ...prev, - expandedRowPaths: newPaths, - }; - }); - }, - [onExpandChange] - ); - - // 전체 확장 (재귀적으로 모든 레벨 확장) - const handleExpandAll = useCallback(() => { - try { - if (!pivotResult) { - return; - } - - // 재귀적으로 모든 가능한 경로 생성 - const allRowPaths: string[][] = []; - const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); - - // 행 필드가 없으면 종료 - if (rowFields.length === 0) { - return; - } - - // 데이터에서 모든 고유한 경로 추출 - const pathSet = new Set(); - filteredData.forEach((item) => { - // 마지막 레벨은 제외 (확장할 자식이 없으므로) - for (let depth = 1; depth < rowFields.length; depth++) { - const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? "")); - const pathKey = JSON.stringify(path); - pathSet.add(pathKey); - } - }); - - // Set을 배열로 변환 (최대 1000개로 제한하여 성능 보호) - const MAX_PATHS = 1000; - let count = 0; - pathSet.forEach((pathKey) => { - if (count < MAX_PATHS) { - allRowPaths.push(JSON.parse(pathKey)); - count++; - } - }); - - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: allRowPaths, - expandedColumnPaths: [], - })); - } catch (error) { - console.error("❌ [handleExpandAll] 에러:", error); - } - }, [pivotResult, fields, filteredData]); - - // 전체 축소 - const handleCollapseAll = useCallback(() => { - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: [], - expandedColumnPaths: [], - })); - }, []); - - // 셀 클릭 - const handleCellClick = useCallback( - (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { - if (!onCellClick) return; - - const cellData: PivotCellData = { - value: values[0]?.value, - rowPath, - columnPath: colPath, - field: values[0]?.field, - }; - - onCellClick(cellData); - }, - [onCellClick] - ); - - // 셀 더블클릭 (Drill Down) - const handleCellDoubleClick = useCallback( - (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { - const cellData: PivotCellData = { - value: values[0]?.value, - rowPath, - columnPath: colPath, - field: values[0]?.field, - }; - - // Drill Down 모달 열기 - setDrillDownData({ open: true, cellData }); - - // 외부 콜백 호출 - if (onCellDoubleClick) { - onCellDoubleClick(cellData); - } - }, - [onCellDoubleClick] - ); - - // CSV 내보내기 - const handleExportCSV = useCallback(() => { - if (!pivotResult) return; - - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; - - let csv = ""; - - // 헤더 행 - const headerRow = [""].concat( - flatColumns.map((col) => col.caption || "총계") - ); - if (totals?.showRowGrandTotals) { - headerRow.push("총계"); - } - csv += headerRow.join(",") + "\n"; - - // 데이터 행 - flatRows.forEach((row) => { - const rowData = [row.caption]; - - flatColumns.forEach((col) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey); - rowData.push(values?.[0]?.value?.toString() || ""); - }); - - if (totals?.showRowGrandTotals) { - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - rowData.push(rowTotal?.[0]?.value?.toString() || ""); - } - - csv += rowData.join(",") + "\n"; - }); - - // 열 총계 행 - if (totals?.showColumnGrandTotals) { - const totalRow = ["총계"]; - flatColumns.forEach((col) => { - const colTotal = grandTotals.column.get(pathToKey(col.path)); - totalRow.push(colTotal?.[0]?.value?.toString() || ""); - }); - if (totals?.showRowGrandTotals) { - totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); - } - csv += totalRow.join(",") + "\n"; - } - - // 다운로드 - const blob = new Blob(["\uFEFF" + csv], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `${title || "pivot"}_export.csv`; - link.click(); - }, [pivotResult, totals, title]); - - // Excel 내보내기 - const handleExportExcel = useCallback(async () => { - if (!pivotResult) return; - - try { - await exportPivotToExcel(pivotResult, fields, totals, { - fileName: title || "pivot_export", - title: title, - }); - } catch (error) { - console.error("Excel 내보내기 실패:", error); - } - }, [pivotResult, fields, totals, title]); - - // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) - const handlePrint = useCallback(() => { - if (typeof window === "undefined") return; - - 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(() => { - // 로컬 스토리지에서 상태 제거 (SSR 보호) - if (typeof window !== "undefined") { - localStorage.removeItem(stateStorageKey); - } - - // 확장/축소, 정렬, 필터 상태만 초기화 - setPivotState({ - expandedRowPaths: [], - expandedColumnPaths: [], - sortConfig: null, - filterConfig: {}, - }); - setSortConfig(null); - setColumnWidths({}); - setSelectedCell(null); - setSelectionRange(null); - }, [stateStorageKey]); - - // 필드 숨기기/표시 상태 - 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 hiddenFieldsList = useMemo(() => { - return fields.filter((f) => hiddenFields.has(f.field)); - }, [fields, hiddenFields]); - - // 모든 필드 표시 - const showAllFields = useCallback(() => { - setHiddenFields(new Set()); - }, []); - - // ==================== 렌더링 ==================== - - // 빈 상태 - if (!data || data.length === 0) { - return ( -
- -

데이터가 없습니다

-

데이터를 로드하거나 필드를 설정해주세요

-
- ); - } - - // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) - const hasActiveFields = fields.some( - (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) - ); - if (!hasActiveFields) { - return ( -
- {/* 필드 패널 */} - setShowFieldPanel(!showFieldPanel)} - initialFields={initialFieldsRef.current} - /> - - {/* 안내 메시지 */} -
- -

필드가 설정되지 않았습니다

-

- 행, 열, 데이터 영역에 필드를 배치해주세요 -

- -
- - {/* 필드 선택기 모달 */} - -
- ); - } - - // 피벗 결과 없음 - if (!pivotResult) { - return ( -
- -
- ); - } - - const { flatColumns, dataMatrix, grandTotals, columnHeaderLevels } = 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 ( -
- {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} - setShowFieldPanel(!showFieldPanel)} - initialFields={initialFieldsRef.current} - /> - - {/* 헤더 툴바 */} -
-
- {title &&

{title}

} - - ({filteredData.length !== data.length - ? `${filteredData.length} / ${data.length}건` - : `${data.length}건`}) - -
- -
- {/* 필드 선택기 버튼 */} - {fieldChooser?.enabled !== false && ( - - )} - - {/* 필드 패널 토글 */} - - - {allowExpandAll && ( - <> - - - - - )} - - {/* 상태 유지 체크박스 */} -
- setPersistState(checked === true)} - className="h-3.5 w-3.5" - /> - -
- - {/* 차트 토글 */} - {chartConfig && ( - - )} - - {/* 내보내기 버튼들 */} - {exportConfig?.excel && ( - <> - - - - - - )} - - - - - - - - {/* 숨겨진 필드 표시 드롭다운 */} - {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={ - - } - /> - ); - })} -
-
- )} - - {/* 피벗 테이블 */} -
0 ? containerHeight : undefined, - // 최소 200px 보장 + 데이터에 맞게 조정 (최대 400px) - minHeight: Math.max( - 200, // 절대 최소값 - 블라인드 효과 방지 - Math.min(400, (sortedFlatRows.length + 3) * ROW_HEIGHT + 50) - ) - }} - tabIndex={0} - onKeyDown={handleKeyDown} - > - - - {/* 다중 행 열 헤더 */} - {columnHeaderLevels.length > 0 ? ( - // 열 필드가 있는 경우: 각 레벨별로 행 생성 - columnHeaderLevels.map((levelCells, levelIdx) => ( - - {/* 좌상단 코너 (첫 번째 레벨에만 표시) */} - {levelIdx === 0 && ( - - )} - - {/* 열 헤더 셀 - 해당 레벨 */} - {levelCells.map((cell, cellIdx) => ( - - ))} - - {/* 행 총계 헤더 (첫 번째 레벨에만 표시) */} - {levelIdx === 0 && totals?.showRowGrandTotals && ( - - )} - - {/* 열 필드 필터 (첫 번째 레벨에만 표시) */} - {levelIdx === 0 && columnFields.length > 0 && ( - - )} - - )) - ) : ( - // 열 필드가 없는 경우: 단일 행 - - - - {/* 열 헤더 셀 (열 필드 없을 때) */} - {flatColumns.map((col, idx) => ( - - ))} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( - - )} - - )} - - {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} - {dataFields.length > 1 && ( - - {flatColumns.map((col, colIdx) => ( - - {dataFields.map((df, dfIdx) => ( - - ))} - - ))} - - )} - - - - {/* 열 총계 행 (상단 위치) */} - {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( - - - - {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 && ( - - )} - - {/* 데이터 셀 */} - {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 && (() => { - const bottomPadding = Math.max(0, virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT)); - return bottomPadding > 0 ? ( - - - ) : null; - })()} - - {/* 열 총계 행 (하단 위치 - 기본값) */} - {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( - - - - {flatColumns.map((col, colIdx) => ( - - ))} - - {/* 대총합 */} - {totals?.showRowGrandTotals && ( - - )} - - )} - -
1 ? 1 : 0)} - > -
- {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 && 항목} -
-
-
- {cell.caption || "(전체)"} - {levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && ( - - )} -
-
1 ? 1 : 0)} - > - 총계 - 1 ? 1 : 0)} - > -
- {columnFields.map((f) => ( - { - const newFields = fields.map((fld) => - fld.field === field.field && fld.area === "column" - ? { ...fld, filterValues: values, filterType: type } - : fld - ); - handleFieldsChange(newFields); - }} - trigger={ - - } - /> - ))} -
-
1 ? 2 : 1} - > -
- {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 || "(전체)"} - {dataFields.length === 1 && } -
-
handleResizeStart(idx, e)} - /> -
1 ? 2 : 1} - > - 총계 -
handleSort(df.field)} - > -
- {df.caption} - -
-
- 총계 -
-
-
- 총계 -
-
- - {/* 차트 */} - {showChart && chartConfig && pivotResult && ( - - )} - - {/* 필드 선택기 모달 */} - - - {/* Drill Down 모달 */} - setDrillDownData((prev) => ({ ...prev, open }))} - cellData={drillDownData.cellData} - data={data} - fields={fields} - rowFields={rowFields} - columnFields={columnFields} - /> -
- ); -}; - -export default PivotGridComponent; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx deleted file mode 100644 index 993e56d5..00000000 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ /dev/null @@ -1,822 +0,0 @@ -"use client"; - -/** - * PivotGrid 설정 패널 - 간소화 버전 - * - * 피벗 테이블 설정 방법: - * 1. 테이블 선택 - * 2. 컬럼을 드래그하여 행/열/값 영역에 배치 - */ - -import React, { useState, useEffect, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { - PivotGridComponentConfig, - PivotFieldConfig, - PivotAreaType, - AggregationType, - FieldDataType, - DateGroupInterval, -} 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 { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Rows, - Columns, - Calculator, - X, - Plus, - GripVertical, - Table2, - BarChart3, - Settings, - ChevronDown, - ChevronUp, - Info, -} from "lucide-react"; -import { tableTypeApi } from "@/lib/api/screen"; - -// ==================== 타입 ==================== - -interface TableInfo { - table_name: string; - table_comment?: string; -} - -interface ColumnInfo { - column_name: string; - data_type: string; - column_comment?: string; -} - -interface PivotGridConfigPanelProps { - config: PivotGridComponentConfig; - onChange: (config: PivotGridComponentConfig) => void; -} - -// DB 타입을 FieldDataType으로 변환 -function mapDbTypeToFieldType(dbType: string): FieldDataType { - const type = dbType.toLowerCase(); - 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")) { - return "date"; - } - if (type.includes("bool")) { - return "boolean"; - } - return "string"; -} - -// ==================== 컬럼 칩 컴포넌트 ==================== - -interface ColumnChipProps { - column: ColumnInfo; - isUsed: boolean; - onClick: () => void; -} - -const ColumnChip: React.FC = ({ column, isUsed, onClick }) => { - const dataType = mapDbTypeToFieldType(column.data_type); - const typeColor = { - number: "bg-primary/10 text-primary border-primary/20", - string: "bg-emerald-100 text-emerald-700 border-emerald-200", - date: "bg-purple-100 text-purple-700 border-purple-200", - boolean: "bg-amber-100 text-orange-700 border-orange-200", - }[dataType]; - - return ( - - ); -}; - -// ==================== 영역 드롭존 컴포넌트 ==================== - -interface AreaDropZoneProps { - area: PivotAreaType; - label: string; - description: string; - icon: React.ReactNode; - fields: PivotFieldConfig[]; - columns: ColumnInfo[]; - onAddField: (column: ColumnInfo) => void; - onRemoveField: (index: number) => void; - onUpdateField: (index: number, updates: Partial) => void; - color: string; -} - -const AreaDropZone: React.FC = ({ - area, - label, - description, - icon, - fields, - columns, - onAddField, - onRemoveField, - onUpdateField, - color, -}) => { - const [isExpanded, setIsExpanded] = useState(true); - - // 사용 가능한 컬럼 (이미 추가된 컬럼 제외) - const availableColumns = columns.filter( - (col) => !fields.some((f) => f.field === col.column_name) - ); - - return ( -
- {/* 헤더 */} -
setIsExpanded(!isExpanded)} - > -
- {icon} - {label} - - {fields.length} - -
- {isExpanded ? : } -
- - {/* 설명 */} -

{description}

- - {isExpanded && ( -
- {/* 추가된 필드 목록 */} - {fields.length > 0 ? ( -
- {fields.map((field, idx) => ( -
- - - {field.caption || field.field} - - - {/* 데이터 영역일 때 집계 함수 선택 */} - {area === "data" && ( - - )} - - {/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */} - {(area === "row" || area === "column") && field.dataType === "date" && ( - - )} - - -
- ))} -
- ) : ( -
- 아래에서 컬럼을 선택하세요 -
- )} - - {/* 컬럼 추가 드롭다운 */} - {availableColumns.length > 0 && ( - - )} -
- )} -
- ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotGridConfigPanel: React.FC = ({ - config, - onChange, -}) => { - const [tables, setTables] = useState([]); - 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 { - const tableList = await tableTypeApi.getTables(); - const mappedTables: TableInfo[] = tableList.map((t: any) => ({ - table_name: t.table_name, - table_comment: t.table_label || t.display_name || t.table_name, - })); - setTables(mappedTables); - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - // 테이블 선택 시 컬럼 로드 - useEffect(() => { - const loadColumns = async () => { - if (!config.dataSource?.tableName) { - setColumns([]); - return; - } - - setLoadingColumns(true); - try { - const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); - const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ - column_name: c.column_name, - data_type: c.data_type || "text", - // 라벨 우선순위: display_name > comment > column_label > column_name - column_comment: c.display_name || c.comment || c.column_label || c.column_name, - })); - setColumns(mappedColumns); - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [config.dataSource?.tableName]); - - // 설정 업데이트 헬퍼 - const updateConfig = useCallback( - (updates: Partial) => { - onChange({ ...config, ...updates }); - }, - [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 && ( -
-
- - - {loadingColumns && (컬럼 로딩 중...)} -
- - {/* 사용 가능한 컬럼 목록 */} - {columns.length > 0 && ( -
- -
- {columns.map((col) => { - const isUsed = (config.fields || []).some(f => f.field === col.column_name); - return ( - {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}} - /> - ); - })} -
-
- )} - - {/* 영역별 드롭존 */} -
- } - 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-primary/20 bg-primary/10/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" - /> -
-
- )} - - {/* 고급 설정 토글 */} -
- -
- - {/* 고급 설정 */} - {showAdvanced && ( -
- {/* 표시 설정 */} -
- - -
-
- - - updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) - } - /> -
- -
- - - updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) - } - /> -
- -
- - -
- -
- - -
- -
- - - updateConfig({ totals: { ...config.totals, showRowTotals: v } }) - } - /> -
- -
- - - updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) - } - /> -
- -
- - - updateConfig({ style: { ...config.style, alternateRowColors: v } as any }) - } - /> -
- -
- - - updateConfig({ style: { ...config.style, mergeCells: v } as any }) - } - /> -
- -
- - - updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) - } - /> -
- -
- - - updateConfig({ saveState: v } as any) - } - /> -
-
-
- - {/* 크기 설정 */} -
- -
-
- - 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 } as any }); - }} - 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 } as any }); - }} - 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 } as any }); - }} - className="w-6 h-6 rounded cursor-pointer" - title="바 색상" - /> - )} - - {rule.type === "iconSet" && ( - - )} - - -
- ))} - - -
-
-
- )} -
- ); -}; - -export default PivotGridConfigPanel; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx deleted file mode 100644 index 0893f7b9..00000000 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ /dev/null @@ -1,384 +0,0 @@ -"use client"; - -import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import { PivotGridComponent } from "./PivotGridComponent"; -import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; -import { PivotFieldConfig } from "./types"; -import { dataApi } from "@/lib/api/data"; -import { AlertCircle, RefreshCw } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -// ==================== 에러 경계 ==================== - -interface ErrorBoundaryState { - hasError: boolean; - error?: Error; -} - -class PivotGridErrorBoundary extends Component< - { children: ReactNode; onReset?: () => void }, - ErrorBoundaryState -> { - constructor(props: { children: ReactNode; onReset?: () => void }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error("🔴 [PivotGrid] 렌더링 에러:", error); - console.error("🔴 [PivotGrid] 에러 정보:", errorInfo); - } - - handleReset = () => { - this.setState({ hasError: false, error: undefined }); - this.props.onReset?.(); - }; - - render() { - if (this.state.hasError) { - return ( -
- -

- 피벗 그리드 오류 -

-

- {this.state.error?.message || "알 수 없는 오류가 발생했습니다."} -

- -
- ); - } - - return this.props.children; - } -} - -// ==================== 샘플 데이터 (미리보기용) ==================== - -const SAMPLE_DATA = [ - { region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 }, - { region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 }, - { region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 }, - { region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 }, - { region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 }, - { region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 }, - { region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 }, - { region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 }, - { region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 }, - { region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 }, - { region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 }, - { region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 }, - { region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 }, - { region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 }, - { region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 }, - { region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 }, - { region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 }, - { region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 }, - { region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 }, - { region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 }, - { region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 }, - { region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 }, - { region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 }, - { region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 }, - { region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 }, - { region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 }, - { region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 }, - { region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 }, - { region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 }, - { region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 }, - { region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 }, - { region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 }, - { region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 }, - { region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 }, - { region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 }, - { region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 }, -]; - -const SAMPLE_FIELDS: PivotFieldConfig[] = [ - { - field: "region", - caption: "지역", - area: "row", - areaIndex: 0, - dataType: "string", - visible: true, - }, - { - field: "product", - caption: "제품", - area: "row", - areaIndex: 1, - dataType: "string", - visible: true, - }, - { - field: "quarter", - caption: "분기", - area: "column", - areaIndex: 0, - dataType: "string", - visible: true, - }, - { - field: "sales", - caption: "매출", - area: "data", - areaIndex: 0, - dataType: "number", - summaryType: "sum", - format: { type: "number", precision: 0 }, - visible: true, - }, -]; - -/** - * PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입) - */ -const PivotGridWrapper: React.FC = (props) => { - // 컴포넌트 설정에서 값 추출 - const componentConfig = props.componentConfig || props.config || {}; - const configFields = componentConfig.fields || props.fields; - const configData = props.data; - - // 🆕 테이블에서 데이터 자동 로딩 - const [loadedData, setLoadedData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const loadTableData = async () => { - const tableName = componentConfig.dataSource?.tableName; - - // 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음 - if (configData || !tableName || props.isDesignMode) { - return; - } - - setIsLoading(true); - try { - const response = await dataApi.getTableData(tableName, { - page: 1, - size: 10000, // 피벗 분석용 대량 데이터 - }); - - // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 - if (response.data && Array.isArray(response.data)) { - setLoadedData(response.data); - } else { - console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); - setLoadedData([]); - } - } catch (error) { - console.error("❌ [PivotGrid] 데이터 로딩 에러:", error); - } finally { - setIsLoading(false); - } - }; - - loadTableData(); - }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); - - // 디자인 모드 판단: - // 1. isDesignMode === true - // 2. isInteractive === false (편집 모드) - const isDesignMode = props.isDesignMode === true || props.isInteractive === false; - - // 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터 - const actualData = configData || loadedData; - const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0; - const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; - - // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 - const usePreviewData = isDesignMode || (!hasValidData && !isLoading); - - // 최종 데이터/필드 결정 - const finalData = usePreviewData ? SAMPLE_DATA : actualData; - const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; - const finalTitle = usePreviewData - ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" - : (componentConfig.title || props.title); - - // 총계 설정 - const totalsConfig = componentConfig.totals || props.totals || { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }; - - // 🆕 로딩 중 표시 - if (isLoading) { - return ( -
-
-
-

데이터 로딩 중...

-
-
- ); - } - - // 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함 - return ( - - - - ); -}; - -/** - * PivotGrid 컴포넌트 정의 - */ -const PivotGridDefinition = createComponentDefinition({ - id: "pivot-grid", - name: "피벗 그리드", - name_eng: "PivotGrid Component", - description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "text", - component: PivotGridWrapper, // 래퍼 컴포넌트 사용 - default_config: { - dataSource: { - type: "table", - tableName: "", - }, - fields: SAMPLE_FIELDS, - // 미리보기용 샘플 데이터 - sampleData: SAMPLE_DATA, - totals: { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }, - style: { - theme: "default", - headerStyle: "default", - cellPadding: "normal", - borderStyle: "light", - alternateRowColors: true, - highlightTotals: true, - }, - allowExpandAll: true, - exportConfig: { - excel: true, - }, - height: "400px", - }, - default_size: { width: 800, height: 500 }, - config_panel: PivotGridConfigPanel, - icon: "BarChart3", - tags: ["피벗", "분석", "집계", "그리드", "데이터"], - version: "1.0.0", - author: "개발팀", - documentation: "", - // hidden: true, // v2-pivot-grid 사용으로 패널에서 숨김 -}); - -/** - * PivotGrid 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class PivotGridRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = PivotGridDefinition; - - render(): React.ReactElement { - const props = this.props as any; - - // 컴포넌트 설정에서 값 추출 - const componentConfig = props.componentConfig || props.config || {}; - const configFields = componentConfig.fields || props.fields; - const configData = props.data; - - // 디자인 모드 판단: - // 1. isDesignMode === true - // 2. isInteractive === false (편집 모드) - // 3. 데이터가 없는 경우 - const isDesignMode = props.isDesignMode === true || props.isInteractive === false; - const hasValidData = configData && Array.isArray(configData) && configData.length > 0; - const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; - - // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 - const usePreviewData = isDesignMode || !hasValidData; - - // 최종 데이터/필드 결정 - const finalData = usePreviewData ? SAMPLE_DATA : configData; - const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; - const finalTitle = usePreviewData - ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" - : (componentConfig.title || props.title); - - // 총계 설정 - const totalsConfig = componentConfig.totals || props.totals || { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }; - - return ( - - ); - } -} - -// 자동 등록 실행 -PivotGridRenderer.registerSelf(); - -// 강제 등록 (디버깅용) -if (typeof window !== "undefined") { - setTimeout(() => { - try { - PivotGridRenderer.registerSelf(); - } catch (error) { - console.error("❌ PivotGrid 강제 등록 실패:", error); - } - }, 1000); -} diff --git a/frontend/lib/registry/components/pivot-grid/README.md b/frontend/lib/registry/components/pivot-grid/README.md deleted file mode 100644 index bc6fba52..00000000 --- a/frontend/lib/registry/components/pivot-grid/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# PivotGrid 컴포넌트 - -다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다. - -## 주요 기능 - -### 1. 다차원 데이터 배치 - -- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시) -- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기) -- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량) -- **필터 영역(Filter Area)**: 전체 데이터 필터링 - -### 2. 집계 함수 - -| 함수 | 설명 | 사용 예 | -|------|------|---------| -| `sum` | 합계 | 매출 합계 | -| `count` | 개수 | 건수 | -| `avg` | 평균 | 평균 단가 | -| `min` | 최소값 | 최저가 | -| `max` | 최대값 | 최고가 | -| `countDistinct` | 고유값 개수 | 거래처 수 | - -### 3. 날짜 그룹화 - -날짜 필드를 다양한 단위로 그룹화할 수 있습니다: - -- `year`: 연도별 -- `quarter`: 분기별 -- `month`: 월별 -- `week`: 주별 -- `day`: 일별 - -### 4. 드릴다운 - -계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다. - -### 5. 총합계/소계 - -- 행 총합계 (Row Grand Total) -- 열 총합계 (Column Grand Total) -- 행 소계 (Row Subtotal) -- 열 소계 (Column Subtotal) - -### 6. 내보내기 - -CSV 형식으로 데이터를 내보낼 수 있습니다. - -## 사용법 - -### 기본 사용 - -```tsx -import { PivotGridComponent } from "@/lib/registry/components/pivot-grid"; - -const salesData = [ - { region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 }, - { region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 }, - // ... -]; - - -``` - -### 날짜 그룹화 - -```tsx - -``` - -### 포맷 설정 - -```tsx - -``` - -### 화면 관리에서 사용 - -설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다. - -```tsx -import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; - - -``` - -## 설정 옵션 - -### PivotGridProps - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `title` | `string` | - | 피벗 테이블 제목 | -| `data` | `any[]` | `[]` | 원본 데이터 배열 | -| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 | -| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 | -| `style` | `PivotStyleConfig` | - | 스타일 설정 | -| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 | -| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 | -| `height` | `string | number` | `"auto"` | 높이 | -| `maxHeight` | `string` | - | 최대 높이 | - -### PivotFieldConfig - -| 속성 | 타입 | 필수 | 설명 | -|------|------|------|------| -| `field` | `string` | O | 데이터 필드명 | -| `caption` | `string` | O | 표시 라벨 | -| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 | -| `areaIndex` | `number` | - | 영역 내 순서 | -| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 | -| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) | -| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 | -| `format` | `PivotFieldFormat` | - | 값 포맷 | -| `visible` | `boolean` | - | 표시 여부 | - -### PivotTotalsConfig - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 | -| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 | -| `showRowTotals` | `boolean` | `true` | 행 소계 표시 | -| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 | - -## 파일 구조 - -``` -pivot-grid/ -├── index.ts # 모듈 진입점 -├── types.ts # 타입 정의 -├── PivotGridComponent.tsx # 메인 컴포넌트 -├── PivotGridRenderer.tsx # 화면 관리 렌더러 -├── PivotGridConfigPanel.tsx # 설정 패널 -├── README.md # 문서 -└── utils/ - ├── index.ts # 유틸리티 모듈 진입점 - ├── aggregation.ts # 집계 함수 - └── pivotEngine.ts # 피벗 데이터 처리 엔진 -``` - -## 사용 시나리오 - -### 1. 매출 분석 - -지역별/기간별/제품별 매출 현황을 분석합니다. - -### 2. 재고 현황 - -창고별/품목별 재고 수량을 한눈에 파악합니다. - -### 3. 생산 실적 - -생산라인별/일자별 생산량을 분석합니다. - -### 4. 비용 분석 - -부서별/계정별 비용을 집계하여 분석합니다. - -### 5. 수주 현황 - -거래처별/품목별/월별 수주 현황을 분석합니다. - -## 주의사항 - -1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요. -2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다. -3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요. - - diff --git a/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx deleted file mode 100644 index 1dac623b..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/ContextMenu.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"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/DrillDownModal.tsx b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx deleted file mode 100644 index 994d782f..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client"; - -/** - * DrillDownModal 컴포넌트 - * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotCellData, PivotFieldConfig } from "../types"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Search, - Download, - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - ArrowUpDown, - ArrowUp, - ArrowDown, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface DrillDownModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - cellData: PivotCellData | null; - data: any[]; // 전체 원본 데이터 - fields: PivotFieldConfig[]; - rowFields: PivotFieldConfig[]; - columnFields: PivotFieldConfig[]; -} - -interface SortConfig { - field: string; - direction: "asc" | "desc"; -} - -// ==================== 메인 컴포넌트 ==================== - -export const DrillDownModal: React.FC = ({ - open, - onOpenChange, - cellData, - data, - fields, - rowFields, - columnFields, -}) => { - const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [sortConfig, setSortConfig] = useState(null); - - // 드릴다운 데이터 필터링 - const filteredData = useMemo(() => { - if (!cellData || !data) return []; - - // 행/열 경로에 해당하는 데이터 필터링 - let result = data.filter((row) => { - // 행 경로 매칭 - for (let i = 0; i < cellData.rowPath.length; i++) { - const field = rowFields[i]; - if (field && String(row[field.field]) !== cellData.rowPath[i]) { - return false; - } - } - - // 열 경로 매칭 - for (let i = 0; i < cellData.columnPath.length; i++) { - const field = columnFields[i]; - if (field && String(row[field.field]) !== cellData.columnPath[i]) { - return false; - } - } - - return true; - }); - - // 검색 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter((row) => - Object.values(row).some((val) => - String(val).toLowerCase().includes(query) - ) - ); - } - - // 정렬 - if (sortConfig) { - result = [...result].sort((a, b) => { - const aVal = a[sortConfig.field]; - const bVal = b[sortConfig.field]; - - if (aVal === null || aVal === undefined) return 1; - if (bVal === null || bVal === undefined) return -1; - - let comparison = 0; - if (typeof aVal === "number" && typeof bVal === "number") { - comparison = aVal - bVal; - } else { - comparison = String(aVal).localeCompare(String(bVal)); - } - - return sortConfig.direction === "asc" ? comparison : -comparison; - }); - } - - return result; - }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); - - // 페이지네이션 - const totalPages = Math.ceil(filteredData.length / pageSize); - const paginatedData = useMemo(() => { - const start = (currentPage - 1) * pageSize; - return filteredData.slice(start, start + pageSize); - }, [filteredData, currentPage, pageSize]); - - // 표시할 컬럼 결정 - const displayColumns = useMemo(() => { - // 모든 필드의 field명 수집 - const fieldNames = new Set(); - - // fields에서 가져오기 - fields.forEach((f) => fieldNames.add(f.field)); - - // 데이터에서 추가 컬럼 가져오기 - if (data.length > 0) { - Object.keys(data[0]).forEach((key) => fieldNames.add(key)); - } - - return Array.from(fieldNames).map((fieldName) => { - const fieldConfig = fields.find((f) => f.field === fieldName); - return { - field: fieldName, - caption: fieldConfig?.caption || fieldName, - dataType: fieldConfig?.dataType || "string", - }; - }); - }, [fields, data]); - - // 정렬 토글 - const handleSort = (field: string) => { - setSortConfig((prev) => { - if (!prev || prev.field !== field) { - return { field, direction: "asc" }; - } - if (prev.direction === "asc") { - return { field, direction: "desc" }; - } - return null; - }); - }; - - // CSV 내보내기 - const handleExportCSV = () => { - if (filteredData.length === 0) return; - - const headers = displayColumns.map((c) => c.caption); - const rows = filteredData.map((row) => - displayColumns.map((c) => { - const val = row[c.field]; - if (val === null || val === undefined) return ""; - if (typeof val === "string" && val.includes(",")) { - return `"${val}"`; - } - return String(val); - }) - ); - - const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); - - const blob = new Blob(["\uFEFF" + csv], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; - link.click(); - }; - - // 페이지 변경 - const goToPage = (page: number) => { - setCurrentPage(Math.max(1, Math.min(page, totalPages))); - }; - - // 경로 표시 - const pathDisplay = cellData - ? [ - ...(cellData.rowPath.length > 0 - ? [`행: ${cellData.rowPath.join(" > ")}`] - : []), - ...(cellData.columnPath.length > 0 - ? [`열: ${cellData.columnPath.join(" > ")}`] - : []), - ].join(" | ") - : ""; - - return ( - - - - 상세 데이터 - - {pathDisplay || "선택한 셀의 원본 데이터"} - - ({filteredData.length}건) - - - - - {/* 툴바 */} -
-
- - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="pl-9 h-9" - /> -
- - - - -
- - {/* 테이블 */} - -
- - - - {displayColumns.map((col) => ( - handleSort(col.field)} - > -
- {col.caption} - {sortConfig?.field === col.field ? ( - sortConfig.direction === "asc" ? ( - - ) : ( - - ) - ) : ( - - )} -
-
- ))} -
-
- - {paginatedData.length === 0 ? ( - - - 데이터가 없습니다. - - - ) : ( - paginatedData.map((row, idx) => ( - - {displayColumns.map((col) => ( - - {formatCellValue(row[col.field], col.dataType)} - - ))} - - )) - )} - -
-
-
- - {/* 페이지네이션 */} - {totalPages > 1 && ( -
-
- {(currentPage - 1) * pageSize + 1} -{" "} - {Math.min(currentPage * pageSize, filteredData.length)} /{" "} - {filteredData.length}건 -
- -
- - - - - {currentPage} / {totalPages} - - - - -
-
- )} -
-
- ); -}; - -// ==================== 유틸리티 ==================== - -function formatCellValue(value: any, dataType: string): string { - if (value === null || value === undefined) return "-"; - - if (dataType === "number") { - const num = Number(value); - if (isNaN(num)) return String(value); - return num.toLocaleString(); - } - - if (dataType === "date") { - try { - const date = new Date(value); - if (!isNaN(date.getTime())) { - return date.toLocaleDateString("ko-KR"); - } - } catch { - // 변환 실패 시 원본 반환 - } - } - - return String(value); -} - -export default DrillDownModal; - diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx deleted file mode 100644 index fba64e65..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ /dev/null @@ -1,448 +0,0 @@ -"use client"; - -/** - * FieldChooser 컴포넌트 - * 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Search, - Filter, - Columns, - Rows, - BarChart3, - GripVertical, - Plus, - Minus, - Type, - Hash, - Calendar, - ToggleLeft, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface AvailableField { - field: string; - caption: string; - dataType: "string" | "number" | "date" | "boolean"; - isSelected: boolean; - currentArea?: PivotAreaType; -} - -interface FieldChooserProps { - open: boolean; - onOpenChange: (open: boolean) => void; - availableFields: AvailableField[]; - selectedFields: PivotFieldConfig[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; -} - -// ==================== 영역 설정 ==================== - -const AREA_OPTIONS: { - value: PivotAreaType | "none"; - label: string; - icon: React.ReactNode; -}[] = [ - { value: "none", label: "사용 안함", icon: }, - { value: "filter", label: "필터", icon: }, - { value: "row", label: "행", icon: }, - { value: "column", label: "열", icon: }, - { value: "data", label: "데이터", icon: }, -]; - -const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [ - { value: "sum", label: "합계" }, - { value: "count", label: "개수" }, - { value: "avg", label: "평균" }, - { value: "min", label: "최소" }, - { value: "max", label: "최대" }, - { value: "countDistinct", label: "고유 개수" }, -]; - -const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ - { value: "absoluteValue", label: "절대값" }, - { value: "percentOfRowTotal", label: "행 총계 %" }, - { value: "percentOfColumnTotal", label: "열 총계 %" }, - { value: "percentOfGrandTotal", label: "전체 총계 %" }, - { value: "runningTotalByRow", label: "행 누계" }, - { value: "runningTotalByColumn", label: "열 누계" }, - { value: "differenceFromPrevious", label: "이전 대비 차이" }, - { 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: , - date: , - boolean: , -}; - -// ==================== 필드 아이템 ==================== - -interface FieldItemProps { - field: AvailableField; - config?: PivotFieldConfig; - onAreaChange: (area: PivotAreaType | "none") => void; - onSummaryChange?: (summary: AggregationType) => void; - onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; -} - -const FieldItem: React.FC = ({ - field, - config, - onAreaChange, - onSummaryChange, - onDisplayModeChange, -}) => { - const currentArea = config?.area || "none"; - const isSelected = currentArea !== "none"; - - return ( -
- {/* 데이터 타입 아이콘 */} -
- {DATA_TYPE_ICONS[field.dataType] || } -
- - {/* 필드명 */} -
-
{field.caption}
-
- {field.field} -
-
- - {/* 영역 선택 */} - - - {/* 집계 함수 선택 (데이터 영역인 경우) */} - {currentArea === "data" && onSummaryChange && ( - - )} - - {/* 표시 모드 선택 (데이터 영역인 경우) */} - {currentArea === "data" && onDisplayModeChange && ( - - )} -
- ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const FieldChooser: React.FC = ({ - open, - onOpenChange, - availableFields, - selectedFields, - onFieldsChange, -}) => { - const [searchQuery, setSearchQuery] = useState(""); - const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( - "all" - ); - - // 필터링된 필드 목록 - const filteredFields = useMemo(() => { - let result = availableFields; - - // 검색어 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (f) => - f.caption.toLowerCase().includes(query) || - f.field.toLowerCase().includes(query) - ); - } - - // 선택 상태 필터 - if (filterType === "selected") { - result = result.filter((f) => - selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) - ); - } else if (filterType === "unselected") { - result = result.filter( - (f) => - !selectedFields.some( - (sf) => sf.field === f.field && sf.visible !== false - ) - ); - } - - return result; - }, [availableFields, selectedFields, searchQuery, filterType]); - - // 필드 영역 변경 - const handleAreaChange = ( - field: AvailableField, - area: PivotAreaType | "none" - ) => { - const existingConfig = selectedFields.find((f) => f.field === field.field); - - if (area === "none") { - // 필드 완전 제거 (visible: false 대신 배열에서 제거) - if (existingConfig) { - const newFields = selectedFields.filter((f) => f.field !== field.field); - onFieldsChange(newFields); - } - } else { - // 필드 추가 또는 영역 변경 - if (existingConfig) { - const newFields = selectedFields.map((f) => - f.field === field.field - ? { ...f, area, visible: true } - : f - ); - onFieldsChange(newFields); - } else { - // 새 필드 추가 - const newField: PivotFieldConfig = { - field: field.field, - caption: field.caption, - area, - dataType: field.dataType, - visible: true, - summaryType: area === "data" ? "sum" : undefined, - areaIndex: selectedFields.filter((f) => f.area === area).length, - }; - onFieldsChange([...selectedFields, newField]); - } - } - }; - - // 집계 함수 변경 - const handleSummaryChange = ( - field: AvailableField, - summaryType: AggregationType - ) => { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, summaryType } : f - ); - onFieldsChange(newFields); - }; - - // 표시 모드 변경 - const handleDisplayModeChange = ( - field: AvailableField, - displayMode: SummaryDisplayMode - ) => { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f - ); - onFieldsChange(newFields); - }; - - // 모든 필드 선택 해제 - const handleClearAll = () => { - const newFields = selectedFields.map((f) => ({ ...f, visible: false })); - onFieldsChange(newFields); - }; - - // 통계 - const stats = useMemo(() => { - const visible = selectedFields.filter((f) => f.visible !== false); - return { - total: availableFields.length, - selected: visible.length, - filter: visible.filter((f) => f.area === "filter").length, - row: visible.filter((f) => f.area === "row").length, - column: visible.filter((f) => f.area === "column").length, - data: visible.filter((f) => f.area === "data").length, - }; - }, [availableFields, selectedFields]); - - return ( - - - - 필드 선택기 - - 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. - - - - {/* 통계 */} -
- 전체: {stats.total} - - 선택됨: {stats.selected} - - 필터: {stats.filter} - 행: {stats.row} - 열: {stats.column} - 데이터: {stats.data} -
- - {/* 검색 및 필터 */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9" - /> -
- - - - -
- - {/* 필드 목록 */} - -
- {filteredFields.length === 0 ? ( -
- 검색 결과가 없습니다. -
- ) : ( - filteredFields.map((field) => { - const config = selectedFields.find( - (f) => f.field === field.field && f.visible !== false - ); - return ( - handleAreaChange(field, area)} - onSummaryChange={ - config?.area === "data" - ? (summary) => handleSummaryChange(field, summary) - : undefined - } - onDisplayModeChange={ - config?.area === "data" - ? (mode) => handleDisplayModeChange(field, mode) - : undefined - } - /> - ); - }) - )} -
-
- - {/* 푸터 */} -
- -
-
-
- ); -}; - -export default FieldChooser; - diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx deleted file mode 100644 index b2bbef5e..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ /dev/null @@ -1,828 +0,0 @@ -"use client"; - -/** - * FieldPanel 컴포넌트 - * 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터) - * 드래그 앤 드롭으로 필드 재배치 가능 - */ - -import React, { useState } from "react"; -import { - DndContext, - DragOverlay, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragStartEvent, - DragEndEvent, - DragOverEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - horizontalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { useDroppable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig, PivotAreaType } from "../types"; -import { - X, - Filter, - Columns, - Rows, - BarChart3, - GripVertical, - ChevronDown, - RotateCcw, - FilterX, - LayoutGrid, - Trash2, - Calendar, - Check, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -// ==================== 타입 ==================== - -interface FieldPanelProps { - fields: PivotFieldConfig[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; - onFieldRemove?: (field: PivotFieldConfig) => void; - onFieldSettingsChange?: (field: PivotFieldConfig) => void; - collapsed?: boolean; - onToggleCollapse?: () => void; - /** 초기 필드 설정 (필드 배치 초기화용) */ - initialFields?: PivotFieldConfig[]; -} - -interface FieldChipProps { - field: PivotFieldConfig; - onRemove: () => void; - onSettingsChange?: (field: PivotFieldConfig) => void; -} - -interface DroppableAreaProps { - area: PivotAreaType; - fields: PivotFieldConfig[]; - title: string; - icon: React.ReactNode; - onFieldRemove: (field: PivotFieldConfig) => void; - onFieldSettingsChange?: (field: PivotFieldConfig) => void; - isOver?: boolean; -} - -// ==================== 영역 설정 ==================== - -const AREA_CONFIG: Record< - PivotAreaType, - { title: string; icon: React.ReactNode; color: string } -> = { - filter: { - title: "필터", - icon: , - color: "bg-amber-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", - }, - column: { - title: "열", - icon: , - color: "bg-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30", - }, - row: { - title: "행", - icon: , - color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800", - }, - data: { - title: "데이터", - icon: , - color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", - }, -}; - -// ==================== 필드 칩 (드래그 가능) ==================== - -const SortableFieldChip: React.FC = ({ - field, - onRemove, - onSettingsChange, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: `${field.area}-${field.field}` }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - // 필터 적용 여부 확인 - const hasFilter = field.filterValues && field.filterValues.length > 0; - const filterCount = field.filterValues?.length || 0; - - // 그룹화 상태 확인 - const hasGrouping = field.groupInterval && field.dataType === "date"; - const groupLabels: Record = { - year: "연도", - quarter: "분기", - month: "월", - week: "주", - day: "일", - }; - - return ( -
- {/* 드래그 핸들 */} - - - {/* 필터 아이콘 (필터 적용 시) */} - {hasFilter && ( - - )} - - {/* 필드 라벨 */} - - - - - - {field.area === "data" && ( - <> - - onSettingsChange?.({ ...field, summaryType: "sum" }) - } - > - 합계 - - - onSettingsChange?.({ ...field, summaryType: "count" }) - } - > - 개수 - - - onSettingsChange?.({ ...field, summaryType: "avg" }) - } - > - 평균 - - - onSettingsChange?.({ ...field, summaryType: "min" }) - } - > - 최소 - - - onSettingsChange?.({ ...field, summaryType: "max" }) - } - > - 최대 - - - - )} - {/* 날짜 그룹화 옵션 (행/열 영역의 날짜 타입 필드만) */} - {(field.area === "row" || field.area === "column") && - field.dataType === "date" && ( - <> -
- - 날짜 그룹화 -
- onSettingsChange?.({ ...field, groupInterval: undefined })} - className="pl-6" - > - {!field.groupInterval && } - 그룹화 없음 - - onSettingsChange?.({ ...field, groupInterval: "year" })} - className="pl-6" - > - {field.groupInterval === "year" && } - 연도별 - - onSettingsChange?.({ ...field, groupInterval: "quarter" })} - className="pl-6" - > - {field.groupInterval === "quarter" && } - 분기별 - - onSettingsChange?.({ ...field, groupInterval: "month" })} - className="pl-6" - > - {field.groupInterval === "month" && } - 월별 - - onSettingsChange?.({ ...field, groupInterval: "week" })} - className="pl-6" - > - {field.groupInterval === "week" && } - 주별 - - onSettingsChange?.({ ...field, groupInterval: "day" })} - className="pl-6" - > - {field.groupInterval === "day" && } - 일별 - - - - )} - - onSettingsChange?.({ - ...field, - sortOrder: field.sortOrder === "asc" ? "desc" : "asc", - }) - } - > - {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} - - - {/* 필터 초기화 (필터가 적용된 경우에만 표시) */} - {hasFilter && ( - <> - onSettingsChange?.({ ...field, filterValues: [] })} - className="text-amber-600" - > - - 필터 초기화 ({filterCount}개 선택됨) - - - - )} - onSettingsChange?.({ ...field, visible: false })} - > - 필드 숨기기 - -
-
- - {/* 삭제 버튼 */} - -
- ); -}; - -// ==================== 드롭 영역 ==================== - -const DroppableArea: React.FC = ({ - area, - fields, - title, - icon, - onFieldRemove, - onFieldSettingsChange, - isOver, -}) => { - const config = AREA_CONFIG[area]; - const areaFields = fields.filter((f) => f.area === area && f.visible !== false); - const fieldIds = areaFields.map((f) => `${area}-${f.field}`); - - // 🆕 드롭 가능 영역 설정 - const { setNodeRef, isOver: isOverDroppable } = useDroppable({ - id: area, // "filter", "column", "row", "data" - }); - - const finalIsOver = isOver || isOverDroppable; - - return ( -
- {/* 영역 헤더 */} -
- {icon} - {title} - {areaFields.length > 0 && ( - - {areaFields.length} - - )} -
- - {/* 필드 목록 */} - -
- {areaFields.length === 0 ? ( -
- - ← 필드를 여기로 드래그하세요 - -
- ) : ( - areaFields.map((field) => ( - onFieldRemove(field)} - onSettingsChange={onFieldSettingsChange} - /> - )) - )} -
-
-
- ); -}; - -// ==================== 유틸리티 ==================== - -function getSummaryLabel(type: string): string { - const labels: Record = { - sum: "합계", - count: "개수", - avg: "평균", - min: "최소", - max: "최대", - countDistinct: "고유", - }; - return labels[type] || type; -} - -// ==================== 메인 컴포넌트 ==================== - -export const FieldPanel: React.FC = ({ - fields, - onFieldsChange, - onFieldRemove, - onFieldSettingsChange, - collapsed = false, - onToggleCollapse, - initialFields, -}) => { - const [activeId, setActiveId] = useState(null); - const [overArea, setOverArea] = useState(null); - - // 필터만 초기화 - const handleResetFilters = () => { - const newFields = fields.map((f) => ({ - ...f, - filterValues: [], - filterType: "include" as const, - })); - onFieldsChange(newFields); - }; - - // 필드 배치 초기화 (initialFields가 있으면 사용, 없으면 모든 필드를 row로) - const handleResetLayout = () => { - if (initialFields && initialFields.length > 0) { - // initialFields의 영역 배치를 복원하되 현재 필터 값은 유지 - const newFields = fields.map((f) => { - const initial = initialFields.find((i) => i.field === f.field); - if (initial) { - return { - ...f, - area: initial.area, - areaIndex: initial.areaIndex, - }; - } - return f; - }); - onFieldsChange(newFields); - } else { - // 기본값: 숫자는 data, 나머지는 row로 - const newFields = fields.map((f, idx) => ({ - ...f, - area: f.dataType === "number" ? "data" : "row" as PivotAreaType, - areaIndex: idx, - visible: true, - })); - onFieldsChange(newFields); - } - }; - - // 전체 초기화 (필드 배치 + 필터) - const handleResetAll = () => { - if (initialFields && initialFields.length > 0) { - // initialFields로 완전히 복원 - onFieldsChange([...initialFields]); - } else { - // 기본값으로 초기화 - const newFields = fields.map((f, idx) => ({ - ...f, - area: f.dataType === "number" ? "data" : "row" as PivotAreaType, - areaIndex: idx, - visible: true, - filterValues: [], - filterType: "include" as const, - })); - onFieldsChange(newFields); - } - }; - - // 필터가 적용된 필드 개수 - const filteredFieldCount = fields.filter( - (f) => f.filterValues && f.filterValues.length > 0 - ).length; - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - // 드래그 시작 - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as string); - }; - - // 드래그 오버 - const handleDragOver = (event: DragOverEvent) => { - const { over } = event; - if (!over) { - setOverArea(null); - return; - } - - // 드롭 영역 감지 (영역 자체의 ID를 우선 확인) - const overId = over.id as string; - - // 1. overId가 영역 자체인 경우 (filter, column, row, data) - if (["filter", "column", "row", "data"].includes(overId)) { - setOverArea(overId as PivotAreaType); - return; - } - - // 2. overId가 필드인 경우 (예: row-part_name) - const targetArea = overId.split("-")[0] as PivotAreaType; - if (["filter", "column", "row", "data"].includes(targetArea)) { - setOverArea(targetArea); - } - }; - - // 드래그 종료 - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장 - setActiveId(null); - setOverArea(null); - - if (!over) { - return; - } - - const activeId = active.id as string; - const overId = over.id as string; - - // 필드 정보 파싱 - const [sourceArea, sourceField] = activeId.split("-") as [ - PivotAreaType, - string - ]; - - // targetArea 결정: handleDragOver에서 감지한 영역 우선 사용 - let targetArea: PivotAreaType; - if (currentOverArea) { - targetArea = currentOverArea; - } else if (["filter", "column", "row", "data"].includes(overId)) { - targetArea = overId as PivotAreaType; - } else { - targetArea = overId.split("-")[0] as PivotAreaType; - } - - // 같은 영역 내 정렬 - if (sourceArea === targetArea) { - const areaFields = fields.filter((f) => f.area === sourceArea); - const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); - const targetIndex = areaFields.findIndex( - (f) => `${f.area}-${f.field}` === overId - ); - - if (sourceIndex !== targetIndex && targetIndex >= 0) { - // 순서 변경 - const newFields = [...fields]; - const fieldToMove = newFields.find( - (f) => f.field === sourceField && f.area === sourceArea - ); - if (fieldToMove) { - fieldToMove.areaIndex = targetIndex; - // 다른 필드들 인덱스 조정 - newFields - .filter((f) => f.area === sourceArea && f.field !== sourceField) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) - .forEach((f, idx) => { - f.areaIndex = idx >= targetIndex ? idx + 1 : idx; - }); - } - onFieldsChange(newFields); - } - return; - } - - // 다른 영역으로 이동 - if (["filter", "column", "row", "data"].includes(targetArea)) { - const newFields = fields.map((f) => { - if (f.field === sourceField && f.area === sourceArea) { - return { - ...f, - area: targetArea as PivotAreaType, - areaIndex: fields.filter((ff) => ff.area === targetArea).length, - }; - } - return f; - }); - - onFieldsChange(newFields); - } - }; - - // 필드 제거 - const handleFieldRemove = (field: PivotFieldConfig) => { - if (onFieldRemove) { - onFieldRemove(field); - } else { - // 기본 동작: visible을 false로 설정 - const newFields = fields.map((f) => - f.field === field.field && f.area === field.area - ? { ...f, visible: false } - : f - ); - onFieldsChange(newFields); - } - }; - - // 필드 설정 변경 - const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { - if (onFieldSettingsChange) { - onFieldSettingsChange(updatedField); - } - const newFields = fields.map((f) => - f.field === updatedField.field && f.area === updatedField.area - ? updatedField - : f - ); - onFieldsChange(newFields); - }; - - // 활성 필드 찾기 (드래그 중인 필드) - const activeField = activeId - ? 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} - -
- -
- ); - } - - return ( - -
- {/* 4개 영역 배치: 2x2 그리드 */} -
- {/* 필터 영역 */} - - - {/* 열 영역 */} - - - {/* 행 영역 */} - - - {/* 데이터 영역 */} - -
- - {/* 하단 버튼 영역 */} -
- {/* 초기화 드롭다운 */} - - - - - - - - 필터만 초기화 - {filteredFieldCount > 0 && ( - - ({filteredFieldCount}개) - - )} - - - - 필드 배치 초기화 - - - - - 전체 초기화 - - - - - {/* 접기 버튼 */} - {onToggleCollapse && ( - - )} -
-
- - {/* 드래그 오버레이 */} - - {activeField ? ( -
- - {activeField.caption} -
- ) : null} -
-
- ); -}; - -export default FieldPanel; - diff --git a/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx deleted file mode 100644 index e3185f5a..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx +++ /dev/null @@ -1,265 +0,0 @@ -"use client"; - -/** - * FilterPopup 컴포넌트 - * 피벗 필드의 값을 필터링하는 팝업 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig } from "../types"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Label } from "@/components/ui/label"; -import { - Search, - Filter, - Check, - X, - CheckSquare, - Square, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface FilterPopupProps { - field: PivotFieldConfig; - data: any[]; - onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; - trigger?: React.ReactNode; -} - -// ==================== 메인 컴포넌트 ==================== - -export const FilterPopup: React.FC = ({ - field, - data, - onFilterChange, - trigger, -}) => { - const [open, setOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedValues, setSelectedValues] = useState>( - new Set(field.filterValues || []) - ); - const [filterType, setFilterType] = useState<"include" | "exclude">( - field.filterType || "include" - ); - - // 고유 값 추출 - const uniqueValues = useMemo(() => { - const values = new Set(); - data.forEach((row) => { - const value = row[field.field]; - if (value !== null && value !== undefined) { - values.add(value); - } - }); - return Array.from(values).sort((a, b) => { - if (typeof a === "number" && typeof b === "number") return a - b; - return String(a).localeCompare(String(b), "ko"); - }); - }, [data, field.field]); - - // 필터링된 값 목록 - const filteredValues = useMemo(() => { - if (!searchQuery) return uniqueValues; - const query = searchQuery.toLowerCase(); - return uniqueValues.filter((val) => - String(val).toLowerCase().includes(query) - ); - }, [uniqueValues, searchQuery]); - - // 값 토글 - const handleValueToggle = (value: any) => { - const newSelected = new Set(selectedValues); - if (newSelected.has(value)) { - newSelected.delete(value); - } else { - newSelected.add(value); - } - setSelectedValues(newSelected); - }; - - // 모두 선택 - const handleSelectAll = () => { - setSelectedValues(new Set(filteredValues)); - }; - - // 모두 해제 - const handleClearAll = () => { - setSelectedValues(new Set()); - }; - - // 적용 - const handleApply = () => { - onFilterChange(field, Array.from(selectedValues), filterType); - setOpen(false); - }; - - // 초기화 - const handleReset = () => { - setSelectedValues(new Set()); - setFilterType("include"); - onFilterChange(field, [], "include"); - setOpen(false); - }; - - // 필터 활성 상태 - const isFilterActive = field.filterValues && field.filterValues.length > 0; - - // 선택된 항목 수 - const selectedCount = selectedValues.size; - const totalCount = uniqueValues.length; - - return ( - - - {trigger || ( - - )} - - -
-
- {field.caption} 필터 -
- - -
-
- - {/* 검색 */} -
- - setSearchQuery(e.target.value)} - className="pl-8 h-8 text-sm" - /> -
- - {/* 전체 선택/해제 */} -
- - {selectedCount} / {totalCount} 선택됨 - -
- - -
-
-
- - {/* 값 목록 */} - -
- {filteredValues.length === 0 ? ( -
- 결과가 없습니다 -
- ) : ( - filteredValues.map((value) => ( - - )) - )} -
-
- - {/* 버튼 */} -
- -
- - -
-
-
-
- ); -}; - -export default FilterPopup; - diff --git a/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx deleted file mode 100644 index c8faa33f..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx +++ /dev/null @@ -1,386 +0,0 @@ -"use client"; - -/** - * PivotChart 컴포넌트 - * 피벗 데이터를 차트로 시각화 - */ - -import React, { useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types"; -import { pathToKey } from "../utils/pivotEngine"; -import { - BarChart, - Bar, - LineChart, - Line, - AreaChart, - Area, - PieChart, - Pie, - Cell, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from "recharts"; - -// ==================== 타입 ==================== - -interface PivotChartProps { - pivotResult: PivotResult; - config: PivotChartConfig; - dataFields: PivotFieldConfig[]; - className?: string; -} - -// ==================== 색상 ==================== - -const COLORS = [ - "#4472C4", // 파랑 - "#ED7D31", // 주황 - "#A5A5A5", // 회색 - "#FFC000", // 노랑 - "#5B9BD5", // 하늘 - "#70AD47", // 초록 - "#264478", // 진한 파랑 - "#9E480E", // 진한 주황 - "#636363", // 진한 회색 - "#997300", // 진한 노랑 -]; - -// ==================== 데이터 변환 ==================== - -function transformDataForChart( - pivotResult: PivotResult, - dataFields: PivotFieldConfig[] -): any[] { - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; - - // 행 기준 차트 데이터 생성 - return flatRows.map((row) => { - const dataPoint: any = { - name: row.caption, - path: row.path, - }; - - // 각 열에 대한 데이터 추가 - flatColumns.forEach((col) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey); - - if (values && values.length > 0) { - const columnName = col.caption || "전체"; - dataPoint[columnName] = values[0].value; - } - }); - - // 총계 추가 - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - if (rowTotal && rowTotal.length > 0) { - dataPoint["총계"] = rowTotal[0].value; - } - - return dataPoint; - }); -} - -function transformDataForPie( - pivotResult: PivotResult, - dataFields: PivotFieldConfig[] -): any[] { - const { flatRows, grandTotals } = pivotResult; - - return flatRows.map((row, idx) => { - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - return { - name: row.caption, - value: rowTotal?.[0]?.value || 0, - color: COLORS[idx % COLORS.length], - }; - }); -} - -// ==================== 차트 컴포넌트 ==================== - -const CustomTooltip: React.FC = ({ active, payload, label }) => { - if (!active || !payload || !payload.length) return null; - - return ( -
-

{label}

- {payload.map((entry: any, idx: number) => ( -

- {entry.name}: {entry.value?.toLocaleString()} -

- ))} -
- ); -}; - -// 막대 차트 -const PivotBarChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; - stacked?: boolean; -}> = ({ data, columns, height, showLegend, stacked }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 선 차트 -const PivotLineChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; -}> = ({ data, columns, height, showLegend }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 영역 차트 -const PivotAreaChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; -}> = ({ data, columns, height, showLegend }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 파이 차트 -const PivotPieChart: React.FC<{ - data: any[]; - height: number; - showLegend: boolean; -}> = ({ data, height, showLegend }) => { - return ( - - - - `${name} (${(percent * 100).toFixed(1)}%)` - ) as any} - labelLine - > - {data.map((entry, idx) => ( - - ))} - - } /> - {showLegend && ( - - )} - - - ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotChart: React.FC = ({ - pivotResult, - config, - dataFields, - className, -}) => { - // 차트 데이터 변환 - const chartData = useMemo(() => { - if (config.type === "pie") { - return transformDataForPie(pivotResult, dataFields); - } - return transformDataForChart(pivotResult, dataFields); - }, [pivotResult, dataFields, config.type]); - - // 열 이름 목록 (파이 차트 제외) - const columns = useMemo(() => { - if (config.type === "pie" || chartData.length === 0) return []; - - const firstItem = chartData[0]; - return Object.keys(firstItem).filter( - (key) => key !== "name" && key !== "path" - ); - }, [chartData, config.type]); - - const height = config.height || 300; - const showLegend = config.showLegend !== false; - - if (!config.enabled) { - return null; - } - - return ( -
- {/* 차트 렌더링 */} - {config.type === "bar" && ( - - )} - - {config.type === "stackedBar" && ( - - )} - - {config.type === "line" && ( - - )} - - {config.type === "area" && ( - - )} - - {config.type === "pie" && ( - - )} -
- ); -}; - -export default PivotChart; - diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts deleted file mode 100644 index 9272e7db..00000000 --- a/frontend/lib/registry/components/pivot-grid/components/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * PivotGrid 서브 컴포넌트 내보내기 - */ - -export { FieldPanel } from "./FieldPanel"; -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/hooks/index.ts b/frontend/lib/registry/components/pivot-grid/hooks/index.ts deleted file mode 100644 index a9a1a4eb..00000000 --- a/frontend/lib/registry/components/pivot-grid/hooks/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * PivotGrid 커스텀 훅 내보내기 - */ - -export { - useVirtualScroll, - useVirtualColumnScroll, - useVirtual2DScroll, -} from "./useVirtualScroll"; - -export type { - VirtualScrollOptions, - VirtualScrollResult, - VirtualColumnScrollOptions, - VirtualColumnScrollResult, - Virtual2DScrollOptions, - Virtual2DScrollResult, -} from "./useVirtualScroll"; - -export { usePivotState } from "./usePivotState"; - -export type { - PivotStateConfig, - SavedPivotState, - UsePivotStateResult, -} from "./usePivotState"; - diff --git a/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts deleted file mode 100644 index dab5ef4d..00000000 --- a/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -/** - * PivotState 훅 - * 피벗 그리드 상태 저장/복원 관리 - */ - -import { useState, useEffect, useCallback } from "react"; -import { PivotFieldConfig, PivotGridState, SortDirection } from "../types"; - -// ==================== 타입 ==================== - -export interface PivotStateConfig { - enabled: boolean; - storageKey?: string; - storageType?: "localStorage" | "sessionStorage"; -} - -export interface SavedPivotState { - version: string; - timestamp: number; - fields: PivotFieldConfig[]; - expandedRowPaths: string[][]; - expandedColumnPaths: string[][]; - filterConfig: Record; - sortConfig: { - field: string; - direction: SortDirection; - } | null; -} - -export interface UsePivotStateResult { - // 상태 - fields: PivotFieldConfig[]; - pivotState: PivotGridState; - - // 상태 변경 - setFields: (fields: PivotFieldConfig[]) => void; - setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; - - // 저장/복원 - saveState: () => void; - loadState: () => boolean; - clearState: () => void; - hasStoredState: () => boolean; - - // 상태 정보 - lastSaved: Date | null; - isDirty: boolean; -} - -// ==================== 상수 ==================== - -const STATE_VERSION = "1.0.0"; -const DEFAULT_STORAGE_KEY = "pivot-grid-state"; - -// ==================== 훅 ==================== - -export function usePivotState( - initialFields: PivotFieldConfig[], - config: PivotStateConfig -): UsePivotStateResult { - const { - enabled, - storageKey = DEFAULT_STORAGE_KEY, - storageType = "localStorage", - } = config; - - // 상태 - const [fields, setFieldsInternal] = useState(initialFields); - const [pivotState, setPivotStateInternal] = useState({ - expandedRowPaths: [], - expandedColumnPaths: [], - sortConfig: null, - filterConfig: {}, - }); - const [lastSaved, setLastSaved] = useState(null); - const [isDirty, setIsDirty] = useState(false); - const [initialStateLoaded, setInitialStateLoaded] = useState(false); - - // 스토리지 가져오기 - const getStorage = useCallback(() => { - if (typeof window === "undefined") return null; - return storageType === "localStorage" ? localStorage : sessionStorage; - }, [storageType]); - - // 저장된 상태 확인 - const hasStoredState = useCallback((): boolean => { - const storage = getStorage(); - if (!storage) return false; - return storage.getItem(storageKey) !== null; - }, [getStorage, storageKey]); - - // 상태 저장 - const saveState = useCallback(() => { - if (!enabled) return; - - const storage = getStorage(); - if (!storage) return; - - const stateToSave: SavedPivotState = { - version: STATE_VERSION, - timestamp: Date.now(), - fields, - expandedRowPaths: pivotState.expandedRowPaths, - expandedColumnPaths: pivotState.expandedColumnPaths, - filterConfig: pivotState.filterConfig, - sortConfig: pivotState.sortConfig, - }; - - try { - storage.setItem(storageKey, JSON.stringify(stateToSave)); - setLastSaved(new Date()); - setIsDirty(false); - console.log("✅ 피벗 상태 저장됨:", storageKey); - } catch (error) { - console.error("❌ 피벗 상태 저장 실패:", error); - } - }, [enabled, getStorage, storageKey, fields, pivotState]); - - // 상태 불러오기 - const loadState = useCallback((): boolean => { - if (!enabled) return false; - - const storage = getStorage(); - if (!storage) return false; - - try { - const saved = storage.getItem(storageKey); - if (!saved) return false; - - const parsedState: SavedPivotState = JSON.parse(saved); - - // 버전 체크 - if (parsedState.version !== STATE_VERSION) { - console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨"); - return false; - } - - // 상태 복원 - setFieldsInternal(parsedState.fields); - setPivotStateInternal({ - expandedRowPaths: parsedState.expandedRowPaths, - expandedColumnPaths: parsedState.expandedColumnPaths, - sortConfig: parsedState.sortConfig, - filterConfig: parsedState.filterConfig, - }); - setLastSaved(new Date(parsedState.timestamp)); - setIsDirty(false); - - console.log("✅ 피벗 상태 복원됨:", storageKey); - return true; - } catch (error) { - console.error("❌ 피벗 상태 복원 실패:", error); - return false; - } - }, [enabled, getStorage, storageKey]); - - // 상태 초기화 - const clearState = useCallback(() => { - const storage = getStorage(); - if (!storage) return; - - try { - storage.removeItem(storageKey); - setLastSaved(null); - console.log("🗑️ 피벗 상태 삭제됨:", storageKey); - } catch (error) { - console.error("❌ 피벗 상태 삭제 실패:", error); - } - }, [getStorage, storageKey]); - - // 필드 변경 (dirty 플래그 설정) - const setFields = useCallback((newFields: PivotFieldConfig[]) => { - setFieldsInternal(newFields); - setIsDirty(true); - }, []); - - // 피벗 상태 변경 (dirty 플래그 설정) - const setPivotState = useCallback( - (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { - setPivotStateInternal(newState); - setIsDirty(true); - }, - [] - ); - - // 초기 로드 - useEffect(() => { - if (!initialStateLoaded && enabled && hasStoredState()) { - loadState(); - setInitialStateLoaded(true); - } - }, [enabled, hasStoredState, loadState, initialStateLoaded]); - - // 초기 필드 동기화 (저장된 상태가 없을 때) - useEffect(() => { - if (initialStateLoaded) return; - if (!hasStoredState() && initialFields.length > 0) { - setFieldsInternal(initialFields); - setInitialStateLoaded(true); - } - }, [initialFields, hasStoredState, initialStateLoaded]); - - // 자동 저장 (변경 시) - useEffect(() => { - if (!enabled || !isDirty) return; - - const timeout = setTimeout(() => { - saveState(); - }, 1000); // 1초 디바운스 - - return () => clearTimeout(timeout); - }, [enabled, isDirty, saveState]); - - return { - fields, - pivotState, - setFields, - setPivotState, - saveState, - loadState, - clearState, - hasStoredState, - lastSaved, - isDirty, - }; -} - -export default usePivotState; - diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts deleted file mode 100644 index f3d83e26..00000000 --- a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts +++ /dev/null @@ -1,316 +0,0 @@ -"use client"; - -/** - * Virtual Scroll 훅 - * 대용량 피벗 데이터의 가상 스크롤 처리 - */ - -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; - -// ==================== 타입 ==================== - -export interface VirtualScrollOptions { - itemCount: number; // 전체 아이템 수 - itemHeight: number; // 각 아이템 높이 (px) - containerHeight: number; // 컨테이너 높이 (px) - overscan?: number; // 버퍼 아이템 수 (기본: 5) -} - -export interface VirtualScrollResult { - // 현재 보여야 할 아이템 범위 - startIndex: number; - endIndex: number; - - // 가상 스크롤 관련 값 - totalHeight: number; // 전체 높이 - offsetTop: number; // 상단 오프셋 - - // 보여지는 아이템 목록 - visibleItems: number[]; - - // 이벤트 핸들러 - onScroll: (scrollTop: number) => void; - - // 컨테이너 ref - containerRef: React.RefObject; -} - -// ==================== 훅 ==================== - -export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { - const { - itemCount, - itemHeight, - containerHeight, - overscan = 5, - } = options; - - const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - - // 보이는 아이템 수 - const visibleCount = Math.ceil(containerHeight / itemHeight); - - // 시작/끝 인덱스 계산 (음수 방지) - const { startIndex, endIndex } = useMemo(() => { - // itemCount가 0이면 빈 배열 - if (itemCount === 0) { - return { startIndex: 0, endIndex: -1 }; - } - const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); - const end = Math.min( - itemCount - 1, - Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan - ); - return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록 - }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); - - // 전체 높이 - const totalHeight = itemCount * itemHeight; - - // 상단 오프셋 - const offsetTop = startIndex * itemHeight; - - // 보이는 아이템 인덱스 배열 - const visibleItems = useMemo(() => { - const items: number[] = []; - for (let i = startIndex; i <= endIndex; i++) { - items.push(i); - } - return items; - }, [startIndex, endIndex]); - - // 스크롤 핸들러 - const onScroll = useCallback((newScrollTop: number) => { - setScrollTop(newScrollTop); - }, []); - - // 스크롤 이벤트 리스너 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleScroll = () => { - setScrollTop(container.scrollTop); - }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - - return () => { - container.removeEventListener("scroll", handleScroll); - }; - }, []); - - return { - startIndex, - endIndex, - totalHeight, - offsetTop, - visibleItems, - onScroll, - containerRef, - }; -} - -// ==================== 열 가상 스크롤 ==================== - -export interface VirtualColumnScrollOptions { - columnCount: number; // 전체 열 수 - columnWidth: number; // 각 열 너비 (px) - containerWidth: number; // 컨테이너 너비 (px) - overscan?: number; -} - -export interface VirtualColumnScrollResult { - startIndex: number; - endIndex: number; - totalWidth: number; - offsetLeft: number; - visibleColumns: number[]; - onScroll: (scrollLeft: number) => void; -} - -export function useVirtualColumnScroll( - options: VirtualColumnScrollOptions -): VirtualColumnScrollResult { - const { - columnCount, - columnWidth, - containerWidth, - overscan = 3, - } = options; - - const [scrollLeft, setScrollLeft] = useState(0); - - const { startIndex, endIndex } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); - const end = Math.min( - columnCount - 1, - Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan - ); - return { startIndex: start, endIndex: end }; - }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); - - const totalWidth = columnCount * columnWidth; - const offsetLeft = startIndex * columnWidth; - - const visibleColumns = useMemo(() => { - const cols: number[] = []; - for (let i = startIndex; i <= endIndex; i++) { - cols.push(i); - } - return cols; - }, [startIndex, endIndex]); - - const onScroll = useCallback((newScrollLeft: number) => { - setScrollLeft(newScrollLeft); - }, []); - - return { - startIndex, - endIndex, - totalWidth, - offsetLeft, - visibleColumns, - onScroll, - }; -} - -// ==================== 2D 가상 스크롤 (행 + 열) ==================== - -export interface Virtual2DScrollOptions { - rowCount: number; - columnCount: number; - rowHeight: number; - columnWidth: number; - containerHeight: number; - containerWidth: number; - rowOverscan?: number; - columnOverscan?: number; -} - -export interface Virtual2DScrollResult { - // 행 범위 - rowStartIndex: number; - rowEndIndex: number; - totalHeight: number; - offsetTop: number; - visibleRows: number[]; - - // 열 범위 - columnStartIndex: number; - columnEndIndex: number; - totalWidth: number; - offsetLeft: number; - visibleColumns: number[]; - - // 스크롤 핸들러 - onScroll: (scrollTop: number, scrollLeft: number) => void; - - // 컨테이너 ref - containerRef: React.RefObject; -} - -export function useVirtual2DScroll( - options: Virtual2DScrollOptions -): Virtual2DScrollResult { - const { - rowCount, - columnCount, - rowHeight, - columnWidth, - containerHeight, - containerWidth, - rowOverscan = 5, - columnOverscan = 3, - } = options; - - const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - const [scrollLeft, setScrollLeft] = useState(0); - - // 행 계산 - const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); - const end = Math.min( - rowCount - 1, - Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan - ); - - const rows: number[] = []; - for (let i = start; i <= end; i++) { - rows.push(i); - } - - return { - rowStartIndex: start, - rowEndIndex: end, - visibleRows: rows, - }; - }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); - - // 열 계산 - const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); - const end = Math.min( - columnCount - 1, - Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan - ); - - const cols: number[] = []; - for (let i = start; i <= end; i++) { - cols.push(i); - } - - return { - columnStartIndex: start, - columnEndIndex: end, - visibleColumns: cols, - }; - }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); - - const totalHeight = rowCount * rowHeight; - const totalWidth = columnCount * columnWidth; - const offsetTop = rowStartIndex * rowHeight; - const offsetLeft = columnStartIndex * columnWidth; - - const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { - setScrollTop(newScrollTop); - setScrollLeft(newScrollLeft); - }, []); - - // 스크롤 이벤트 리스너 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleScroll = () => { - setScrollTop(container.scrollTop); - setScrollLeft(container.scrollLeft); - }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - - return () => { - container.removeEventListener("scroll", handleScroll); - }; - }, []); - - return { - rowStartIndex, - rowEndIndex, - totalHeight, - offsetTop, - visibleRows, - columnStartIndex, - columnEndIndex, - totalWidth, - offsetLeft, - visibleColumns, - onScroll, - containerRef, - }; -} - -export default useVirtualScroll; - diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts deleted file mode 100644 index b1bbe99b..00000000 --- a/frontend/lib/registry/components/pivot-grid/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * PivotGrid 컴포넌트 모듈 - * 다차원 데이터 분석을 위한 피벗 테이블 - */ - -// 타입 내보내기 -export type { - // 기본 타입 - PivotAreaType, - AggregationType, - SummaryDisplayMode, - SortDirection, - DateGroupInterval, - FieldDataType, - DataSourceType, - // 필드 설정 - PivotFieldFormat, - PivotFieldConfig, - // 데이터 소스 - PivotFilterCondition, - PivotJoinConfig, - PivotDataSourceConfig, - // 표시 설정 - PivotTotalsConfig, - FieldChooserConfig, - PivotChartConfig, - PivotStyleConfig, - PivotExportConfig, - // Props - PivotGridProps, - // 결과 데이터 - PivotCellData, - PivotHeaderNode, - PivotCellValue, - PivotResult, - PivotFlatRow, - PivotFlatColumn, - // 상태 - PivotGridState, - // Config - PivotGridComponentConfig, -} from "./types"; - -// 컴포넌트 내보내기 -export { PivotGridComponent } from "./PivotGridComponent"; -export { PivotGridConfigPanel } from "./PivotGridConfigPanel"; - -// 유틸리티 -export { - aggregate, - sum, - count, - avg, - min, - max, - countDistinct, - formatNumber, - formatDate, - getAggregationLabel, -} from "./utils/aggregation"; - -export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine"; diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts deleted file mode 100644 index d4d8b1e5..00000000 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * PivotGrid 컴포넌트 타입 정의 - * 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트 - */ - -// ==================== 기본 타입 ==================== - -// 필드 영역 타입 -export type PivotAreaType = "row" | "column" | "data" | "filter"; - -// 집계 함수 타입 -export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; - -// 요약 표시 모드 -export type SummaryDisplayMode = - | "absoluteValue" // 절대값 (기본) - | "percentOfColumnTotal" // 열 총계 대비 % - | "percentOfRowTotal" // 행 총계 대비 % - | "percentOfGrandTotal" // 전체 총계 대비 % - | "percentOfColumnGrandTotal" // 열 대총계 대비 % - | "percentOfRowGrandTotal" // 행 대총계 대비 % - | "runningTotalByRow" // 행 방향 누계 - | "runningTotalByColumn" // 열 방향 누계 - | "differenceFromPrevious" // 이전 대비 차이 - | "percentDifferenceFromPrevious"; // 이전 대비 % 차이 - -// 정렬 방향 -export type SortDirection = "asc" | "desc" | "none"; - -// 날짜 그룹 간격 -export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day"; - -// 필드 데이터 타입 -export type FieldDataType = "string" | "number" | "date" | "boolean"; - -// 데이터 소스 타입 -export type DataSourceType = "table" | "api" | "static"; - -// ==================== 필드 설정 ==================== - -// 필드 포맷 설정 -export interface PivotFieldFormat { - type: "number" | "currency" | "percent" | "date" | "text"; - precision?: number; // 소수점 자릿수 - thousandSeparator?: boolean; // 천단위 구분자 - prefix?: string; // 접두사 (예: "$", "₩") - suffix?: string; // 접미사 (예: "%", "원") - dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD") -} - -// 필드 설정 -export interface PivotFieldConfig { - // 기본 정보 - field: string; // 데이터 필드명 - caption: string; // 표시 라벨 - area: PivotAreaType; // 배치 영역 - areaIndex?: number; // 영역 내 순서 - - // 데이터 타입 - dataType?: FieldDataType; // 데이터 타입 - - // 집계 설정 (data 영역용) - summaryType?: AggregationType; // 집계 함수 - summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드 - showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭) - - // 정렬 설정 - sortBy?: "value" | "caption"; // 정렬 기준 - sortOrder?: SortDirection; // 정렬 방향 - sortBySummary?: string; // 요약값 기준 정렬 (data 필드명) - - // 날짜 그룹화 설정 - groupInterval?: DateGroupInterval; // 날짜 그룹 간격 - groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성) - - // 표시 설정 - visible?: boolean; // 표시 여부 - width?: number; // 컬럼 너비 - expanded?: boolean; // 기본 확장 상태 - - // 포맷 설정 - format?: PivotFieldFormat; // 값 포맷 - - // 필터 설정 - filterValues?: any[]; // 선택된 필터 값 - filterType?: "include" | "exclude"; // 필터 타입 - allowFiltering?: boolean; // 필터링 허용 - allowSorting?: boolean; // 정렬 허용 - - // 계층 관련 - displayFolder?: string; // 필드 선택기에서 폴더 구조 - isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) - - // 계산 필드 - isCalculated?: boolean; // 계산 필드 여부 - calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]") -} - -// ==================== 데이터 소스 설정 ==================== - -// 필터 조건 -export interface PivotFilterCondition { - field: string; - operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; - value?: any; - valueFromField?: string; // formData에서 값 가져오기 -} - -// 조인 설정 -export interface PivotJoinConfig { - joinType: "INNER" | "LEFT" | "RIGHT"; - targetTable: string; - sourceColumn: string; - targetColumn: string; - columns: string[]; // 가져올 컬럼들 -} - -// 데이터 소스 설정 -export interface PivotDataSourceConfig { - type: DataSourceType; - - // 테이블 기반 - tableName?: string; // 테이블명 - - // API 기반 - apiEndpoint?: string; // API 엔드포인트 - apiMethod?: "GET" | "POST"; // HTTP 메서드 - - // 정적 데이터 - staticData?: any[]; // 정적 데이터 - - // 필터 조건 - filterConditions?: PivotFilterCondition[]; - - // 조인 설정 - joinConfigs?: PivotJoinConfig[]; -} - -// ==================== 표시 설정 ==================== - -// 총합계 표시 설정 -export interface PivotTotalsConfig { - // 행 총합계 - showRowGrandTotals?: boolean; // 행 총합계 표시 - showRowTotals?: boolean; // 행 소계 표시 - rowTotalsPosition?: "first" | "last"; // 소계 위치 - rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단) - - // 열 총합계 - showColumnGrandTotals?: boolean; // 열 총합계 표시 - showColumnTotals?: boolean; // 열 소계 표시 - columnTotalsPosition?: "first" | "last"; // 소계 위치 - columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측) -} - -// 필드 선택기 설정 -export interface FieldChooserConfig { - enabled: boolean; // 활성화 여부 - allowSearch?: boolean; // 검색 허용 - layout?: "default" | "simplified"; // 레이아웃 - height?: number; // 높이 - applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점 -} - -// 차트 연동 설정 -export interface PivotChartConfig { - enabled: boolean; // 차트 표시 여부 - type: "bar" | "line" | "area" | "pie" | "stackedBar"; - position: "top" | "bottom" | "left" | "right"; - height?: number; - showLegend?: boolean; - animate?: boolean; -} - -// 조건부 서식 규칙 -export interface ConditionalFormatRule { - id: string; - type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; - field?: string; // 적용할 데이터 필드 (없으면 전체) - - // colorScale: 값 범위에 따른 색상 그라데이션 - colorScale?: { - minColor: string; // 최소값 색상 (예: "#ff0000") - midColor?: string; // 중간값 색상 (선택) - maxColor: string; // 최대값 색상 (예: "#00ff00") - }; - - // dataBar: 값에 따른 막대 표시 - dataBar?: { - color: string; // 막대 색상 - showValue?: boolean; // 값 표시 여부 - minValue?: number; // 최소값 (없으면 자동) - maxValue?: number; // 최대값 (없으면 자동) - }; - - // iconSet: 값에 따른 아이콘 표시 - iconSet?: { - type: "arrows" | "traffic" | "rating" | "flags"; - thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100) - reverse?: boolean; // 아이콘 순서 반전 - }; - - // cellValue: 조건에 따른 스타일 - cellValue?: { - operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; - value1: number; - value2?: number; // between 연산자용 - backgroundColor?: string; - textColor?: string; - bold?: boolean; - }; -} - -// 스타일 설정 -export interface PivotStyleConfig { - theme: "default" | "compact" | "modern"; - headerStyle: "default" | "dark" | "light"; - cellPadding: "compact" | "normal" | "comfortable"; - borderStyle: "none" | "light" | "heavy"; - alternateRowColors?: boolean; - highlightTotals?: boolean; // 총합계 강조 - conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 - mergeCells?: boolean; // 같은 값 셀 병합 -} - -// ==================== 내보내기 설정 ==================== - -export interface PivotExportConfig { - excel?: boolean; - pdf?: boolean; - fileName?: string; -} - -// ==================== 메인 Props ==================== - -export interface PivotGridProps { - // 기본 설정 - id?: string; - title?: string; - - // 데이터 소스 - dataSource?: PivotDataSourceConfig; - - // 필드 설정 - fields?: PivotFieldConfig[]; - - // 표시 설정 - totals?: PivotTotalsConfig; - style?: PivotStyleConfig; - - // 필드 선택기 - fieldChooser?: FieldChooserConfig; - - // 차트 연동 - chart?: PivotChartConfig; - - // 기능 설정 - allowSortingBySummary?: boolean; // 요약값 기준 정렬 - allowFiltering?: boolean; // 필터링 허용 - allowExpandAll?: boolean; // 전체 확장/축소 허용 - wordWrapEnabled?: boolean; // 텍스트 줄바꿈 - - // 크기 설정 - height?: string | number; - maxHeight?: string; - - // 상태 저장 - stateStoring?: { - enabled: boolean; - storageKey?: string; // localStorage 키 - }; - - // 내보내기 - exportConfig?: PivotExportConfig; - - // 데이터 (외부 주입용) - data?: any[]; - - // 이벤트 - onCellClick?: (cellData: PivotCellData) => void; - onCellDoubleClick?: (cellData: PivotCellData) => void; - onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; - onExpandChange?: (expandedPaths: string[][]) => void; - onDataChange?: (data: any[]) => void; -} - -// ==================== 결과 데이터 구조 ==================== - -// 셀 데이터 -export interface PivotCellData { - value: any; // 셀 값 - rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"]) - columnPath: string[]; // 열 경로 (예: ["2024", "Q1"]) - field?: string; // 데이터 필드명 - aggregationType?: AggregationType; - isTotal?: boolean; // 총합계 여부 - isGrandTotal?: boolean; // 대총합 여부 -} - -// 헤더 노드 (트리 구조) -export interface PivotHeaderNode { - value: any; // 원본 값 - caption: string; // 표시 텍스트 - level: number; // 깊이 - children?: PivotHeaderNode[]; // 자식 노드 - isExpanded: boolean; // 확장 상태 - hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음) - path: string[]; // 경로 (드릴다운용) - subtotal?: PivotCellValue[]; // 소계 - span?: number; // colspan/rowspan -} - -// 셀 값 -export interface PivotCellValue { - field: string; // 데이터 필드 - value: number | null; // 집계 값 - formattedValue: string; // 포맷된 값 -} - -// 피벗 결과 데이터 구조 -export interface PivotResult { - // 행 헤더 트리 - rowHeaders: PivotHeaderNode[]; - - // 열 헤더 트리 - columnHeaders: PivotHeaderNode[]; - - // 데이터 매트릭스 (rowPath + columnPath → values) - dataMatrix: Map; - - // 플랫 행 목록 (렌더링용) - flatRows: PivotFlatRow[]; - - // 플랫 열 목록 (렌더링용) - 리프 노드만 - flatColumns: PivotFlatColumn[]; - - // 열 헤더 레벨별 (다중 행 헤더용) - columnHeaderLevels: PivotColumnHeaderCell[][]; - - // 총합계 - grandTotals: { - row: Map; // 행별 총합 - column: Map; // 열별 총합 - grand: PivotCellValue[]; // 대총합 - }; -} - -// 플랫 행 (렌더링용) -export interface PivotFlatRow { - path: string[]; - level: number; - caption: string; - isExpanded: boolean; - hasChildren: boolean; - isTotal?: boolean; -} - -// 플랫 열 (렌더링용) -export interface PivotFlatColumn { - path: string[]; - level: number; - caption: string; - span: number; - isTotal?: boolean; -} - -// 열 헤더 셀 (다중 행 헤더용) -export interface PivotColumnHeaderCell { - caption: string; // 표시 텍스트 - colSpan: number; // 병합할 열 수 - path: string[]; // 전체 경로 - level: number; // 레벨 (0부터 시작) -} - -// ==================== 상태 관리 ==================== - -export interface PivotGridState { - expandedRowPaths: string[][]; // 확장된 행 경로들 - expandedColumnPaths: string[][]; // 확장된 열 경로들 - sortConfig: { - field: string; - direction: SortDirection; - } | null; - filterConfig: Record; // 필드별 필터값 -} - -// ==================== 컴포넌트 Config (화면관리용) ==================== - -export interface PivotGridComponentConfig { - // 데이터 소스 - dataSource?: PivotDataSourceConfig; - - // 필드 설정 - fields?: PivotFieldConfig[]; - - // 표시 설정 - totals?: PivotTotalsConfig; - style?: PivotStyleConfig; - - // 필드 선택기 - fieldChooser?: FieldChooserConfig; - - // 차트 연동 - chart?: PivotChartConfig; - - // 기능 설정 - allowSortingBySummary?: boolean; - allowFiltering?: boolean; - allowExpandAll?: boolean; - wordWrapEnabled?: boolean; - - // 크기 설정 - height?: string | number; - maxHeight?: string; - - // 내보내기 - exportConfig?: PivotExportConfig; -} - - diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts deleted file mode 100644 index 063efe89..00000000 --- a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * PivotGrid 집계 함수 유틸리티 - * 다양한 집계 연산을 수행합니다. - */ - -import { getFormatRules } from "@/lib/formatting"; - -import { AggregationType, PivotFieldFormat } from "../types"; - -// ==================== 집계 함수 ==================== - -/** - * 합계 계산 - */ -export function sum(values: number[]): number { - return values.reduce((acc, val) => acc + (val || 0), 0); -} - -/** - * 개수 계산 - */ -export function count(values: any[]): number { - return values.length; -} - -/** - * 평균 계산 - */ -export function avg(values: number[]): number { - if (values.length === 0) return 0; - return sum(values) / values.length; -} - -/** - * 최소값 계산 - */ -export function min(values: number[]): number { - if (values.length === 0) return 0; - return Math.min(...values.filter((v) => v !== null && v !== undefined)); -} - -/** - * 최대값 계산 - */ -export function max(values: number[]): number { - if (values.length === 0) return 0; - return Math.max(...values.filter((v) => v !== null && v !== undefined)); -} - -/** - * 고유값 개수 계산 - */ -export function countDistinct(values: any[]): number { - return new Set(values.filter((v) => v !== null && v !== undefined)).size; -} - -/** - * 집계 타입에 따른 집계 수행 - */ -export function aggregate( - values: any[], - type: AggregationType = "sum" -): number { - const numericValues = values - .map((v) => (typeof v === "number" ? v : parseFloat(v))) - .filter((v) => !isNaN(v)); - - switch (type) { - case "sum": - return sum(numericValues); - case "count": - return count(values); - case "avg": - return avg(numericValues); - case "min": - return min(numericValues); - case "max": - return max(numericValues); - case "countDistinct": - return countDistinct(values); - default: - return sum(numericValues); - } -} - -// ==================== 포맷 함수 ==================== - -/** - * 숫자 포맷팅 - */ -export function formatNumber( - value: number | null | undefined, - format?: PivotFieldFormat -): string { - if (value === null || value === undefined) return "-"; - - const { - type = "number", - precision = 0, - thousandSeparator = true, - prefix = "", - suffix = "", - } = format || {}; - - let formatted: string; - - const locale = getFormatRules().number.locale; - - switch (type) { - case "currency": - formatted = value.toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - break; - - case "percent": - formatted = (value * 100).toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - break; - - case "number": - default: - if (thousandSeparator) { - formatted = value.toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - } else { - formatted = value.toFixed(precision); - } - break; - } - - return `${prefix}${formatted}${suffix}`; -} - -/** - * 날짜 포맷팅 - */ -export function formatDate( - value: Date | string | null | undefined, - format: string = getFormatRules().date.display -): string { - if (!value) return "-"; - - const date = typeof value === "string" ? new Date(value) : value; - - if (isNaN(date.getTime())) return "-"; - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const quarter = Math.ceil((date.getMonth() + 1) / 3); - - return format - .replace("YYYY", String(year)) - .replace("MM", month) - .replace("DD", day) - .replace("Q", `Q${quarter}`); -} - -/** - * 집계 타입 라벨 반환 - */ -export function getAggregationLabel(type: AggregationType): string { - const labels: Record = { - sum: "합계", - count: "개수", - avg: "평균", - min: "최소", - max: "최대", - countDistinct: "고유값", - }; - return labels[type] || "합계"; -} - - diff --git a/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts deleted file mode 100644 index a9195d92..00000000 --- a/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * 조건부 서식 유틸리티 - * 셀 값에 따른 스타일 계산 - */ - -import { ConditionalFormatRule } from "../types"; - -// ==================== 타입 ==================== - -export interface CellFormatStyle { - backgroundColor?: string; - textColor?: string; - fontWeight?: string; - dataBarWidth?: number; // 0-100% - dataBarColor?: string; - icon?: string; // 이모지 또는 아이콘 이름 -} - -// ==================== 색상 유틸리티 ==================== - -/** - * HEX 색상을 RGB로 변환 - */ -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null; -} - -/** - * RGB를 HEX로 변환 - */ -function rgbToHex(r: number, g: number, b: number): string { - return ( - "#" + - [r, g, b] - .map((x) => { - const hex = Math.round(x).toString(16); - return hex.length === 1 ? "0" + hex : hex; - }) - .join("") - ); -} - -/** - * 두 색상 사이의 보간 - */ -function interpolateColor( - color1: string, - color2: string, - factor: number -): string { - const rgb1 = hexToRgb(color1); - const rgb2 = hexToRgb(color2); - - if (!rgb1 || !rgb2) return color1; - - const r = rgb1.r + (rgb2.r - rgb1.r) * factor; - const g = rgb1.g + (rgb2.g - rgb1.g) * factor; - const b = rgb1.b + (rgb2.b - rgb1.b) * factor; - - return rgbToHex(r, g, b); -} - -// ==================== 조건부 서식 계산 ==================== - -/** - * Color Scale 스타일 계산 - */ -function applyColorScale( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.colorScale) return {}; - - const { minColor, midColor, maxColor } = rule.colorScale; - const range = maxValue - minValue; - - if (range === 0) { - return { backgroundColor: minColor }; - } - - const normalizedValue = (value - minValue) / range; - - let backgroundColor: string; - - if (midColor) { - // 3색 그라데이션 - if (normalizedValue <= 0.5) { - backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); - } else { - backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); - } - } else { - // 2색 그라데이션 - backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); - } - - // 배경색에 따른 텍스트 색상 결정 - const rgb = hexToRgb(backgroundColor); - const textColor = - rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 - ? "#000000" - : "#ffffff"; - - return { backgroundColor, textColor }; -} - -/** - * Data Bar 스타일 계산 - */ -function applyDataBar( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.dataBar) return {}; - - const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; - - const min = ruleMin ?? minValue; - const max = ruleMax ?? maxValue; - const range = max - min; - - if (range === 0) { - return { dataBarWidth: 100, dataBarColor: color }; - } - - const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); - - return { - dataBarWidth: width, - dataBarColor: color, - }; -} - -/** - * Icon Set 스타일 계산 - */ -function applyIconSet( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.iconSet) return {}; - - const { type, thresholds, reverse } = rule.iconSet; - const range = maxValue - minValue; - const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; - - // 아이콘 정의 - const iconSets: Record = { - arrows: ["↓", "→", "↑"], - traffic: ["🔴", "🟡", "🟢"], - rating: ["⭐", "⭐⭐", "⭐⭐⭐"], - flags: ["🚩", "🏳️", "🏁"], - }; - - const icons = iconSets[type] || iconSets.arrows; - const sortedIcons = reverse ? [...icons].reverse() : icons; - - // 임계값에 따른 아이콘 선택 - let iconIndex = 0; - for (let i = 0; i < thresholds.length; i++) { - if (percentage >= thresholds[i]) { - iconIndex = i + 1; - } - } - iconIndex = Math.min(iconIndex, sortedIcons.length - 1); - - return { - icon: sortedIcons[iconIndex], - }; -} - -/** - * Cell Value 조건 스타일 계산 - */ -function applyCellValue( - value: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.cellValue) return {}; - - const { operator, value1, value2, backgroundColor, textColor, bold } = - rule.cellValue; - - let matches = false; - - switch (operator) { - case ">": - matches = value > value1; - break; - case ">=": - matches = value >= value1; - break; - case "<": - matches = value < value1; - break; - case "<=": - matches = value <= value1; - break; - case "=": - matches = value === value1; - break; - case "!=": - matches = value !== value1; - break; - case "between": - matches = value2 !== undefined && value >= value1 && value <= value2; - break; - } - - if (!matches) return {}; - - return { - backgroundColor, - textColor, - fontWeight: bold ? "bold" : undefined, - }; -} - -// ==================== 메인 함수 ==================== - -/** - * 조건부 서식 적용 - */ -export function getConditionalStyle( - value: number | null | undefined, - field: string, - rules: ConditionalFormatRule[], - allValues: number[] // 해당 필드의 모든 값 (min/max 계산용) -): CellFormatStyle { - if (value === null || value === undefined || isNaN(value)) { - return {}; - } - - if (!rules || rules.length === 0) { - return {}; - } - - // min/max 계산 - const numericValues = allValues.filter((v) => !isNaN(v)); - const minValue = Math.min(...numericValues); - const maxValue = Math.max(...numericValues); - - let resultStyle: CellFormatStyle = {}; - - // 해당 필드에 적용되는 규칙 필터링 및 적용 - for (const rule of rules) { - // 필드 필터 확인 - if (rule.field && rule.field !== field) { - continue; - } - - let ruleStyle: CellFormatStyle = {}; - - switch (rule.type) { - case "colorScale": - ruleStyle = applyColorScale(value, minValue, maxValue, rule); - break; - case "dataBar": - ruleStyle = applyDataBar(value, minValue, maxValue, rule); - break; - case "iconSet": - ruleStyle = applyIconSet(value, minValue, maxValue, rule); - break; - case "cellValue": - ruleStyle = applyCellValue(value, rule); - break; - } - - // 스타일 병합 (나중 규칙이 우선) - resultStyle = { ...resultStyle, ...ruleStyle }; - } - - return resultStyle; -} - -/** - * 조건부 서식 스타일을 React 스타일 객체로 변환 - */ -export function formatStyleToReact( - style: CellFormatStyle -): React.CSSProperties { - const result: React.CSSProperties = {}; - - if (style.backgroundColor) { - result.backgroundColor = style.backgroundColor; - } - if (style.textColor) { - result.color = style.textColor; - } - if (style.fontWeight) { - result.fontWeight = style.fontWeight as any; - } - - return result; -} - -export default getConditionalStyle; - diff --git a/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts deleted file mode 100644 index 6069a3a5..00000000 --- a/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Excel 내보내기 유틸리티 - * 피벗 테이블 데이터를 Excel 파일로 내보내기 - * xlsx 라이브러리 사용 (브라우저 호환) - */ - -import * as XLSX from "xlsx"; -import { - PivotResult, - PivotFieldConfig, - PivotTotalsConfig, -} from "../types"; -import { pathToKey } from "./pivotEngine"; - -// ==================== 타입 ==================== - -export interface ExportOptions { - fileName?: string; - sheetName?: string; - title?: string; - subtitle?: string; - includeHeaders?: boolean; - includeTotals?: boolean; -} - -// ==================== 메인 함수 ==================== - -/** - * 피벗 데이터를 Excel로 내보내기 - */ -export async function exportPivotToExcel( - pivotResult: PivotResult, - fields: PivotFieldConfig[], - totals: PivotTotalsConfig, - options: ExportOptions = {} -): Promise { - const { - fileName = "pivot_export", - sheetName = "Pivot", - title, - includeHeaders = true, - includeTotals = true, - } = options; - - // 필드 분류 - const rowFields = fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - // 데이터 배열 생성 - const data: any[][] = []; - - // 제목 추가 - if (title) { - data.push([title]); - data.push([]); // 빈 행 - } - - // 헤더 행 - if (includeHeaders) { - const headerRow: any[] = [ - rowFields.map((f) => f.caption).join(" / ") || "항목", - ]; - - // 열 헤더 - for (const col of pivotResult.flatColumns) { - headerRow.push(col.caption || "(전체)"); - } - - // 총계 헤더 - if (totals?.showRowGrandTotals && includeTotals) { - headerRow.push("총계"); - } - - data.push(headerRow); - } - - // 데이터 행 - for (const row of pivotResult.flatRows) { - const excelRow: any[] = []; - - // 행 헤더 (들여쓰기 포함) - const indent = " ".repeat(row.level); - excelRow.push(indent + row.caption); - - // 데이터 셀 - for (const col of pivotResult.flatColumns) { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = pivotResult.dataMatrix.get(cellKey); - - if (values && values.length > 0) { - excelRow.push(values[0].value); - } else { - excelRow.push(""); - } - } - - // 행 총계 - if (totals?.showRowGrandTotals && includeTotals) { - const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); - if (rowTotal && rowTotal.length > 0) { - excelRow.push(rowTotal[0].value); - } else { - excelRow.push(""); - } - } - - data.push(excelRow); - } - - // 열 총계 행 - if (totals?.showColumnGrandTotals && includeTotals) { - const totalRow: any[] = ["총계"]; - - for (const col of pivotResult.flatColumns) { - const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); - if (colTotal && colTotal.length > 0) { - totalRow.push(colTotal[0].value); - } else { - totalRow.push(""); - } - } - - // 대총합 - if (totals?.showRowGrandTotals) { - const grandTotal = pivotResult.grandTotals.grand; - if (grandTotal && grandTotal.length > 0) { - totalRow.push(grandTotal[0].value); - } else { - totalRow.push(""); - } - } - - data.push(totalRow); - } - - // 워크시트 생성 - const worksheet = XLSX.utils.aoa_to_sheet(data); - - // 컬럼 너비 설정 - const colWidths: XLSX.ColInfo[] = []; - const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); - for (let i = 0; i < maxCols; i++) { - colWidths.push({ wch: i === 0 ? 25 : 15 }); - } - worksheet["!cols"] = colWidths; - - // 워크북 생성 - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - - // 파일 다운로드 - XLSX.writeFile(workbook, `${fileName}.xlsx`); -} - -/** - * Drill Down 데이터를 Excel로 내보내기 - */ -export async function exportDrillDownToExcel( - data: any[], - columns: { field: string; caption: string }[], - options: ExportOptions = {} -): Promise { - const { - fileName = "drilldown_export", - sheetName = "Data", - title, - } = options; - - // 데이터 배열 생성 - const sheetData: any[][] = []; - - // 제목 - if (title) { - sheetData.push([title]); - sheetData.push([]); // 빈 행 - } - - // 헤더 - const headerRow = columns.map((col) => col.caption); - sheetData.push(headerRow); - - // 데이터 - for (const row of data) { - const dataRow = columns.map((col) => row[col.field] ?? ""); - sheetData.push(dataRow); - } - - // 워크시트 생성 - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - - // 컬럼 너비 설정 - const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); - worksheet["!cols"] = colWidths; - - // 워크북 생성 - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - - // 파일 다운로드 - XLSX.writeFile(workbook, `${fileName}.xlsx`); -} diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts deleted file mode 100644 index 2c0a83d6..00000000 --- a/frontend/lib/registry/components/pivot-grid/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./aggregation"; -export * from "./pivotEngine"; -export * from "./exportExcel"; -export * from "./conditionalFormat"; - - diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts deleted file mode 100644 index 129c3e39..00000000 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ /dev/null @@ -1,898 +0,0 @@ -/** - * PivotGrid 데이터 처리 엔진 - * 원시 데이터를 피벗 구조로 변환합니다. - */ - -import { - PivotFieldConfig, - PivotResult, - PivotHeaderNode, - PivotFlatRow, - PivotFlatColumn, - PivotCellValue, - PivotColumnHeaderCell, - DateGroupInterval, - AggregationType, - SummaryDisplayMode, -} from "../types"; -import { aggregate, formatNumber, formatDate } from "./aggregation"; - -// ==================== 헬퍼 함수 ==================== - -/** - * 필드 값 추출 (날짜 그룹핑 포함) - */ -function getFieldValue( - row: Record, - field: PivotFieldConfig -): string { - const rawValue = row[field.field]; - - if (rawValue === null || rawValue === undefined) { - return "(빈 값)"; - } - - // 날짜 그룹핑 처리 - if (field.groupInterval && field.dataType === "date") { - const date = new Date(rawValue); - if (isNaN(date.getTime())) return String(rawValue); - - switch (field.groupInterval) { - case "year": - return String(date.getFullYear()); - case "quarter": - return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; - case "month": - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; - case "week": - const weekNum = getWeekNumber(date); - return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; - case "day": - return formatDate(date); - default: - return String(rawValue); - } - } - - return String(rawValue); -} - -/** - * 주차 계산 - */ -function getWeekNumber(date: Date): number { - const d = new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - ); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); -} - -/** - * 경로를 키로 변환 - */ -export function pathToKey(path: string[]): string { - return path.join("||"); -} - -/** - * 모든 가능한 경로 생성 (열 전체 확장용) - */ -function generateAllPaths( - data: Record[], - fields: PivotFieldConfig[] -): string[] { - const allPaths: string[] = []; - - // 각 레벨까지의 고유 경로 수집 - for (let depth = 1; depth <= fields.length; depth++) { - const fieldsAtDepth = fields.slice(0, depth); - const pathSet = new Set(); - - data.forEach((row) => { - const path = fieldsAtDepth.map((f) => getFieldValue(row, f)); - pathSet.add(pathToKey(path)); - }); - - pathSet.forEach((pathKey) => allPaths.push(pathKey)); - } - - return allPaths; -} - -/** - * 키를 경로로 변환 - */ -export function keyToPath(key: string): string[] { - return key.split("||"); -} - -// ==================== 헤더 생성 ==================== - -/** - * 계층적 헤더 노드 생성 - */ -function buildHeaderTree( - data: Record[], - fields: PivotFieldConfig[], - expandedPaths: Set -): PivotHeaderNode[] { - if (fields.length === 0) return []; - - // 첫 번째 필드로 그룹화 - const firstField = fields[0]; - const groups = new Map[]>(); - - data.forEach((row) => { - const value = getFieldValue(row, firstField); - if (!groups.has(value)) { - groups.set(value, []); - } - groups.get(value)!.push(row); - }); - - // 정렬 - const sortedKeys = Array.from(groups.keys()).sort((a, b) => { - if (firstField.sortOrder === "desc") { - return b.localeCompare(a, "ko"); - } - return a.localeCompare(b, "ko"); - }); - - // 노드 생성 - const nodes: PivotHeaderNode[] = []; - const remainingFields = fields.slice(1); - - for (const key of sortedKeys) { - const groupData = groups.get(key)!; - const path = [key]; - const pathKey = pathToKey(path); - - const node: PivotHeaderNode = { - value: key, - caption: key, - level: 0, - isExpanded: expandedPaths.has(pathKey), - hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 - path: path, - span: 1, - }; - - // 자식 노드 생성 (확장된 경우만) - if (remainingFields.length > 0 && node.isExpanded) { - node.children = buildChildNodes( - groupData, - remainingFields, - path, - expandedPaths, - 1 - ); - // span 계산 - node.span = calculateSpan(node.children); - } - - nodes.push(node); - } - - return nodes; -} - -/** - * 자식 노드 재귀 생성 - */ -function buildChildNodes( - data: Record[], - fields: PivotFieldConfig[], - parentPath: string[], - expandedPaths: Set, - level: number -): PivotHeaderNode[] { - if (fields.length === 0) return []; - - const field = fields[0]; - const groups = new Map[]>(); - - data.forEach((row) => { - const value = getFieldValue(row, field); - if (!groups.has(value)) { - groups.set(value, []); - } - groups.get(value)!.push(row); - }); - - const sortedKeys = Array.from(groups.keys()).sort((a, b) => { - if (field.sortOrder === "desc") { - return b.localeCompare(a, "ko"); - } - return a.localeCompare(b, "ko"); - }); - - const nodes: PivotHeaderNode[] = []; - const remainingFields = fields.slice(1); - - for (const key of sortedKeys) { - const groupData = groups.get(key)!; - const path = [...parentPath, key]; - const pathKey = pathToKey(path); - - const node: PivotHeaderNode = { - value: key, - caption: key, - level: level, - isExpanded: expandedPaths.has(pathKey), - hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 - path: path, - span: 1, - }; - - if (remainingFields.length > 0 && node.isExpanded) { - node.children = buildChildNodes( - groupData, - remainingFields, - path, - expandedPaths, - level + 1 - ); - node.span = calculateSpan(node.children); - } - - nodes.push(node); - } - - return nodes; -} - -/** - * span 계산 (colspan/rowspan) - */ -function calculateSpan(children?: PivotHeaderNode[]): number { - if (!children || children.length === 0) return 1; - return children.reduce((sum, child) => sum + (child.span ?? 1), 0); -} - -// ==================== 플랫 구조 변환 ==================== - -/** - * 헤더 트리를 플랫 행으로 변환 - */ -function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { - const result: PivotFlatRow[] = []; - - function traverse(node: PivotHeaderNode) { - result.push({ - path: node.path, - level: node.level, - caption: node.caption, - isExpanded: node.isExpanded, - hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준) - }); - - if (node.isExpanded && node.children) { - for (const child of node.children) { - traverse(child); - } - } - } - - for (const node of nodes) { - traverse(node); - } - - return result; -} - -/** - * 헤더 트리를 플랫 열로 변환 (각 레벨별) - */ -function flattenColumns( - nodes: PivotHeaderNode[], - maxLevel: number -): PivotFlatColumn[][] { - const levels: PivotFlatColumn[][] = Array.from( - { length: maxLevel + 1 }, - () => [] - ); - - function traverse(node: PivotHeaderNode, currentLevel: number) { - levels[currentLevel].push({ - path: node.path, - level: currentLevel, - caption: node.caption, - span: node.span ?? 1, - }); - - if (node.children && node.isExpanded) { - for (const child of node.children) { - traverse(child, currentLevel + 1); - } - } else if (currentLevel < maxLevel) { - // 확장되지 않은 노드는 다음 레벨들에서 span으로 처리 - for (let i = currentLevel + 1; i <= maxLevel; i++) { - levels[i].push({ - path: node.path, - level: i, - caption: "", - span: node.span ?? 1, - }); - } - } - } - - for (const node of nodes) { - traverse(node, 0); - } - - return levels; -} - -/** - * 열 헤더의 최대 깊이 계산 - */ -function getMaxColumnLevel( - nodes: PivotHeaderNode[], - totalFields: number -): number { - let maxLevel = 0; - - function traverse(node: PivotHeaderNode, level: number) { - maxLevel = Math.max(maxLevel, level); - if (node.children && node.isExpanded) { - for (const child of node.children) { - traverse(child, level + 1); - } - } - } - - for (const node of nodes) { - traverse(node, 0); - } - - return Math.min(maxLevel, totalFields - 1); -} - -/** - * 다중 행 열 헤더 생성 - * 각 레벨별로 셀과 colSpan 정보를 반환 - */ -function buildColumnHeaderLevels( - nodes: PivotHeaderNode[], - totalLevels: number -): PivotColumnHeaderCell[][] { - if (totalLevels === 0 || nodes.length === 0) { - return []; - } - - const levels: PivotColumnHeaderCell[][] = Array.from( - { length: totalLevels }, - () => [] - ); - - // 리프 노드 수 계산 (colSpan 계산용) - function countLeaves(node: PivotHeaderNode): number { - if (!node.children || node.children.length === 0 || !node.isExpanded) { - return 1; - } - return node.children.reduce((sum, child) => sum + countLeaves(child), 0); - } - - // 트리 순회하며 각 레벨에 셀 추가 - function traverse(node: PivotHeaderNode, level: number) { - const colSpan = countLeaves(node); - - levels[level].push({ - caption: node.caption, - colSpan, - path: node.path, - level, - }); - - if (node.children && node.isExpanded) { - for (const child of node.children) { - traverse(child, level + 1); - } - } else if (level < totalLevels - 1) { - // 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움 - for (let i = level + 1; i < totalLevels; i++) { - levels[i].push({ - caption: "", - colSpan, - path: node.path, - level: i, - }); - } - } - } - - for (const node of nodes) { - traverse(node, 0); - } - - return levels; -} - -// ==================== 데이터 매트릭스 생성 ==================== - -/** - * 데이터 매트릭스 생성 - */ -function buildDataMatrix( - data: Record[], - rowFields: PivotFieldConfig[], - columnFields: PivotFieldConfig[], - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][] -): Map { - const matrix = new Map(); - - // 각 셀에 대해 해당하는 데이터 집계 - for (const row of flatRows) { - for (const colPath of flatColumnLeaves) { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; - - // 해당 행/열 경로에 맞는 데이터 필터링 - const filteredData = data.filter((record) => { - // 행 조건 확인 - for (let i = 0; i < row.path.length; i++) { - const field = rowFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== row.path[i]) return false; - } - - // 열 조건 확인 - for (let i = 0; i < colPath.length; i++) { - const field = columnFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== colPath[i]) return false; - } - - return true; - }); - - // 데이터 필드별 집계 - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate( - values, - dataField.summaryType || "sum" - ); - const formattedValue = formatNumber( - aggregatedValue, - dataField.format - ); - - return { - field: dataField.field, - value: aggregatedValue, - formattedValue, - }; - }); - - matrix.set(cellKey, cellValues); - } - } - - return matrix; -} - -/** - * 열 leaf 노드 경로 추출 - */ -function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { - const leaves: string[][] = []; - - function traverse(node: PivotHeaderNode) { - if (!node.isExpanded || !node.children || node.children.length === 0) { - leaves.push(node.path); - } else { - for (const child of node.children) { - traverse(child); - } - } - } - - for (const node of nodes) { - traverse(node); - } - - // 열 필드가 없을 경우 빈 경로 추가 - if (leaves.length === 0) { - leaves.push([]); - } - - return leaves; -} - -// ==================== Summary Display Mode 적용 ==================== - -/** - * Summary Display Mode에 따른 값 변환 - */ -function applyDisplayMode( - value: number, - displayMode: SummaryDisplayMode | undefined, - rowTotal: number, - columnTotal: number, - grandTotal: number, - prevValue: number | null, - runningTotal: number, - format?: PivotFieldConfig["format"] -): { value: number; formattedValue: string } { - if (!displayMode || displayMode === "absoluteValue") { - return { - value, - formattedValue: formatNumber(value, format), - }; - } - - let resultValue: number; - let formatOverride: PivotFieldConfig["format"] | undefined; - - switch (displayMode) { - case "percentOfRowTotal": - resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfColumnTotal": - resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfGrandTotal": - resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfRowGrandTotal": - resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfColumnGrandTotal": - resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "runningTotalByRow": - case "runningTotalByColumn": - resultValue = runningTotal; - break; - - case "differenceFromPrevious": - resultValue = prevValue === null ? 0 : value - prevValue; - break; - - case "percentDifferenceFromPrevious": - resultValue = prevValue === null || prevValue === 0 - ? 0 - : ((value - prevValue) / Math.abs(prevValue)) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - default: - resultValue = value; - } - - return { - value: resultValue, - formattedValue: formatNumber(resultValue, formatOverride || format), - }; -} - -/** - * 데이터 매트릭스에 Summary Display Mode 적용 - */ -function applyDisplayModeToMatrix( - matrix: Map, - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][], - rowTotals: Map, - columnTotals: Map, - grandTotals: PivotCellValue[] -): Map { - // displayMode가 있는 데이터 필드가 있는지 확인 - const hasDisplayMode = dataFields.some( - (df) => df.summaryDisplayMode || df.showValuesAs - ); - if (!hasDisplayMode) return matrix; - - const newMatrix = new Map(); - - // 누계를 위한 추적 (행별, 열별) - const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 - const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 - - // 행 순서대로 처리 - for (const row of flatRows) { - // 이전 열 값 추적 (차이 계산용) - const prevColValues: (number | null)[] = dataFields.map(() => null); - - for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { - const colPath = flatColumnLeaves[colIdx]; - const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; - const values = matrix.get(cellKey); - - if (!values) { - newMatrix.set(cellKey, []); - continue; - } - - const rowKey = pathToKey(row.path); - const colKey = pathToKey(colPath); - - // 총합 가져오기 - const rowTotal = rowTotals.get(rowKey); - const colTotal = columnTotals.get(colKey); - - const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { - const dataField = dataFields[fieldIdx]; - const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; - - if (!displayMode || displayMode === "absoluteValue") { - prevColValues[fieldIdx] = val.value; - return val; - } - - // 누계 계산 - // 행 방향 누계 - if (!rowRunningTotals.has(rowKey)) { - rowRunningTotals.set(rowKey, dataFields.map(() => 0)); - } - const rowRunning = rowRunningTotals.get(rowKey)!; - rowRunning[fieldIdx] += val.value || 0; - - // 열 방향 누계 - if (!colRunningTotals.has(colKey)) { - colRunningTotals.set(colKey, new Map()); - } - const colRunning = colRunningTotals.get(colKey)!; - if (!colRunning.has(fieldIdx)) { - colRunning.set(fieldIdx, 0); - } - colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); - - const result = applyDisplayMode( - val.value || 0, - displayMode, - rowTotal?.[fieldIdx]?.value || 0, - colTotal?.[fieldIdx]?.value || 0, - grandTotals[fieldIdx]?.value || 0, - prevColValues[fieldIdx], - displayMode === "runningTotalByRow" - ? rowRunning[fieldIdx] - : colRunning.get(fieldIdx) || 0, - dataField.format - ); - - prevColValues[fieldIdx] = val.value; - - return { - field: val.field, - value: result.value, - formattedValue: result.formattedValue, - }; - }); - - newMatrix.set(cellKey, newValues); - } - } - - return newMatrix; -} - -// ==================== 총합계 계산 ==================== - -/** - * 총합계 계산 - */ -function calculateGrandTotals( - data: Record[], - rowFields: PivotFieldConfig[], - columnFields: PivotFieldConfig[], - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][] -): { - row: Map; - column: Map; - grand: PivotCellValue[]; -} { - const rowTotals = new Map(); - const columnTotals = new Map(); - - // 행별 총합 (각 행의 모든 열 합계) - for (const row of flatRows) { - const filteredData = data.filter((record) => { - for (let i = 0; i < row.path.length; i++) { - const field = rowFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== row.path[i]) return false; - } - return true; - }); - - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - rowTotals.set(pathToKey(row.path), cellValues); - } - - // 열별 총합 (각 열의 모든 행 합계) - for (const colPath of flatColumnLeaves) { - const filteredData = data.filter((record) => { - for (let i = 0; i < colPath.length; i++) { - const field = columnFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== colPath[i]) return false; - } - return true; - }); - - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - columnTotals.set(pathToKey(colPath), cellValues); - } - - // 대총합 - const grandValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = data.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - return { - row: rowTotals, - column: columnTotals, - grand: grandValues, - }; -} - -// ==================== 메인 함수 ==================== - -/** - * 피벗 데이터 처리 - */ -export function processPivotData( - data: Record[], - fields: PivotFieldConfig[], - expandedRowPaths: string[][] = [], - expandedColumnPaths: string[][] = [] -): PivotResult { - // 영역별 필드 분리 - const rowFields = fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const columnFields = fields - .filter((f) => f.area === "column" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const dataFields = fields - .filter((f) => f.area === "data" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - // 참고: 필터링은 PivotGridComponent에서 이미 처리됨 - // 여기서는 추가 필터링 없이 전달받은 데이터 사용 - const filteredData = data; - - // 확장 경로 Set 변환 (잘못된 형식 필터링) - const validRowPaths = (expandedRowPaths || []).filter( - (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") - ); - const validColPaths = (expandedColumnPaths || []).filter( - (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") - ); - const expandedRowSet = new Set(validRowPaths.map(pathToKey)); - const expandedColSet = new Set(validColPaths.map(pathToKey)); - - // 기본 확장: 첫 번째 레벨 모두 확장 - if (expandedRowPaths.length === 0 && rowFields.length > 0) { - const firstField = rowFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedRowSet.add(val)); - } - - // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음) - // 모든 가능한 열 경로를 확장 상태로 설정 - if (columnFields.length > 0) { - const allColumnPaths = generateAllPaths(filteredData, columnFields); - allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey)); - } - - // 헤더 트리 생성 - const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); - const columnHeaders = buildHeaderTree( - filteredData, - columnFields, - expandedColSet - ); - - // 플랫 구조 변환 - const flatRows = flattenRows(rowHeaders); - const flatColumnLeaves = getColumnLeaves(columnHeaders); - const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); - const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); - - // 데이터 매트릭스 생성 - let dataMatrix = buildDataMatrix( - filteredData, - rowFields, - columnFields, - dataFields, - flatRows, - flatColumnLeaves - ); - - // 총합계 계산 - const grandTotals = calculateGrandTotals( - filteredData, - rowFields, - columnFields, - dataFields, - flatRows, - flatColumnLeaves - ); - - // Summary Display Mode 적용 - dataMatrix = applyDisplayModeToMatrix( - dataMatrix, - dataFields, - flatRows, - flatColumnLeaves, - grandTotals.row, - grandTotals.column, - grandTotals.grand - ); - - // 다중 행 열 헤더 생성 - const columnHeaderLevels = buildColumnHeaderLevels( - columnHeaders, - columnFields.length - ); - - return { - rowHeaders, - columnHeaders, - dataMatrix, - flatRows, - flatColumns: flatColumnLeaves.map((path, idx) => ({ - path, - level: path.length - 1, - caption: path[path.length - 1] || "", - span: 1, - })), - columnHeaderLevels, - grandTotals, - }; -} - - diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx deleted file mode 100644 index 4f7014b2..00000000 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx +++ /dev/null @@ -1,1314 +0,0 @@ -"use client"; - -import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; -import { ComponentRendererProps } from "@/types/component"; -import { CardDisplayConfig } from "./types"; -import { tableTypeApi } from "@/lib/api/screen"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { getFullImageUrl, apiClient } from "@/lib/api/client"; -import { filterDOMProps } from "@/lib/utils/domPropsFilter"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; -import { useModalDataStore } from "@/stores/modalDataStore"; -import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; - -export interface CardDisplayComponentProps extends ComponentRendererProps { - config?: CardDisplayConfig; - tableData?: any[]; - tableColumns?: any[]; -} - -/** - * CardDisplay 컴포넌트 - * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 - */ -export const CardDisplayComponent: React.FC = ({ - component, - isDesignMode = false, - isSelected = false, - isInteractive = false, - onClick, - onDragStart, - onDragEnd, - config, - className, - style, - form_data, - onFormDataChange, - screenId, - tableName, - tableData = [], - tableColumns = [], - ...props -}) => { - // 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음) - const screenContext = useScreenContextOptional(); - const splitPanelContext = useSplitPanelContext(); - const splitPanelPosition = screenContext?.split_panel_position; - - // TableOptions Context (검색 필터 위젯 연동용) - let tableOptionsContext: ReturnType | null = null; - try { - tableOptionsContext = useTableOptions(); - } catch (e) { - // Context가 없으면 (디자이너 모드) 무시 - } - - // 테이블 데이터 상태 관리 - const [loadedTableData, setLoadedTableData] = useState([]); - const [loadedTableColumns, setLoadedTableColumns] = useState([]); - const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 - const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 - const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 - - // 필터 상태 (검색 필터 위젯에서 전달받은 필터) - const [filters, setFiltersInternal] = useState([]); - - // 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가) - const [refreshKey, setRefreshKey] = useState(0); - - // refreshCardDisplay 이벤트 리스너 - useEffect(() => { - const handleRefreshCardDisplay = () => { - console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침"); - setRefreshKey((prev) => prev + 1); - }; - - window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay); - - return () => { - window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay); - }; - }, []); - - // 필터 상태 변경 래퍼 - const setFilters = useCallback((newFilters: TableFilter[]) => { - setFiltersInternal(newFilters); - }, []); - - // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) - const [columnMeta, setColumnMeta] = useState< - Record - >({}); - const [categoryMappings, setCategoryMappings] = useState< - Record> - >({}); - - // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) - const [selectedRows, setSelectedRows] = useState>(new Set()); - - // 상세보기 모달 상태 - const [viewModalOpen, setViewModalOpen] = useState(false); - const [selectedData, setSelectedData] = useState(null); - - // 편집 모달 상태 - const [editModalOpen, setEditModalOpen] = useState(false); - const [editData, setEditData] = useState(null); - - // 카드 액션 핸들러 - const handleCardView = (data: any) => { - // console.log("👀 상세보기 클릭:", data); - setSelectedData(data); - setViewModalOpen(true); - }; - - const handleCardEdit = (data: any) => { - // console.log("✏️ 편집 클릭:", data); - setEditData({ ...data }); // 복사본 생성 - setEditModalOpen(true); - }; - - // 삭제 핸들러 - const handleCardDelete = async (data: any, index: number) => { - // 사용자 확인 - if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) { - return; - } - - try { - const tableNameToUse = tableName || component.componentConfig?.tableName; - if (!tableNameToUse) { - alert("테이블 정보가 없습니다."); - return; - } - - // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) - const deleteData = [data]; - - - // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) - // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 - // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 - const response = await apiClient.request({ - method: 'DELETE', - url: `/table-management/tables/${tableNameToUse}/delete`, - data: deleteData, - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.data.success) { - alert("삭제되었습니다."); - - // 로컬 상태에서 삭제된 항목 제거 - setLoadedTableData(prev => prev.filter((item, idx) => idx !== index)); - - // 선택된 항목이면 선택 해제 - const cardKey = getCardKey(data, index); - if (selectedRows.has(cardKey)) { - const newSelectedRows = new Set(selectedRows); - newSelectedRows.delete(cardKey); - setSelectedRows(newSelectedRows); - } - } else { - alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); - } - } catch (error: any) { - const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; - alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); - } - }; - - // 편집 폼 데이터 변경 핸들러 - const handleEditFormChange = (key: string, value: string) => { - setEditData((prev: any) => ({ - ...prev, - [key]: value - })); - }; - - // 편집 저장 핸들러 - const handleEditSave = async () => { - // console.log("💾 편집 저장:", editData); - - try { - // TODO: 실제 API 호출로 데이터 업데이트 - // await tableTypeApi.updateTableData(tableName, editData); - - // console.log("✅ 편집 저장 완료"); - alert("✅ 저장되었습니다!"); - - // 모달 닫기 - setEditModalOpen(false); - setEditData(null); - - // 데이터 새로고침 (필요시) - // loadTableData(); - - } catch (error) { - alert("저장에 실패했습니다."); - } - }; - - // 테이블 데이터 로딩 - useEffect(() => { - const loadTableData = async () => { - // 디자인 모드에서는 테이블 데이터를 로드하지 않음 - if (isDesignMode) { - setLoading(false); - setInitialLoadDone(true); - return; - } - - // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) - // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 - const isRightPanelEarly = splitPanelPosition === "right"; - const hasSelectedLeftDataEarly = splitPanelContext?.selected_left_data && - Object.keys(splitPanelContext.selected_left_data).length > 0; - - if (isRightPanelEarly && !hasSelectedLeftDataEarly) { - // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) - // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 - if (!initialLoadDone) { - setLoadedTableData([]); - } - setLoading(false); - setInitialLoadDone(true); - return; - } - - // tableName 확인 (props에서 전달받은 tableName 또는 componentConfig에서 추출) - const tableNameToUse = tableName || component.componentConfig?.tableName; - - if (!tableNameToUse) { - setLoading(false); - setInitialLoadDone(true); - return; - } - - // 연결 필터 확인 (분할 패널 내부일 때) - let linkedFilterValues: Record = {}; - let hasLinkedFiltersConfigured = false; - let hasSelectedLeftData = false; - - if (splitPanelContext) { - // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) - const linkedFiltersConfig = splitPanelContext.linkedFilters || []; - hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || - filter.targetColumn === tableNameToUse - ); - - // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = splitPanelContext.selected_left_data && - Object.keys(splitPanelContext.selected_left_data).length > 0; - - linkedFilterValues = splitPanelContext.getLinkedFilterValues(); - // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) - // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 - const tableSpecificFilters: Record = {}; - for (const [key, value] of Object.entries(linkedFilterValues)) { - // key가 "테이블명.컬럼명" 형식인 경우 - if (key.includes(".")) { - const [tblName, columnName] = key.split("."); - if (tblName === tableNameToUse) { - // 연결 필터는 코드 값이므로 equals 연산자 사용 - tableSpecificFilters[columnName] = { value, operator: "equals" }; - hasLinkedFiltersConfigured = true; - } - } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) - tableSpecificFilters[key] = { value, operator: "equals" }; - } - } - linkedFilterValues = tableSpecificFilters; - - } - - // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 - // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 - // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 - const isRightPanelFromContext = splitPanelPosition === "right"; - const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId - ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" - : false; - const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; - const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - - - if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { - setLoadedTableData([]); - setLoading(false); - setInitialLoadDone(true); - return; - } - - try { - setLoading(true); - - // API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함) - const apiParams: Record = { - page: 1, - size: 50, // 카드 표시용으로 적당한 개수 - search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, - }; - - // 조인 컬럼 설정 가져오기 (componentConfig에서) - const joinColumnsConfig = component.componentConfig?.joinColumns || []; - const entityJoinColumns = joinColumnsConfig - .filter((col: any) => col.isJoinColumn) - .map((col: any) => ({ - columnName: col.columnName, - sourceColumn: col.sourceColumn, - referenceTable: col.referenceTable, - referenceColumn: col.referenceColumn, - displayColumn: col.referenceColumn, - label: col.label, - joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가 - sourceTable: tableNameToUse, // 기준 테이블 - })); - - // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 - // 조인 컬럼이 있으면 entityJoinApi 사용 - let dataResponse; - if (entityJoinColumns.length > 0) { - console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns); - dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, { - ...apiParams, - additionalJoinColumns: entityJoinColumns, - }); - } else { - dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams); - } - - const [columnsResponse, inputTypesResponse] = await Promise.all([ - tableTypeApi.getColumns(tableNameToUse), - tableTypeApi.getColumnInputTypes(tableNameToUse), - ]); - - setLoadedTableData(dataResponse.data); - setLoadedTableColumns(columnsResponse); - - // 컬럼 메타 정보 설정 (inputType 포함) - const meta: Record = {}; - inputTypesResponse.forEach((item: any) => { - meta[item.column_name] = { - web_type: item.web_type, - inputType: item.input_type, - codeCategory: item.code_category, - }; - }); - setColumnMeta(meta); - - // 카테고리 타입 컬럼 찾기 및 매핑 로드 - const categoryColumns = Object.entries(meta) - .filter(([_, m]) => m.inputType === "category") - .map(([columnName]) => columnName); - - - if (categoryColumns.length > 0) { - const mappings: Record> = {}; - - for (const columnName of categoryColumns) { - try { - const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`); - - - if (response.data.success && response.data.data) { - const mapping: Record = {}; - response.data.data.forEach((item: any) => { - const code = item.value_code || item.category_code || item.code || item.value; - const label = item.value_label || item.category_name || item.name || item.label || code; - // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) - const rawColor = item.color ?? item.badge_color; - const color = (rawColor && rawColor !== "none") ? rawColor : undefined; - mapping[code] = { label, color }; - }); - mappings[columnName] = mapping; - } - } catch (error) { - // 카테고리 매핑 로드 실패 시 무시 - } - } - - setCategoryMappings(mappings); - } - } catch (error) { - setLoadedTableData([]); - setLoadedTableColumns([]); - } finally { - setLoading(false); - setInitialLoadDone(true); - } - }; - - loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selected_left_data, splitPanelPosition, refreshKey]); - - // 컴포넌트 설정 (기본값 보장) - const componentConfig = { - cardsPerRow: 3, // 기본값 3 (한 행당 카드 수) - cardSpacing: 16, - cardStyle: { - showTitle: true, - showSubtitle: true, - showDescription: true, - showImage: false, - showActions: true, - maxDescriptionLength: 100, - imagePosition: "top", - imageSize: "medium", - }, - columnMapping: {}, - dataSource: "table", - staticData: [], - ...config, - ...component.config, - ...component.componentConfig, - } as CardDisplayConfig; - - // 컴포넌트 기본 스타일 - const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - position: "relative", - backgroundColor: "transparent", - }; - - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) - // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 - - if (isDesignMode) { - componentStyle.border = "1px dashed hsl(var(--border))"; - componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; - } - - // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) - const isRightPanelForDisplay = splitPanelPosition === "right" || - (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); - const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - const selectedLeftDataForDisplay = splitPanelContext?.selected_left_data; - const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && - Object.keys(selectedLeftDataForDisplay).length > 0; - - // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 - useEffect(() => { - if (hasSelectedLeftDataForDisplay) { - setHasEverSelectedLeftData(true); - } - }, [hasSelectedLeftDataForDisplay]); - - // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 - // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 - const shouldHideDataForRightPanel = isRightPanelForDisplay && - !hasEverSelectedLeftData && - !hasSelectedLeftDataForDisplay; - - // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) - const displayData = useMemo(() => { - // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 - if (shouldHideDataForRightPanel) { - return []; - } - - // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) - if (loadedTableData.length > 0) { - return loadedTableData; - } - - // props로 전달받은 테이블 데이터가 있으면 사용 - if (tableData.length > 0) { - return tableData; - } - - if (componentConfig.staticData && componentConfig.staticData.length > 0) { - return componentConfig.staticData; - } - - // 데이터가 없으면 빈 배열 반환 - return []; - }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); - - // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) - const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; - - // 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언) - const getCardKey = useCallback((data: any, index: number): string => { - return String(data.id || data.objid || data.ID || index); - }, []); - - // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) - const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => { - // 단일 선택: 새로운 Set 생성 (기존 선택 초기화) - const newSelectedRows = new Set(); - - if (checked) { - // 선택 시 해당 카드만 선택 - newSelectedRows.add(cardKey); - } - // checked가 false면 빈 Set (선택 해제) - - setSelectedRows(newSelectedRows); - - // 선택된 카드 데이터 계산 - const selectedRowsData = displayData.filter((item, index) => - newSelectedRows.has(getCardKey(item, index)) - ); - - // onFormDataChange 호출 - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData, - }); - } - - // modalDataStore에 선택된 데이터 저장 - const tableNameToUse = componentConfig.dataSource?.tableName || tableName; - if (tableNameToUse && selectedRowsData.length > 0) { - const modalItems = selectedRowsData.map((row, idx) => ({ - id: getCardKey(row, idx), - originalData: row, - additionalData: {}, - })); - useModalDataStore.getState().setData(tableNameToUse, modalItems); - } else if (tableNameToUse && selectedRowsData.length === 0) { - useModalDataStore.getState().clearData(tableNameToUse); - } - - // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - if (checked) { - splitPanelContext.setSelectedLeftData(data); - } else { - splitPanelContext.setSelectedLeftData(null); - } - } - }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); - - const handleCardClick = useCallback((data: any, index: number) => { - const cardKey = getCardKey(data, index); - const isCurrentlySelected = selectedRows.has(cardKey); - - // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택 - handleCardSelection(cardKey, data, !isCurrentlySelected); - - if (componentConfig.onCardClick) { - componentConfig.onCardClick(data); - } - }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]); - - // DataProvidable 인터페이스 구현 (테이블 리스트와 동일) - const dataProvider = useMemo(() => ({ - componentId: component.id, - component_type: "card-display" as const, - - getSelectedData: () => { - const selectedData = displayData.filter((item, index) => - selectedRows.has(getCardKey(item, index)) - ); - return selectedData; - }, - - getAllData: () => { - return displayData; - }, - - clearSelection: () => { - setSelectedRows(new Set()); - }, - }), [component.id, displayData, selectedRows, getCardKey]); - - // ScreenContext에 데이터 제공자로 등록 - useEffect(() => { - if (screenContext && component.id) { - screenContext.registerDataProvider(component.id, dataProvider); - - return () => { - screenContext.unregisterDataProvider(component.id); - }; - } - }, [screenContext, component.id, dataProvider]); - - // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) - const tableId = `card-display-${component.id}`; - const tableNameToUse = tableName || component.componentConfig?.tableName || ''; - const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; - - // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) - const loadedTableDataRef = useRef(loadedTableData); - const categoryMappingsRef = useRef(categoryMappings); - - useEffect(() => { - loadedTableDataRef.current = loadedTableData; - }, [loadedTableData]); - - useEffect(() => { - categoryMappingsRef.current = categoryMappings; - }, [categoryMappings]); - - // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) - // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) - const mountCountRef = useRef(0); - - useEffect(() => { - mountCountRef.current += 1; - const currentMount = mountCountRef.current; - - if (!tableNameToUse || isDesignMode) return; - - // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 - const isRightPanel = splitPanelPosition === "right" || - (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); - const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; - const hasSelectedLeftData = splitPanelContext?.selected_left_data && - Object.keys(splitPanelContext.selected_left_data).length > 0; - - // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) - if (isRightPanel && !hasSelectedLeftData) { - // 데이터를 지우지 않고 로딩만 false로 설정 - setLoading(false); - return; - } - - // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) - // 필터 변경이 아닌 경우 스킵 - if (currentMount <= 2 && filters.length === 0) { - return; - } - - const loadFilteredData = async () => { - try { - // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) - - // 필터 값을 검색 파라미터로 변환 - const searchParams: Record = {}; - filters.forEach(filter => { - if (filter.value !== undefined && filter.value !== null && filter.value !== '') { - searchParams[filter.columnName] = filter.value; - } - }); - - // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) - const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { - page: 1, - size: 50, - search: searchParams, - }); - - setLoadedTableData(dataResponse.data); - - // 데이터 건수 업데이트 - if (tableOptionsContext) { - tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); - } - } catch (error) { - // 필터 적용 실패 시 무시 - } - }; - - // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) - loadFilteredData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selected_left_data, splitPanelPosition]); - - // 컬럼 고유 값 조회 함수 (select 타입 필터용) - const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { - if (!tableNameToUse) return []; - - try { - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValues = new Set(); - loadedTableDataRef.current.forEach(row => { - const value = row[columnName]; - if (value !== null && value !== undefined && value !== '') { - uniqueValues.add(String(value)); - } - }); - - // 카테고리 매핑이 있으면 라벨 적용 - const mapping = categoryMappingsRef.current[columnName]; - return Array.from(uniqueValues).map(value => ({ - value, - label: mapping?.[value]?.label || value, - })); - } catch (error) { - return []; - } - }, [tableNameToUse]); - - // TableOptionsContext에 등록 - // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) - const registerTableRef = useRef(tableOptionsContext?.registerTable); - const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); - - // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) - const setFiltersRef = useRef(setFiltersInternal); - const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); - - useEffect(() => { - registerTableRef.current = tableOptionsContext?.registerTable; - unregisterTableRef.current = tableOptionsContext?.unregisterTable; - }, [tableOptionsContext]); - - useEffect(() => { - setFiltersRef.current = setFiltersInternal; - }, [setFiltersInternal]); - - useEffect(() => { - getColumnUniqueValuesRef.current = getColumnUniqueValues; - }, [getColumnUniqueValues]); - - // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) - const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.column_name)); - - useEffect(() => { - if (!registerTableRef.current || !unregisterTableRef.current) return; - if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; - - // 컬럼 정보를 TableColumn 형식으로 변환 - const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ - column_name: col.column_name, - column_label: col.display_name || col.column_label || col.column_name, - input_type: columnMeta[col.column_name]?.inputType || 'text', - visible: true, - width: 200, - sortable: true, - filterable: true, - })); - - // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 - const onFilterChangeWrapper = (newFilters: TableFilter[]) => { - setFiltersRef.current(newFilters); - }; - - const getColumnUniqueValuesWrapper = async (columnName: string) => { - return getColumnUniqueValuesRef.current(columnName); - }; - - const registration = { - tableId, - label: tableLabel, - table_name: tableNameToUse, - columns, - dataCount: loadedTableData.length, - onFilterChange: onFilterChangeWrapper, - onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 - onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 - getColumnUniqueValues: getColumnUniqueValuesWrapper, - }; - - registerTableRef.current(registration); - - const unregister = unregisterTableRef.current; - const currentTableId = tableId; - - return () => { - unregister(currentTableId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isDesignMode, - tableId, - tableNameToUse, - tableLabel, - columnsKey, // 컬럼 변경 시에만 재등록 - ]); - - // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 - // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) - if (shouldHideDataForRightPanel) { - return ( -
-
-
좌측에서 항목을 선택해주세요
-
선택한 항목의 관련 데이터가 여기에 표시됩니다
-
-
- ); - } - - // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 - // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) - if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { - return ( -
-
테이블 데이터를 로드하는 중...
-
- ); - } - - // 컨테이너 스타일 - 통일된 디자인 시스템 적용 - const containerStyle: React.CSSProperties = { - display: "grid", - gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수) - gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시 - gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격 - padding: "16px", // 패딩 - width: "100%", - height: "100%", - background: "transparent", // 배경색 제거 - overflow: "auto", - borderRadius: "0", // 라운드 제거 - }; - - // 카드 스타일 - 컴팩트한 디자인 - const cardStyle: React.CSSProperties = { - backgroundColor: "hsl(var(--card))", - border: "1px solid hsl(var(--border))", - borderRadius: "8px", - padding: "16px", - boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", - transition: "all 0.2s ease", - overflow: "hidden", - display: "flex", - flexDirection: "column", - position: "relative", - cursor: isDesignMode ? "pointer" : "default", - width: "100%", // 전체 너비 차지 - }; - - // 텍스트 자르기 함수 - const truncateText = (text: string, maxLength: number) => { - if (!text) return ""; - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + "..."; - }; - - // 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환) - const getColumnValueAsString = (data: any, columnName?: string): string => { - if (!columnName) return ""; - const value = data[columnName]; - if (value === null || value === undefined || value === "") return ""; - - // 카테고리 타입인 경우 매핑된 라벨 반환 - const meta = columnMeta[columnName]; - if (meta?.inputType === "category") { - const mapping = categoryMappings[columnName]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - return categoryData?.label || valueStr; - } - - return String(value); - }; - - // 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시) - const getColumnValue = (data: any, columnName?: string): React.ReactNode => { - if (!columnName) return ""; - const value = data[columnName]; - if (value === null || value === undefined || value === "") return ""; - - // 카테고리 타입인 경우 매핑된 라벨과 배지로 표시 - const meta = columnMeta[columnName]; - if (meta?.inputType === "category") { - const mapping = categoryMappings[columnName]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - const displayLabel = categoryData?.label || valueStr; - const displayColor = categoryData?.color; - - // 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음) - if (!displayColor || displayColor === "none") { - return displayLabel; - } - - return ( - - {displayLabel} - - ); - } - - return String(value); - }; - - // 컬럼명을 라벨로 변환하는 헬퍼 함수 - const getColumnLabel = (columnName: string) => { - if (!actualTableColumns || actualTableColumns.length === 0) { - // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 - return formatColumnName(columnName); - } - const column = actualTableColumns.find( - (col) => col.column_name === columnName - ); - const label = column?.display_name || column?.column_label || column?.label; - return label || formatColumnName(columnName); - }; - - // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) - const formatColumnName = (columnName: string) => { - // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 - return columnName - .replace(/_/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()); - }; - - // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 - const getAutoFallbackValue = (data: any, type: "title" | "subtitle" | "description") => { - const keys = Object.keys(data); - switch (type) { - case "title": - // 이름 관련 필드 우선 검색 - return data.name || data.title || data.label || data[keys[0]] || "제목 없음"; - case "subtitle": - // 직책, 부서, 카테고리 관련 필드 검색 - return data.position || data.role || data.department || data.category || data.type || ""; - case "description": - // 설명, 내용 관련 필드 검색 - return data.description || data.content || data.summary || data.memo || ""; - default: - return ""; - } - }; - - // 이벤트 핸들러 - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(); - }; - - // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) - const safeDomProps = filterDOMProps(props); - - return ( - <> - -
-
- {displayData.length === 0 ? ( -
- 표시할 데이터가 없습니다. -
- ) : ( - displayData.map((data, index) => { - // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) - const titleValue = - getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); - - const subtitleValue = - getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || - getAutoFallbackValue(data, "subtitle"); - - const descriptionValue = - getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) || - getAutoFallbackValue(data, "description"); - - // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 - const imageColumn = componentConfig.columnMapping?.imageColumn || - Object.keys(data).find(key => { - const lowerKey = key.toLowerCase(); - return lowerKey.includes('image') || lowerKey.includes('photo') || - lowerKey.includes('avatar') || lowerKey.includes('thumbnail') || - lowerKey.includes('picture') || lowerKey.includes('img'); - }); - - // 이미지 값 가져오기 (직접 접근 + 폴백) - const imageValue = imageColumn - ? data[imageColumn] - : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""); - - // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 - const shouldShowImage = componentConfig.cardStyle?.showImage !== false; - - // 이미지 URL 생성 (TableListComponent와 동일한 로직 사용) - const imageUrl = imageValue ? getFullImageUrl(imageValue) : ""; - - const cardKey = getCardKey(data, index); - const isCardSelected = selectedRows.has(cardKey); - - return ( -
handleCardClick(data, index)} - > - {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} - {shouldShowImage && ( -
- {imageUrl ? ( - {titleValue { - // 이미지 로드 실패 시 기본 아이콘으로 대체 - e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E"; - }} - /> - ) : ( -
- 👤 -
- )} -
- )} - - {/* 우측 컨텐츠 영역 */} -
- {/* 타이틀 + 서브타이틀 */} - {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( -
- {componentConfig.cardStyle?.showTitle && ( -

{titleValue}

- )} - {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( - {subtitleValue} - )} -
- )} - - {/* 추가 표시 컬럼들 - 가로 배치 */} - {componentConfig.columnMapping?.displayColumns && - componentConfig.columnMapping.displayColumns.length > 0 && ( -
- {componentConfig.columnMapping.displayColumns.map((columnName, idx) => { - const value = getColumnValue(data, columnName); - if (!value) return null; - - return ( -
- {getColumnLabel(columnName)}: - {value} -
- ); - })} -
- )} - - {/* 카드 설명 */} - {componentConfig.cardStyle?.showDescription && descriptionValue && ( -
-

- {truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)} -

-
- )} - - {/* 카드 액션 - 설정에 따라 표시 */} - {(componentConfig.cardStyle?.showActions ?? true) && ( -
- {(componentConfig.cardStyle?.showViewButton ?? true) && ( - - )} - {(componentConfig.cardStyle?.showEditButton ?? true) && ( - - )} - {(componentConfig.cardStyle?.showDeleteButton ?? false) && ( - - )} -
- )} -
-
- ); - }) - )} -
-
- - {/* 상세보기 모달 */} - - - - - 📋 - 상세 정보 - - - - {selectedData && ( -
-
- {Object.entries(selectedData) - .filter(([key, value]) => value !== null && value !== undefined && value !== '') - .map(([key, value]) => { - // 카테고리 타입인 경우 배지로 표시 - const meta = columnMeta[key]; - let displayValue: React.ReactNode = String(value); - - if (meta?.inputType === "category") { - const mapping = categoryMappings[key]; - const valueStr = String(value); - const categoryData = mapping?.[valueStr]; - const displayLabel = categoryData?.label || valueStr; - const displayColor = categoryData?.color; - - // 색상이 있고 "none"이 아닌 경우에만 배지로 표시 - if (displayColor && displayColor !== "none") { - displayValue = ( - - {displayLabel} - - ); - } else { - // 배지 없음: 일반 텍스트로 표시 - displayValue = displayLabel; - } - } - - return ( -
-
- {getColumnLabel(key)} -
-
- {displayValue} -
-
- ); - }) - } -
- -
- -
-
- )} -
-
- - {/* 편집 모달 */} - - - - - ✏️ - 데이터 편집 - - - - {editData && ( -
-
- {Object.entries(editData) - .filter(([key, value]) => value !== null && value !== undefined) - .map(([key, value]) => ( -
- - handleEditFormChange(key, e.target.value)} - className="w-full" - placeholder={`${key} 입력`} - /> -
- )) - } -
- -
- - -
-
- )} -
-
- - ); -}; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx deleted file mode 100644 index bbd71caa..00000000 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayConfigPanel.tsx +++ /dev/null @@ -1,732 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo } from "react"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { tableManagementApi } from "@/lib/api/tableManagement"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface CardDisplayConfigPanelProps { - config: any; - onChange: (config: any) => void; - screenTableName?: string; - tableColumns?: any[]; -} - -interface EntityJoinColumn { - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; - suggestedLabel: string; -} - -interface JoinTable { - tableName: string; - currentDisplayColumn: string; - joinConfig?: { - sourceColumn: string; - }; - availableColumns: Array<{ - columnName: string; - columnLabel: string; - dataType: string; - description?: string; - }>; -} - -/** - * CardDisplay 설정 패널 - * 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원 - */ -export const CardDisplayConfigPanel: React.FC = ({ - config, - onChange, - screenTableName, - tableColumns = [], -}) => { - // 테이블 선택 상태 - const [tableComboboxOpen, setTableComboboxOpen] = useState(false); - const [allTables, setAllTables] = useState>([]); - const [loadingTables, setLoadingTables] = useState(false); - const [availableColumns, setAvailableColumns] = useState([]); - const [loadingColumns, setLoadingColumns] = useState(false); - - // 엔티티 조인 컬럼 상태 - const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: EntityJoinColumn[]; - joinTables: JoinTable[]; - }>({ availableColumns: [], joinTables: [] }); - const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); - - // 현재 사용할 테이블명 - const targetTableName = useMemo(() => { - if (config.useCustomTable && config.customTableName) { - return config.customTableName; - } - return config.tableName || screenTableName; - }, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]); - - // 전체 테이블 목록 로드 - useEffect(() => { - const loadAllTables = async () => { - setLoadingTables(true); - try { - const response = await tableManagementApi.getTableList(); - if (response.success && response.data) { - setAllTables(response.data.map((t: any) => ({ - tableName: t.tableName || t.table_name, - displayName: t.tableLabel || t.displayName || t.tableName || t.table_name, - }))); - } - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadAllTables(); - }, []); - - // 선택된 테이블의 컬럼 로드 - useEffect(() => { - const loadColumns = async () => { - if (!targetTableName) { - setAvailableColumns([]); - return; - } - - // 커스텀 테이블이 아니면 props로 받은 tableColumns 사용 - if (!config.useCustomTable && tableColumns && tableColumns.length > 0) { - setAvailableColumns(tableColumns); - return; - } - - setLoadingColumns(true); - try { - const result = await tableManagementApi.getColumnList(targetTableName); - if (result.success && result.data?.columns) { - setAvailableColumns(result.data.columns.map((col: any) => ({ - columnName: col.columnName, - columnLabel: col.displayName || col.columnLabel || col.columnName, - dataType: col.dataType, - }))); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - setAvailableColumns([]); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [targetTableName, config.useCustomTable, tableColumns]); - - // 엔티티 조인 컬럼 정보 가져오기 - useEffect(() => { - const fetchEntityJoinColumns = async () => { - if (!targetTableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } - - setLoadingEntityJoins(true); - try { - const result = await entityJoinApi.getEntityJoinColumns(targetTableName); - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], - }); - } catch (error) { - console.error("Entity 조인 컬럼 조회 오류:", error); - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); - } - }; - - fetchEntityJoinColumns(); - }, [targetTableName]); - - // 테이블 선택 핸들러 - const handleTableSelect = (tableName: string, isScreenTable: boolean) => { - if (isScreenTable) { - // 화면 기본 테이블 선택 - onChange({ - ...config, - useCustomTable: false, - customTableName: undefined, - tableName: tableName, - columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화 - }); - } else { - // 다른 테이블 선택 - onChange({ - ...config, - useCustomTable: true, - customTableName: tableName, - tableName: tableName, - columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화 - }); - } - setTableComboboxOpen(false); - }; - - // 현재 선택된 테이블 표시명 가져오기 - const getSelectedTableDisplay = () => { - if (!targetTableName) return "테이블을 선택하세요"; - const found = allTables.find(t => t.tableName === targetTableName); - return found?.displayName || targetTableName; - }; - - const handleChange = (key: string, value: any) => { - onChange({ ...config, [key]: value }); - }; - - const handleNestedChange = (path: string, value: any) => { - const keys = path.split("."); - let newConfig = { ...config }; - let current = newConfig; - - for (let i = 0; i < keys.length - 1; i++) { - if (!current[keys[i]]) { - current[keys[i]] = {}; - } - current = current[keys[i]]; - } - - current[keys[keys.length - 1]] = value; - onChange(newConfig); - }; - - // 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트 - const handleColumnSelect = (path: string, columnName: string) => { - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === columnName - ); - - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const existingJoinColumn = joinColumnsConfig.find( - (jc: any) => jc.columnName === columnName - ); - - if (!existingJoinColumn) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - - const newJoinColumnConfig = { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }; - - onChange({ - ...config, - columnMapping: { - ...config.columnMapping, - [path.split(".")[1]]: columnName, - }, - joinColumns: [...joinColumnsConfig, newJoinColumnConfig], - }); - return; - } - } - - handleNestedChange(path, columnName); - }; - - // 표시 컬럼 추가 - const addDisplayColumn = () => { - const currentColumns = config.columnMapping?.displayColumns || []; - const newColumns = [...currentColumns, ""]; - handleNestedChange("columnMapping.displayColumns", newColumns); - }; - - // 표시 컬럼 삭제 - const removeDisplayColumn = (index: number) => { - const currentColumns = [...(config.columnMapping?.displayColumns || [])]; - currentColumns.splice(index, 1); - handleNestedChange("columnMapping.displayColumns", currentColumns); - }; - - // 표시 컬럼 값 변경 - const updateDisplayColumn = (index: number, value: string) => { - const currentColumns = [...(config.columnMapping?.displayColumns || [])]; - currentColumns[index] = value; - - const joinColumn = entityJoinColumns.availableColumns.find( - (col) => col.joinAlias === value - ); - - if (joinColumn) { - const joinColumnsConfig = config.joinColumns || []; - const existingJoinColumn = joinColumnsConfig.find( - (jc: any) => jc.columnName === value - ); - - if (!existingJoinColumn) { - const joinTableInfo = entityJoinColumns.joinTables?.find( - (jt) => jt.tableName === joinColumn.tableName - ); - - const newJoinColumnConfig = { - columnName: joinColumn.joinAlias, - label: joinColumn.suggestedLabel || joinColumn.columnLabel, - sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", - referenceTable: joinColumn.tableName, - referenceColumn: joinColumn.columnName, - isJoinColumn: true, - }; - - onChange({ - ...config, - columnMapping: { - ...config.columnMapping, - displayColumns: currentColumns, - }, - joinColumns: [...joinColumnsConfig, newJoinColumnConfig], - }); - return; - } - } - - handleNestedChange("columnMapping.displayColumns", currentColumns); - }; - - // 테이블별로 조인 컬럼 그룹화 - const joinColumnsByTable: Record = {}; - entityJoinColumns.availableColumns.forEach((col) => { - if (!joinColumnsByTable[col.tableName]) { - joinColumnsByTable[col.tableName] = []; - } - joinColumnsByTable[col.tableName].push(col); - }); - - // 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props) - const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns); - - // 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI) - const renderColumnSelect = ( - value: string, - onChangeHandler: (value: string) => void, - placeholder: string = "컬럼을 선택하세요" - ) => { - return ( - - ); - }; - - return ( -
-
카드 디스플레이 설정
- - {/* 테이블 선택 */} -
- - - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {/* 화면 기본 테이블 */} - {screenTableName && ( - - handleTableSelect(screenTableName, true)} - className="text-xs" - > - - - {allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName} - - - )} - - {/* 전체 테이블 */} - - {allTables - .filter(t => t.tableName !== screenTableName) - .map((table) => ( - handleTableSelect(table.tableName, false)} - className="text-xs" - > - - - {table.displayName} - - ))} - - - - - - {config.useCustomTable && ( -

- 화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다. -

- )} -
- - {/* 테이블이 선택된 경우 컬럼 매핑 설정 */} - {(currentTableColumns.length > 0 || loadingColumns) && ( -
-
컬럼 매핑
- - {(loadingEntityJoins || loadingColumns) && ( -
- {loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."} -
- )} - -
- - {renderColumnSelect( - config.columnMapping?.titleColumn || "", - (value) => handleColumnSelect("columnMapping.titleColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.subtitleColumn || "", - (value) => handleColumnSelect("columnMapping.subtitleColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.descriptionColumn || "", - (value) => handleColumnSelect("columnMapping.descriptionColumn", value) - )} -
- -
- - {renderColumnSelect( - config.columnMapping?.imageColumn || "", - (value) => handleColumnSelect("columnMapping.imageColumn", value) - )} -
- - {/* 동적 표시 컬럼 추가 */} -
-
- - -
- -
- {(config.columnMapping?.displayColumns || []).map((column: string, index: number) => ( -
-
- {renderColumnSelect( - column, - (value) => updateDisplayColumn(index, value) - )} -
- -
- ))} - - {(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && ( -
- "컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요 -
- )} -
-
-
- )} - - {/* 카드 스타일 설정 */} -
-
카드 스타일
- -
-
- - handleChange("cardsPerRow", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
- -
- - handleChange("cardSpacing", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
-
- -
-
- handleNestedChange("cardStyle.showTitle", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showSubtitle", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showDescription", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showImage", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showActions", checked)} - /> - -
- - {/* 개별 버튼 설정 */} - {(config.cardStyle?.showActions ?? true) && ( -
-
- handleNestedChange("cardStyle.showViewButton", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showEditButton", checked)} - /> - -
- -
- handleNestedChange("cardStyle.showDeleteButton", checked)} - /> - -
-
- )} -
- -
- - handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))} - className="h-8 text-xs" - /> -
-
- - {/* 공통 설정 */} -
-
공통 설정
- -
- handleChange("disabled", checked)} - /> - -
- -
- handleChange("readonly", checked)} - /> - -
-
-
- ); -}; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx deleted file mode 100644 index b1d927e8..00000000 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayRenderer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import React from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { V2CardDisplayDefinition } from "./index"; -import { CardDisplayComponent } from "./CardDisplayComponent"; - -/** - * CardDisplay 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class CardDisplayRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = V2CardDisplayDefinition; - - render(): React.ReactElement { - return ; - } - - /** - * 컴포넌트별 특화 메서드들 - */ - - // text 타입 특화 속성 처리 - protected getCardDisplayProps() { - const baseProps = this.getWebTypeProps(); - - // text 타입에 특화된 추가 속성들 - return { - ...baseProps, - // 여기에 text 타입 특화 속성들 추가 - }; - } - - // 값 변경 처리 - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; - - // 포커스 처리 - protected handleFocus = () => { - // 포커스 로직 - }; - - // 블러 처리 - protected handleBlur = () => { - // 블러 로직 - }; -} - -// 자동 등록 실행 -CardDisplayRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/v2-card-display/README.md b/frontend/lib/registry/components/v2-card-display/README.md deleted file mode 100644 index 13bc2164..00000000 --- a/frontend/lib/registry/components/v2-card-display/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# CardDisplay 컴포넌트 - -테이블 데이터를 카드 형태로 표시하는 컴포넌트 - -## 개요 - -- **ID**: `card-display` -- **카테고리**: display -- **웹타입**: text -- **작성자**: 개발팀 -- **버전**: 1.0.0 - -## 특징 - -- ✅ 자동 등록 시스템 -- ✅ 타입 안전성 -- ✅ Hot Reload 지원 -- ✅ 설정 패널 제공 -- ✅ 반응형 디자인 - -## 사용법 - -### 기본 사용법 - -```tsx -import { CardDisplayComponent } from "@/lib/registry/components/card-display"; - - -``` - -### 설정 옵션 - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| placeholder | string | "" | 플레이스홀더 텍스트 | -| maxLength | number | 255 | 최대 입력 길이 | -| minLength | number | 0 | 최소 입력 길이 | -| disabled | boolean | false | 비활성화 여부 | -| required | boolean | false | 필수 입력 여부 | -| readonly | boolean | false | 읽기 전용 여부 | - -## 이벤트 - -- `onChange`: 값 변경 시 -- `onFocus`: 포커스 시 -- `onBlur`: 포커스 해제 시 -- `onClick`: 클릭 시 - -## 스타일링 - -컴포넌트는 다음과 같은 스타일 옵션을 제공합니다: - -- `variant`: "default" | "outlined" | "filled" -- `size`: "sm" | "md" | "lg" - -## 예시 - -```tsx -// 기본 예시 - -``` - -## 개발자 정보 - -- **생성일**: 2025-09-15 -- **CLI 명령어**: `node scripts/create-component.js card-display "카드 디스플레이" "테이블 데이터를 카드 형태로 표시하는 컴포넌트" display text` -- **경로**: `lib/registry/components/card-display/` - -## 관련 문서 - -- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) -- [개발자 문서](https://docs.example.com/components/card-display) diff --git a/frontend/lib/registry/components/v2-card-display/index.ts b/frontend/lib/registry/components/v2-card-display/index.ts deleted file mode 100644 index 55ade000..00000000 --- a/frontend/lib/registry/components/v2-card-display/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import React from "react"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import type { WebType } from "@/types/screen"; -import { CardDisplayComponent } from "./CardDisplayComponent"; -import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel"; -import { CardDisplayConfig } from "./types"; -import { withContainerQuery } from "../../hoc/withContainerQuery"; - -/** - * CardDisplay 컴포넌트 정의 - * 테이블 데이터를 카드 형태로 표시하는 컴포넌트 - */ -export const V2CardDisplayDefinition = createComponentDefinition({ - id: "v2-card-display", - hidden: true, // Phase E: 통합 컴포넌트로 대체됨 - name: "카드 디스플레이", - name_eng: "CardDisplay Component", - description: "테이블 데이터를 카드 형태로 표시하는 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "text", - component: withContainerQuery(CardDisplayComponent, "v2-card-display"), - default_config: { - cardsPerRow: 3, // 기본값 3 (한 행당 카드 수) - cardSpacing: 16, - cardStyle: { - showTitle: true, - showSubtitle: true, - showDescription: true, - showImage: false, - showActions: true, - maxDescriptionLength: 100, - imagePosition: "top", - imageSize: "medium", - }, - columnMapping: {}, - dataSource: "table", - staticData: [], - }, - default_size: { width: 800, height: 400 }, - config_panel: V2CardDisplayConfigPanel, - icon: "Grid3x3", - tags: ["card", "display", "table", "grid"], - version: "1.0.0", - author: "개발팀", - documentation: - "테이블 데이터를 카드 형태로 표시하는 컴포넌트입니다. 레이아웃과 다르게 컴포넌트로서 재사용 가능하며, 다양한 설정이 가능합니다.", -}); - -// 컴포넌트는 CardDisplayRenderer에서 자동 등록됩니다 - -// 타입 내보내기 -export type { CardDisplayConfig } from "./types"; diff --git a/frontend/lib/registry/components/v2-card-display/types.ts b/frontend/lib/registry/components/v2-card-display/types.ts deleted file mode 100644 index 368e43cc..00000000 --- a/frontend/lib/registry/components/v2-card-display/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { ComponentConfig } from "@/types/component"; - -/** - * 카드 스타일 설정 - */ -export interface CardStyleConfig { - showTitle?: boolean; - showSubtitle?: boolean; - showDescription?: boolean; - showImage?: boolean; - maxDescriptionLength?: number; - imagePosition?: "top" | "left" | "right"; - imageSize?: "small" | "medium" | "large"; - showActions?: boolean; // 액션 버튼 표시 여부 (전체) - showViewButton?: boolean; // 상세보기 버튼 표시 여부 - showEditButton?: boolean; // 편집 버튼 표시 여부 - showDeleteButton?: boolean; // 삭제 버튼 표시 여부 -} - -/** - * 컬럼 매핑 설정 - */ -export interface ColumnMappingConfig { - titleColumn?: string; - subtitleColumn?: string; - descriptionColumn?: string; - imageColumn?: string; - displayColumns?: string[]; - actionColumns?: string[]; // 액션 버튼으로 표시할 컬럼들 -} - -/** - * CardDisplay 컴포넌트 설정 타입 - */ -export interface CardDisplayConfig extends ComponentConfig { - // 카드 레이아웃 설정 - cardsPerRow?: number; - cardSpacing?: number; - - // 카드 스타일 설정 - cardStyle?: CardStyleConfig; - - // 컬럼 매핑 설정 - columnMapping?: ColumnMappingConfig; - - // 컴포넌트별 테이블 설정 - useCustomTable?: boolean; - customTableName?: string; - tableName?: string; - isReadOnly?: boolean; - - // 테이블 데이터 설정 - dataSource?: "static" | "table" | "api"; - tableId?: string; - staticData?: any[]; - - // 공통 설정 - disabled?: boolean; - required?: boolean; - readonly?: boolean; - helperText?: string; - - // 스타일 관련 - variant?: "default" | "outlined" | "filled"; - size?: "sm" | "md" | "lg"; - - // 이벤트 관련 - onChange?: (value: any) => void; - onCardClick?: (data: any) => void; - onCardHover?: (data: any) => void; -} - -/** - * CardDisplay 컴포넌트 Props 타입 - */ -export interface CardDisplayProps { - id?: string; - name?: string; - value?: any; - config?: CardDisplayConfig; - className?: string; - style?: React.CSSProperties; - - // 이벤트 핸들러 - onChange?: (value: any) => void; - onFocus?: () => void; - onBlur?: () => void; - onClick?: () => void; -} diff --git a/frontend/lib/registry/components/v2-pivot-grid/PLAN.md b/frontend/lib/registry/components/v2-pivot-grid/PLAN.md deleted file mode 100644 index 7b96ab38..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/PLAN.md +++ /dev/null @@ -1,159 +0,0 @@ -# PivotGrid 컴포넌트 전체 구현 계획 - -## 개요 -DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현 - -## 현재 상태: ✅ 모든 기능 구현 완료! - ---- - -## 구현된 기능 목록 - -### 1. 기본 피벗 테이블 ✅ -- [x] 피벗 테이블 렌더링 -- [x] 행/열 확장/축소 -- [x] 합계/소계 표시 -- [x] 전체 확장/축소 버튼 - -### 2. 필드 패널 (드래그앤드롭) ✅ -- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터) -- [x] 각 영역에 배치된 필드 칩/태그 표시 -- [x] 필드 제거 버튼 (X) -- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용) -- [x] 영역 간 필드 이동 -- [x] 같은 영역 내 순서 변경 -- [x] 드래그 시 시각적 피드백 - -### 3. 필드 선택기 (모달) ✅ -- [x] 모달 열기/닫기 -- [x] 사용 가능한 필드 목록 -- [x] 필드 검색 기능 -- [x] 필드별 영역 선택 드롭다운 -- [x] 데이터 타입 아이콘 표시 -- [x] 집계 함수 선택 (데이터 영역) -- [x] 표시 모드 선택 (데이터 영역) - -### 4. 데이터 요약 (누계, % 모드) ✅ -- [x] 절대값 표시 (기본) -- [x] 행 총계 대비 % -- [x] 열 총계 대비 % -- [x] 전체 총계 대비 % -- [x] 행/열 방향 누계 -- [x] 이전 대비 차이 -- [x] 이전 대비 % 차이 - -### 5. 필터링 ✅ -- [x] 필터 팝업 컴포넌트 (FilterPopup) -- [x] 값 검색 기능 -- [x] 체크박스 기반 값 선택 -- [x] 포함/제외 모드 -- [x] 전체 선택/해제 -- [x] 선택된 항목 수 표시 - -### 6. Drill Down ✅ -- [x] 셀 더블클릭 시 상세 데이터 모달 -- [x] 원본 데이터 테이블 표시 -- [x] 검색 기능 -- [x] 정렬 기능 -- [x] 페이지네이션 -- [x] CSV/Excel 내보내기 - -### 7. Virtual Scrolling ✅ -- [x] useVirtualScroll 훅 (행) -- [x] useVirtualColumnScroll 훅 (열) -- [x] useVirtual2DScroll 훅 (행+열) -- [x] overscan 버퍼 지원 - -### 8. Excel 내보내기 ✅ -- [x] xlsx 라이브러리 사용 -- [x] 피벗 데이터 Excel 내보내기 -- [x] Drill Down 데이터 Excel 내보내기 -- [x] CSV 내보내기 (기본) -- [x] 스타일링 (헤더, 데이터, 총계) -- [x] 숫자 포맷 - -### 9. 차트 통합 ✅ -- [x] recharts 라이브러리 사용 -- [x] 막대 차트 -- [x] 누적 막대 차트 -- [x] 선 차트 -- [x] 영역 차트 -- [x] 파이 차트 -- [x] 범례 표시 -- [x] 커스텀 툴팁 -- [x] 차트 토글 버튼 - -### 10. 조건부 서식 (Conditional Formatting) ✅ -- [x] Color Scale (색상 그라데이션) -- [x] Data Bar (데이터 막대) -- [x] Icon Set (아이콘) -- [x] Cell Value (조건 기반 스타일) -- [x] ConfigPanel에서 설정 UI - -### 11. 상태 저장/복원 ✅ -- [x] usePivotState 훅 -- [x] localStorage/sessionStorage 지원 -- [x] 자동 저장 (디바운스) - -### 12. ConfigPanel 고도화 ✅ -- [x] 데이터 소스 설정 (테이블 선택) -- [x] 필드별 영역 설정 (행, 열, 데이터, 필터) -- [x] 총계 옵션 설정 -- [x] 스타일 설정 (테마, 교차 색상 등) -- [x] 내보내기 설정 (Excel/CSV) -- [x] 차트 설정 UI -- [x] 필드 선택기 설정 UI -- [x] 조건부 서식 설정 UI -- [x] 크기 설정 - ---- - -## 파일 구조 - -``` -pivot-grid/ -├── components/ -│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭) -│ ├── FieldChooser.tsx # 필드 선택기 모달 -│ ├── DrillDownModal.tsx # Drill Down 모달 -│ ├── FilterPopup.tsx # 필터 팝업 -│ ├── PivotChart.tsx # 차트 컴포넌트 -│ └── index.ts # 내보내기 -├── hooks/ -│ ├── useVirtualScroll.ts # 가상 스크롤 훅 -│ ├── usePivotState.ts # 상태 저장 훅 -│ └── index.ts # 내보내기 -├── utils/ -│ ├── aggregation.ts # 집계 함수 -│ ├── pivotEngine.ts # 피벗 엔진 -│ ├── exportExcel.ts # Excel 내보내기 -│ ├── conditionalFormat.ts # 조건부 서식 -│ └── index.ts # 내보내기 -├── types.ts # 타입 정의 -├── PivotGridComponent.tsx # 메인 컴포넌트 -├── PivotGridConfigPanel.tsx # 설정 패널 -├── PivotGridRenderer.tsx # 렌더러 -├── index.ts # 모듈 내보내기 -└── PLAN.md # 이 파일 -``` - ---- - -## 후순위 기능 (선택적) - -다음 기능들은 필요 시 추가 구현 가능: - -### 데이터 바인딩 확장 -- [ ] OLAP Data Source 연동 (복잡) -- [ ] GraphQL 연동 -- [ ] 실시간 데이터 업데이트 (WebSocket) - -### 고급 기능 -- [ ] 피벗 테이블 병합 (여러 데이터 소스) -- [ ] 계산 필드 (커스텀 수식) -- [ ] 데이터 정렬 옵션 강화 -- [ ] 그룹핑 옵션 (날짜 그룹핑 등) - ---- - -## 완료일: 2026-01-08 diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx deleted file mode 100644 index da81f575..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx +++ /dev/null @@ -1,1963 +0,0 @@ -"use client"; - -/** - * PivotGrid 메인 컴포넌트 - * 다차원 데이터 분석을 위한 피벗 테이블 - */ - -import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { - PivotGridProps, - PivotResult, - PivotFieldConfig, - PivotCellData, - PivotFlatRow, - PivotCellValue, - PivotGridState, -} from "./types"; -import { processPivotData, pathToKey } from "./utils/pivotEngine"; -import { exportPivotToExcel } from "./utils/exportExcel"; -import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; -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, - Download, - Settings, - RefreshCw, - Maximize2, - Minimize2, - 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; -}; - -// ==================== 서브 컴포넌트 ==================== - -// 행 헤더 셀 -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; - - return ( - 1 ? rowSpan : undefined} - > -
- {row.hasChildren && ( - - )} - {!row.hasChildren && } - {row.caption} -
- - ); -}; - -// 데이터 셀 -interface DataCellProps { - values: PivotCellValue[]; - isTotal?: boolean; - isSelected?: boolean; - onClick?: (e?: React.MouseEvent) => void; - onDoubleClick?: () => void; - conditionalStyle?: CellFormatStyle; -} - -const DataCell: React.FC = ({ - values, - isTotal = false, - isSelected = false, - onClick, - onDoubleClick, - conditionalStyle, -}) => { - // 조건부 서식 스타일 계산 - 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 ( - - - - - ); - } - - // 툴팁 내용 생성 - const tooltipContent = values.map((v) => - `${v.field || "값"}: ${v.formattedValue || v.value}` - ).join("\n"); - - // 단일 데이터 필드인 경우 - if (values.length === 1) { - return ( - - {/* Data Bar */} - {hasDataBar && ( -
- )} - - {icon && {icon}} - {values[0].formattedValue} - - - ); - } - - // 다중 데이터 필드인 경우 - return ( - <> - {values.map((val, idx) => ( - - {hasDataBar && ( -
- )} - - {icon && {icon}} - {val.formattedValue} - - - ))} - - ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotGridComponent: React.FC = ({ - title, - fields: initialFields = [], - totals = { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }, - style = { - theme: "default", - headerStyle: "default", - cellPadding: "normal", - borderStyle: "light", - alternateRowColors: true, - highlightTotals: true, - }, - fieldChooser, - chart: chartConfig, - allowExpandAll = true, - height = "auto", - maxHeight, - exportConfig, - data: externalData, - onCellClick, - onCellDoubleClick, - onFieldDrop, - onExpandChange, -}) => { - // 디버깅 로그 - console.log("🔶 PivotGridComponent props:", { - title, - hasExternalData: !!externalData, - externalDataLength: externalData?.length, - initialFieldsLength: initialFields?.length, - }); - - // 🆕 데이터 샘플 확인 - if (externalData && externalData.length > 0) { - console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); - console.log("🔶 전체 데이터 개수:", externalData.length); - } - - // 🆕 필드 설정 확인 - if (initialFields && initialFields.length > 0) { - console.log("🔶 필드 설정:", initialFields); - } - // ==================== 상태 ==================== - - const [fields, setFields] = useState(initialFields); - const [pivotState, setPivotState] = useState({ - expandedRowPaths: [], - expandedColumnPaths: [], - sortConfig: null, - filterConfig: {}, - }); - - // 🆕 초기 로드 시 자동 확장 (첫 레벨만) - const [isInitialExpanded, setIsInitialExpanded] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - 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(() => { - if (initialFields.length > 0) { - setFields(initialFields); - } - }, [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 || []; - - // ==================== 필드 분류 ==================== - - const rowFields = useMemo( - () => - fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - const columnFields = useMemo( - () => - fields - .filter((f) => f.area === "column" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - const dataFields = useMemo( - () => - fields - .filter((f) => f.area === "data" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - // 필터 영역 필드 - const filterFields = useMemo( - () => - fields - .filter((f) => f.area === "filter" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), - [fields] - ); - - // 사용 가능한 필드 목록 (FieldChooser용) - const availableFields = useMemo(() => { - if (data.length === 0) return []; - - const sampleRow = data[0]; - return Object.keys(sampleRow).map((key) => { - const existingField = fields.find((f) => f.field === key); - const value = sampleRow[key]; - - // 데이터 타입 추론 - let dataType: "string" | "number" | "date" | "boolean" = "string"; - if (typeof value === "number") dataType = "number"; - else if (typeof value === "boolean") dataType = "boolean"; - else if (value instanceof Date) dataType = "date"; - else if (typeof value === "string") { - // 날짜 문자열 감지 - if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; - } - - return { - field: key, - caption: existingField?.caption || key, - dataType, - isSelected: existingField?.visible !== false, - currentArea: existingField?.area, - }; - }); - }, [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 (!filteredData || filteredData.length === 0 || fields.length === 0) { - return null; - } - - const visibleFields = fields.filter((f) => f.visible !== false); - // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) - if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { - return null; - } - - const result = processPivotData( - filteredData, - visibleFields, - pivotState.expandedRowPaths, - pivotState.expandedColumnPaths - ); - - // 🆕 피벗 결과 확인 - console.log("🔶 피벗 처리 결과:", { - hasResult: !!result, - flatRowsCount: result?.flatRows?.length, - flatColumnsCount: result?.flatColumns?.length, - dataMatrixSize: result?.dataMatrix?.size, - expandedRowPaths: pivotState.expandedRowPaths.length, - expandedColumnPaths: pivotState.expandedColumnPaths.length, - }); - - return result; - }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); - - // 🆕 초기 로드 시 첫 레벨 자동 확장 - useEffect(() => { - if (!isInitialExpanded && pivotResult && pivotResult.flatRows.length > 0) { - // 첫 레벨 행들의 경로 수집 (level 0인 행들) - const firstLevelPaths = pivotResult.flatRows - .filter(row => row.level === 0 && row.hasChildren) - .map(row => row.path); - - if (firstLevelPaths.length > 0) { - console.log("🔶 초기 자동 확장:", firstLevelPaths); - setPivotState(prev => ({ - ...prev, - expandedRowPaths: firstLevelPaths, - })); - setIsInitialExpanded(true); - } - } - }, [pivotResult, isInitialExpanded]); - - // 조건부 서식용 전체 값 수집 - const allCellValues = useMemo(() => { - if (!pivotResult) return new Map(); - - const valuesByField = new Map(); - - // 데이터 매트릭스에서 모든 값 수집 - pivotResult.dataMatrix.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - // 행 총계 값 수집 - pivotResult.grandTotals.row.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - // 열 총계 값 수집 - pivotResult.grandTotals.column.forEach((values) => { - values.forEach((val) => { - if (val.field && typeof val.value === "number" && !isNaN(val.value)) { - const existing = valuesByField.get(val.field) || []; - existing.push(val.value); - valuesByField.set(val.field, existing); - } - }); - }); - - 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 => { - if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { - return {}; - } - const allValues = allCellValues.get(field) || []; - return getConditionalStyle(value, field, style.conditionalFormats, allValues); - }, - [style?.conditionalFormats, allCellValues] - ); - - // ==================== 이벤트 핸들러 ==================== - - // 필드 변경 - const handleFieldsChange = useCallback( - (newFields: PivotFieldConfig[]) => { - setFields(newFields); - }, - [] - ); - - // 행 확장/축소 - const handleToggleRowExpand = useCallback( - (path: string[]) => { - console.log("🔶 행 확장/축소 클릭:", path); - - setPivotState((prev) => { - const pathKey = pathToKey(path); - const existingIndex = prev.expandedRowPaths.findIndex( - (p) => pathToKey(p) === pathKey - ); - - let newPaths: string[][]; - if (existingIndex >= 0) { - console.log("🔶 행 축소:", path); - newPaths = prev.expandedRowPaths.filter( - (_, i) => i !== existingIndex - ); - } else { - console.log("🔶 행 확장:", path); - newPaths = [...prev.expandedRowPaths, path]; - } - - console.log("🔶 새로운 확장 경로:", newPaths); - onExpandChange?.(newPaths); - - return { - ...prev, - expandedRowPaths: newPaths, - }; - }); - }, - [onExpandChange] - ); - - // 전체 확장 - const handleExpandAll = useCallback(() => { - if (!pivotResult) return; - - const allRowPaths: string[][] = []; - pivotResult.flatRows.forEach((row) => { - if (row.hasChildren) { - allRowPaths.push(row.path); - } - }); - - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: allRowPaths, - expandedColumnPaths: [], - })); - }, [pivotResult]); - - // 전체 축소 - const handleCollapseAll = useCallback(() => { - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: [], - expandedColumnPaths: [], - })); - }, []); - - // 셀 클릭 - const handleCellClick = useCallback( - (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { - if (!onCellClick) return; - - const cellData: PivotCellData = { - value: values[0]?.value, - rowPath, - columnPath: colPath, - field: values[0]?.field, - }; - - onCellClick(cellData); - }, - [onCellClick] - ); - - // 셀 더블클릭 (Drill Down) - const handleCellDoubleClick = useCallback( - (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { - const cellData: PivotCellData = { - value: values[0]?.value, - rowPath, - columnPath: colPath, - field: values[0]?.field, - }; - - // Drill Down 모달 열기 - setDrillDownData({ open: true, cellData }); - - // 외부 콜백 호출 - if (onCellDoubleClick) { - onCellDoubleClick(cellData); - } - }, - [onCellDoubleClick] - ); - - // CSV 내보내기 - const handleExportCSV = useCallback(() => { - if (!pivotResult) return; - - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; - - let csv = ""; - - // 헤더 행 - const headerRow = [""].concat( - flatColumns.map((col) => col.caption || "총계") - ); - if (totals?.showRowGrandTotals) { - headerRow.push("총계"); - } - csv += headerRow.join(",") + "\n"; - - // 데이터 행 - flatRows.forEach((row) => { - const rowData = [row.caption]; - - flatColumns.forEach((col) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey); - rowData.push(values?.[0]?.value?.toString() || ""); - }); - - if (totals?.showRowGrandTotals) { - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - rowData.push(rowTotal?.[0]?.value?.toString() || ""); - } - - csv += rowData.join(",") + "\n"; - }); - - // 열 총계 행 - if (totals?.showColumnGrandTotals) { - const totalRow = ["총계"]; - flatColumns.forEach((col) => { - const colTotal = grandTotals.column.get(pathToKey(col.path)); - totalRow.push(colTotal?.[0]?.value?.toString() || ""); - }); - if (totals?.showRowGrandTotals) { - totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); - } - csv += totalRow.join(",") + "\n"; - } - - // 다운로드 - const blob = new Blob(["\uFEFF" + csv], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `${title || "pivot"}_export.csv`; - link.click(); - }, [pivotResult, totals, title]); - - // Excel 내보내기 - const handleExportExcel = useCallback(async () => { - if (!pivotResult) return; - - try { - await exportPivotToExcel(pivotResult, fields, totals, { - fileName: title || "pivot_export", - title: title, - }); - } catch (error) { - 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()); - }, []); - - // ==================== 렌더링 ==================== - - // 빈 상태 - if (!data || data.length === 0) { - return ( -
- -

데이터가 없습니다

-

데이터를 로드하거나 필드를 설정해주세요

-
- ); - } - - // 필드 미설정 (행, 열, 데이터 영역에 필드가 있는지 확인) - const hasActiveFields = fields.some( - (f) => f.visible !== false && ["row", "column", "data"].includes(f.area) - ); - if (!hasActiveFields) { - return ( -
- {/* 필드 패널 */} - setShowFieldPanel(!showFieldPanel)} - /> - - {/* 안내 메시지 */} -
- -

필드가 설정되지 않았습니다

-

- 행, 열, 데이터 영역에 필드를 배치해주세요 -

- -
- - {/* 필드 선택기 모달 */} - -
- ); - } - - // 피벗 결과 없음 - if (!pivotResult) { - return ( -
- -
- ); - } - - 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 ( -
- {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} - setShowFieldPanel(!showFieldPanel)} - /> - - {/* 헤더 툴바 */} -
-
- {title &&

{title}

} - - ({filteredData.length !== data.length - ? `${filteredData.length} / ${data.length}건` - : `${data.length}건`}) - -
- -
- {/* 필드 선택기 버튼 */} - {fieldChooser?.enabled !== false && ( - - )} - - {/* 필드 패널 토글 */} - - - {allowExpandAll && ( - <> - - - - - )} - - {/* 차트 토글 */} - {chartConfig && ( - - )} - - {/* 내보내기 버튼들 */} - {exportConfig?.excel && ( - <> - - - - - - )} - - - - - - - - {/* 숨겨진 필드 표시 드롭다운 */} - {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={ - - } - /> - ); - })} -
-
- )} - - {/* 피벗 테이블 */} -
- - - {/* 열 헤더 */} - - {/* 좌상단 코너 (행 필드 라벨 + 필터) */} - - - {/* 열 헤더 셀 */} - {flatColumns.map((col, idx) => ( - - ))} - - {/* 열 필드 필터 (헤더 왼쪽에 표시) */} - {columnFields.length > 0 && ( - - )} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( - - )} - - - {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} - {dataFields.length > 1 && ( - - {flatColumns.map((col, colIdx) => ( - - {dataFields.map((df, dfIdx) => ( - - ))} - - ))} - {totals?.showRowGrandTotals && - dataFields.map((df, dfIdx) => ( - - ))} - - )} - - - - {/* 열 총계 행 (상단 위치) */} - {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition === "top" && ( - - - - {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 && ( - - )} - - {/* 데이터 셀 */} - {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" && ( - - - - {flatColumns.map((col, colIdx) => ( - - ))} - - {/* 대총합 */} - {totals?.showRowGrandTotals && ( - - )} - - )} - -
0 ? 2 : 1} - > -
- {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 || "(전체)"} - {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={ - - } - /> - ))} -
-
- 총계 -
handleSort(df.field)} - > -
- {df.caption} - -
-
- {df.caption} -
- 총계 -
-
-
- 총계 -
-
- - {/* 차트 */} - {showChart && chartConfig && pivotResult && ( - - )} - - {/* 필드 선택기 모달 */} - - - {/* Drill Down 모달 */} - setDrillDownData((prev) => ({ ...prev, open }))} - cellData={drillDownData.cellData} - data={data} - fields={fields} - rowFields={rowFields} - columnFields={columnFields} - /> -
- ); -}; - -export default PivotGridComponent; diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx deleted file mode 100644 index 0662bc56..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel.tsx +++ /dev/null @@ -1,799 +0,0 @@ -"use client"; - -/** - * PivotGrid 설정 패널 - 간소화 버전 - * - * 피벗 테이블 설정 방법: - * 1. 테이블 선택 - * 2. 컬럼을 드래그하여 행/열/값 영역에 배치 - */ - -import React, { useState, useEffect, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { - PivotGridComponentConfig, - PivotFieldConfig, - PivotAreaType, - AggregationType, - FieldDataType, - PivotStyleConfig, -} 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 { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Rows, - Columns, - Calculator, - X, - Plus, - GripVertical, - Table2, - BarChart3, - Settings, - ChevronDown, - ChevronUp, - Info, -} from "lucide-react"; -import { tableTypeApi } from "@/lib/api/screen"; - -// ==================== 타입 ==================== - -interface TableInfo { - table_name: string; - table_comment?: string; -} - -interface ColumnInfo { - column_name: string; - data_type: string; - column_comment?: string; -} - -interface PivotGridConfigPanelProps { - config: PivotGridComponentConfig; - onChange: (config: PivotGridComponentConfig) => void; -} - -// DB 타입을 FieldDataType으로 변환 -function mapDbTypeToFieldType(dbType: string): FieldDataType { - const type = dbType.toLowerCase(); - 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")) { - return "date"; - } - if (type.includes("bool")) { - return "boolean"; - } - return "string"; -} - -// ==================== 컬럼 칩 컴포넌트 ==================== - -interface ColumnChipProps { - column: ColumnInfo; - isUsed: boolean; - onClick: () => void; -} - -const ColumnChip: React.FC = ({ column, isUsed, onClick }) => { - const dataType = mapDbTypeToFieldType(column.data_type); - const typeColor = { - number: "bg-primary/10 text-primary border-primary/20", - string: "bg-emerald-100 text-emerald-700 border-emerald-200", - date: "bg-purple-100 text-purple-700 border-purple-200", - boolean: "bg-amber-100 text-orange-700 border-orange-200", - }[dataType]; - - return ( - - ); -}; - -// ==================== 영역 드롭존 컴포넌트 ==================== - -interface AreaDropZoneProps { - area: PivotAreaType; - label: string; - description: string; - icon: React.ReactNode; - fields: PivotFieldConfig[]; - columns: ColumnInfo[]; - onAddField: (column: ColumnInfo) => void; - onRemoveField: (index: number) => void; - onUpdateField: (index: number, updates: Partial) => void; - color: string; -} - -const AreaDropZone: React.FC = ({ - area, - label, - description, - icon, - fields, - columns, - onAddField, - onRemoveField, - onUpdateField, - color, -}) => { - const [isExpanded, setIsExpanded] = useState(true); - - // 사용 가능한 컬럼 (이미 추가된 컬럼 제외) - const availableColumns = columns.filter( - (col) => !fields.some((f) => f.field === col.column_name) - ); - - return ( -
- {/* 헤더 */} -
setIsExpanded(!isExpanded)} - > -
- {icon} - {label} - - {fields.length} - -
- {isExpanded ? : } -
- - {/* 설명 */} -

{description}

- - {isExpanded && ( -
- {/* 추가된 필드 목록 */} - {fields.length > 0 ? ( -
- {fields.map((field, idx) => ( -
- - - {field.caption || field.field} - - - {/* 데이터 영역일 때 집계 함수 선택 */} - {area === "data" && ( - - )} - - -
- ))} -
- ) : ( -
- 아래에서 컬럼을 선택하세요 -
- )} - - {/* 컬럼 추가 드롭다운 */} - {availableColumns.length > 0 && ( - - )} -
- )} -
- ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotGridConfigPanel: React.FC = ({ - config, - onChange, -}) => { - const [tables, setTables] = useState([]); - 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 { - const tableList = await tableTypeApi.getTables(); - const mappedTables: TableInfo[] = tableList.map((t: any) => ({ - table_name: t.table_name, - table_comment: t.table_label || t.display_name || t.table_name, - })); - setTables(mappedTables); - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - // 테이블 선택 시 컬럼 로드 - useEffect(() => { - const loadColumns = async () => { - if (!config.dataSource?.tableName) { - setColumns([]); - return; - } - - setLoadingColumns(true); - try { - const columnList = await tableTypeApi.getColumns(config.dataSource.tableName); - const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ - column_name: c.column_name, - data_type: c.data_type || "text", - column_comment: c.column_label || c.column_name, - })); - setColumns(mappedColumns); - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [config.dataSource?.tableName]); - - // 설정 업데이트 헬퍼 - const updateConfig = useCallback( - (updates: Partial) => { - onChange({ ...config, ...updates }); - }, - [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 && ( -
-
- - - {loadingColumns && (컬럼 로딩 중...)} -
- - {/* 사용 가능한 컬럼 목록 */} - {columns.length > 0 && ( -
- -
- {columns.map((col) => { - const isUsed = (config.fields || []).some(f => f.field === col.column_name); - return ( - {/* 클릭 시 아무것도 안함 - 드롭존에서 추가 */}} - /> - ); - })} -
-
- )} - - {/* 영역별 드롭존 */} -
- } - 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-primary/20 bg-primary/10/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" - /> -
-
- )} - - {/* 고급 설정 토글 */} -
- -
- - {/* 고급 설정 */} - {showAdvanced && ( -
- {/* 표시 설정 */} -
- - -
-
- - - updateConfig({ totals: { ...config.totals, showRowGrandTotals: v } }) - } - /> -
- -
- - - updateConfig({ totals: { ...config.totals, showColumnGrandTotals: v } }) - } - /> -
- -
- - -
- -
- - -
- -
- - - updateConfig({ totals: { ...config.totals, showRowTotals: v } }) - } - /> -
- -
- - - updateConfig({ totals: { ...config.totals, showColumnTotals: v } }) - } - /> -
- -
- - - updateConfig({ style: { ...config.style, alternateRowColors: v } as PivotStyleConfig }) - } - /> -
- -
- - - updateConfig({ style: { ...config.style, mergeCells: v } as PivotStyleConfig }) - } - /> -
- -
- - - updateConfig({ exportConfig: { ...config.exportConfig, excel: v } }) - } - /> -
- -
- - - updateConfig({ saveState: v } as any) - } - /> -
-
-
- - {/* 크기 설정 */} -
- -
-
- - 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 } as PivotStyleConfig }); - }} - 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 } as PivotStyleConfig }); - }} - 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 } as PivotStyleConfig }); - }} - className="w-6 h-6 rounded cursor-pointer" - title="바 색상" - /> - )} - - {rule.type === "iconSet" && ( - - )} - - -
- ))} - - -
-
-
- )} -
- ); -}; - -export default PivotGridConfigPanel; diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx deleted file mode 100644 index 8ee076ec..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/PivotGridRenderer.tsx +++ /dev/null @@ -1,366 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import { PivotGridComponent } from "./PivotGridComponent"; -import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; -import { PivotFieldConfig } from "./types"; -import { dataApi } from "@/lib/api/data"; - -// ==================== 샘플 데이터 (미리보기용) ==================== - -const SAMPLE_DATA = [ - { region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 }, - { region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 }, - { region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 }, - { region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 }, - { region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 }, - { region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 }, - { region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 }, - { region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 }, - { region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 }, - { region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 }, - { region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 }, - { region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 }, - { region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 }, - { region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 }, - { region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 }, - { region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 }, - { region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 }, - { region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 }, - { region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 }, - { region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 }, - { region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 }, - { region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 }, - { region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 }, - { region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 }, - { region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 }, - { region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 }, - { region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 }, - { region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 }, - { region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 }, - { region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 }, - { region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 }, - { region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 }, - { region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 }, - { region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 }, - { region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 }, - { region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 }, -]; - -const SAMPLE_FIELDS: PivotFieldConfig[] = [ - { - field: "region", - caption: "지역", - area: "row", - areaIndex: 0, - dataType: "string", - visible: true, - }, - { - field: "product", - caption: "제품", - area: "row", - areaIndex: 1, - dataType: "string", - visible: true, - }, - { - field: "quarter", - caption: "분기", - area: "column", - areaIndex: 0, - dataType: "string", - visible: true, - }, - { - field: "sales", - caption: "매출", - area: "data", - areaIndex: 0, - dataType: "number", - summaryType: "sum", - format: { type: "number", precision: 0 }, - visible: true, - }, -]; - -/** - * PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입) - */ -const PivotGridWrapper: React.FC = (props) => { - // 컴포넌트 설정에서 값 추출 - const componentConfig = props.componentConfig || props.config || {}; - const configFields = componentConfig.fields || props.fields; - const configData = props.data; - - // 🆕 테이블에서 데이터 자동 로딩 - const [loadedData, setLoadedData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const loadTableData = async () => { - const tableName = componentConfig.dataSource?.tableName; - - // 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음 - if (configData || !tableName || props.isDesignMode) { - return; - } - - setIsLoading(true); - try { - console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName); - - const response = await dataApi.getTableData(tableName, { - page: 1, - size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size) - }); - - console.log("🔷 [PivotGrid] API 응답:", response); - - // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 - if (response.data && Array.isArray(response.data)) { - setLoadedData(response.data); - console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건"); - } else { - console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); - setLoadedData([]); - } - } catch (error) { - console.error("❌ [PivotGrid] 데이터 로딩 에러:", error); - } finally { - setIsLoading(false); - } - }; - - loadTableData(); - }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); - - // 디버깅 로그 - console.log("🔷 PivotGridWrapper props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasLoadedData: loadedData.length > 0, - loadedDataLength: loadedData.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - isLoading, - }); - - // 디자인 모드 판단: - // 1. isDesignMode === true - // 2. isInteractive === false (편집 모드) - const isDesignMode = props.isDesignMode === true || props.isInteractive === false; - - // 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터 - const actualData = configData || loadedData; - const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0; - const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; - - // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 - const usePreviewData = isDesignMode || (!hasValidData && !isLoading); - - // 최종 데이터/필드 결정 - const finalData = usePreviewData ? SAMPLE_DATA : actualData; - const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; - const finalTitle = usePreviewData - ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" - : (componentConfig.title || props.title); - - console.log("🔷 PivotGridWrapper final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - - // 총계 설정 - const totalsConfig = componentConfig.totals || props.totals || { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }; - - // 🆕 로딩 중 표시 - if (isLoading) { - return ( -
-
-
-

데이터 로딩 중...

-
-
- ); - } - - return ( - - ); -}; - -/** - * PivotGrid 컴포넌트 정의 - */ -const V2PivotGridDefinition = createComponentDefinition({ - id: "v2-pivot-grid", - name: "피벗 그리드", - name_eng: "PivotGrid Component", - description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", - category: ComponentCategory.DISPLAY, - web_type: "text", - component: PivotGridWrapper, // 래퍼 컴포넌트 사용 - default_config: { - dataSource: { - type: "table", - tableName: "", - }, - fields: SAMPLE_FIELDS, - // 미리보기용 샘플 데이터 - sampleData: SAMPLE_DATA, - totals: { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }, - style: { - theme: "default", - headerStyle: "default", - cellPadding: "normal", - borderStyle: "light", - alternateRowColors: true, - highlightTotals: true, - }, - allowExpandAll: true, - exportConfig: { - excel: true, - }, - height: "400px", - }, - default_size: { width: 800, height: 500 }, - config_panel: PivotGridConfigPanel, - icon: "BarChart3", - tags: ["피벗", "분석", "집계", "그리드", "데이터"], - version: "1.0.0", - author: "개발팀", - documentation: "", -}); - -/** - * PivotGrid 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class PivotGridRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = V2PivotGridDefinition; - - render(): React.ReactElement { - const props = this.props as any; - - // 컴포넌트 설정에서 값 추출 - const componentConfig = props.componentConfig || props.config || {}; - const configFields = componentConfig.fields || props.fields; - const configData = props.data; - - // 디버깅 로그 - console.log("🔷 PivotGridRenderer props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - }); - - // 디자인 모드 판단: - // 1. isDesignMode === true - // 2. isInteractive === false (편집 모드) - // 3. 데이터가 없는 경우 - const isDesignMode = props.isDesignMode === true || props.isInteractive === false; - const hasValidData = configData && Array.isArray(configData) && configData.length > 0; - const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; - - // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 - const usePreviewData = isDesignMode || !hasValidData; - - // 최종 데이터/필드 결정 - const finalData = usePreviewData ? SAMPLE_DATA : configData; - const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; - const finalTitle = usePreviewData - ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" - : (componentConfig.title || props.title); - - console.log("🔷 PivotGridRenderer final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - - // 총계 설정 - const totalsConfig = componentConfig.totals || props.totals || { - showRowGrandTotals: true, - showColumnGrandTotals: true, - showRowTotals: true, - showColumnTotals: true, - }; - - return ( - - ); - } -} - -// 자동 등록 실행 -PivotGridRenderer.registerSelf(); - -// 강제 등록 (디버깅용) -if (typeof window !== "undefined") { - setTimeout(() => { - try { - PivotGridRenderer.registerSelf(); - } catch (error) { - console.error("❌ PivotGrid 강제 등록 실패:", error); - } - }, 1000); -} diff --git a/frontend/lib/registry/components/v2-pivot-grid/README.md b/frontend/lib/registry/components/v2-pivot-grid/README.md deleted file mode 100644 index bc6fba52..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# PivotGrid 컴포넌트 - -다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다. - -## 주요 기능 - -### 1. 다차원 데이터 배치 - -- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시) -- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기) -- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량) -- **필터 영역(Filter Area)**: 전체 데이터 필터링 - -### 2. 집계 함수 - -| 함수 | 설명 | 사용 예 | -|------|------|---------| -| `sum` | 합계 | 매출 합계 | -| `count` | 개수 | 건수 | -| `avg` | 평균 | 평균 단가 | -| `min` | 최소값 | 최저가 | -| `max` | 최대값 | 최고가 | -| `countDistinct` | 고유값 개수 | 거래처 수 | - -### 3. 날짜 그룹화 - -날짜 필드를 다양한 단위로 그룹화할 수 있습니다: - -- `year`: 연도별 -- `quarter`: 분기별 -- `month`: 월별 -- `week`: 주별 -- `day`: 일별 - -### 4. 드릴다운 - -계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다. - -### 5. 총합계/소계 - -- 행 총합계 (Row Grand Total) -- 열 총합계 (Column Grand Total) -- 행 소계 (Row Subtotal) -- 열 소계 (Column Subtotal) - -### 6. 내보내기 - -CSV 형식으로 데이터를 내보낼 수 있습니다. - -## 사용법 - -### 기본 사용 - -```tsx -import { PivotGridComponent } from "@/lib/registry/components/pivot-grid"; - -const salesData = [ - { region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 }, - { region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 }, - // ... -]; - - -``` - -### 날짜 그룹화 - -```tsx - -``` - -### 포맷 설정 - -```tsx - -``` - -### 화면 관리에서 사용 - -설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다. - -```tsx -import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; - - -``` - -## 설정 옵션 - -### PivotGridProps - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `title` | `string` | - | 피벗 테이블 제목 | -| `data` | `any[]` | `[]` | 원본 데이터 배열 | -| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 | -| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 | -| `style` | `PivotStyleConfig` | - | 스타일 설정 | -| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 | -| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 | -| `height` | `string | number` | `"auto"` | 높이 | -| `maxHeight` | `string` | - | 최대 높이 | - -### PivotFieldConfig - -| 속성 | 타입 | 필수 | 설명 | -|------|------|------|------| -| `field` | `string` | O | 데이터 필드명 | -| `caption` | `string` | O | 표시 라벨 | -| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 | -| `areaIndex` | `number` | - | 영역 내 순서 | -| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 | -| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) | -| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 | -| `format` | `PivotFieldFormat` | - | 값 포맷 | -| `visible` | `boolean` | - | 표시 여부 | - -### PivotTotalsConfig - -| 속성 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 | -| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 | -| `showRowTotals` | `boolean` | `true` | 행 소계 표시 | -| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 | - -## 파일 구조 - -``` -pivot-grid/ -├── index.ts # 모듈 진입점 -├── types.ts # 타입 정의 -├── PivotGridComponent.tsx # 메인 컴포넌트 -├── PivotGridRenderer.tsx # 화면 관리 렌더러 -├── PivotGridConfigPanel.tsx # 설정 패널 -├── README.md # 문서 -└── utils/ - ├── index.ts # 유틸리티 모듈 진입점 - ├── aggregation.ts # 집계 함수 - └── pivotEngine.ts # 피벗 데이터 처리 엔진 -``` - -## 사용 시나리오 - -### 1. 매출 분석 - -지역별/기간별/제품별 매출 현황을 분석합니다. - -### 2. 재고 현황 - -창고별/품목별 재고 수량을 한눈에 파악합니다. - -### 3. 생산 실적 - -생산라인별/일자별 생산량을 분석합니다. - -### 4. 비용 분석 - -부서별/계정별 비용을 집계하여 분석합니다. - -### 5. 수주 현황 - -거래처별/품목별/월별 수주 현황을 분석합니다. - -## 주의사항 - -1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요. -2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다. -3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요. - - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx deleted file mode 100644 index 1dac623b..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/ContextMenu.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"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/v2-pivot-grid/components/DrillDownModal.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx deleted file mode 100644 index 994d782f..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client"; - -/** - * DrillDownModal 컴포넌트 - * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotCellData, PivotFieldConfig } from "../types"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Search, - Download, - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - ArrowUpDown, - ArrowUp, - ArrowDown, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface DrillDownModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - cellData: PivotCellData | null; - data: any[]; // 전체 원본 데이터 - fields: PivotFieldConfig[]; - rowFields: PivotFieldConfig[]; - columnFields: PivotFieldConfig[]; -} - -interface SortConfig { - field: string; - direction: "asc" | "desc"; -} - -// ==================== 메인 컴포넌트 ==================== - -export const DrillDownModal: React.FC = ({ - open, - onOpenChange, - cellData, - data, - fields, - rowFields, - columnFields, -}) => { - const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [sortConfig, setSortConfig] = useState(null); - - // 드릴다운 데이터 필터링 - const filteredData = useMemo(() => { - if (!cellData || !data) return []; - - // 행/열 경로에 해당하는 데이터 필터링 - let result = data.filter((row) => { - // 행 경로 매칭 - for (let i = 0; i < cellData.rowPath.length; i++) { - const field = rowFields[i]; - if (field && String(row[field.field]) !== cellData.rowPath[i]) { - return false; - } - } - - // 열 경로 매칭 - for (let i = 0; i < cellData.columnPath.length; i++) { - const field = columnFields[i]; - if (field && String(row[field.field]) !== cellData.columnPath[i]) { - return false; - } - } - - return true; - }); - - // 검색 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter((row) => - Object.values(row).some((val) => - String(val).toLowerCase().includes(query) - ) - ); - } - - // 정렬 - if (sortConfig) { - result = [...result].sort((a, b) => { - const aVal = a[sortConfig.field]; - const bVal = b[sortConfig.field]; - - if (aVal === null || aVal === undefined) return 1; - if (bVal === null || bVal === undefined) return -1; - - let comparison = 0; - if (typeof aVal === "number" && typeof bVal === "number") { - comparison = aVal - bVal; - } else { - comparison = String(aVal).localeCompare(String(bVal)); - } - - return sortConfig.direction === "asc" ? comparison : -comparison; - }); - } - - return result; - }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); - - // 페이지네이션 - const totalPages = Math.ceil(filteredData.length / pageSize); - const paginatedData = useMemo(() => { - const start = (currentPage - 1) * pageSize; - return filteredData.slice(start, start + pageSize); - }, [filteredData, currentPage, pageSize]); - - // 표시할 컬럼 결정 - const displayColumns = useMemo(() => { - // 모든 필드의 field명 수집 - const fieldNames = new Set(); - - // fields에서 가져오기 - fields.forEach((f) => fieldNames.add(f.field)); - - // 데이터에서 추가 컬럼 가져오기 - if (data.length > 0) { - Object.keys(data[0]).forEach((key) => fieldNames.add(key)); - } - - return Array.from(fieldNames).map((fieldName) => { - const fieldConfig = fields.find((f) => f.field === fieldName); - return { - field: fieldName, - caption: fieldConfig?.caption || fieldName, - dataType: fieldConfig?.dataType || "string", - }; - }); - }, [fields, data]); - - // 정렬 토글 - const handleSort = (field: string) => { - setSortConfig((prev) => { - if (!prev || prev.field !== field) { - return { field, direction: "asc" }; - } - if (prev.direction === "asc") { - return { field, direction: "desc" }; - } - return null; - }); - }; - - // CSV 내보내기 - const handleExportCSV = () => { - if (filteredData.length === 0) return; - - const headers = displayColumns.map((c) => c.caption); - const rows = filteredData.map((row) => - displayColumns.map((c) => { - const val = row[c.field]; - if (val === null || val === undefined) return ""; - if (typeof val === "string" && val.includes(",")) { - return `"${val}"`; - } - return String(val); - }) - ); - - const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); - - const blob = new Blob(["\uFEFF" + csv], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; - link.click(); - }; - - // 페이지 변경 - const goToPage = (page: number) => { - setCurrentPage(Math.max(1, Math.min(page, totalPages))); - }; - - // 경로 표시 - const pathDisplay = cellData - ? [ - ...(cellData.rowPath.length > 0 - ? [`행: ${cellData.rowPath.join(" > ")}`] - : []), - ...(cellData.columnPath.length > 0 - ? [`열: ${cellData.columnPath.join(" > ")}`] - : []), - ].join(" | ") - : ""; - - return ( - - - - 상세 데이터 - - {pathDisplay || "선택한 셀의 원본 데이터"} - - ({filteredData.length}건) - - - - - {/* 툴바 */} -
-
- - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="pl-9 h-9" - /> -
- - - - -
- - {/* 테이블 */} - -
- - - - {displayColumns.map((col) => ( - handleSort(col.field)} - > -
- {col.caption} - {sortConfig?.field === col.field ? ( - sortConfig.direction === "asc" ? ( - - ) : ( - - ) - ) : ( - - )} -
-
- ))} -
-
- - {paginatedData.length === 0 ? ( - - - 데이터가 없습니다. - - - ) : ( - paginatedData.map((row, idx) => ( - - {displayColumns.map((col) => ( - - {formatCellValue(row[col.field], col.dataType)} - - ))} - - )) - )} - -
-
-
- - {/* 페이지네이션 */} - {totalPages > 1 && ( -
-
- {(currentPage - 1) * pageSize + 1} -{" "} - {Math.min(currentPage * pageSize, filteredData.length)} /{" "} - {filteredData.length}건 -
- -
- - - - - {currentPage} / {totalPages} - - - - -
-
- )} -
-
- ); -}; - -// ==================== 유틸리티 ==================== - -function formatCellValue(value: any, dataType: string): string { - if (value === null || value === undefined) return "-"; - - if (dataType === "number") { - const num = Number(value); - if (isNaN(num)) return String(value); - return num.toLocaleString(); - } - - if (dataType === "date") { - try { - const date = new Date(value); - if (!isNaN(date.getTime())) { - return date.toLocaleDateString("ko-KR"); - } - } catch { - // 변환 실패 시 원본 반환 - } - } - - return String(value); -} - -export default DrillDownModal; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx deleted file mode 100644 index 89fe5128..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/FieldChooser.tsx +++ /dev/null @@ -1,450 +0,0 @@ -"use client"; - -/** - * FieldChooser 컴포넌트 - * 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Search, - Filter, - Columns, - Rows, - BarChart3, - GripVertical, - Plus, - Minus, - Type, - Hash, - Calendar, - ToggleLeft, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface AvailableField { - field: string; - caption: string; - dataType: "string" | "number" | "date" | "boolean"; - isSelected: boolean; - currentArea?: PivotAreaType; -} - -interface FieldChooserProps { - open: boolean; - onOpenChange: (open: boolean) => void; - availableFields: AvailableField[]; - selectedFields: PivotFieldConfig[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; -} - -// ==================== 영역 설정 ==================== - -const AREA_OPTIONS: { - value: PivotAreaType | "none"; - label: string; - icon: React.ReactNode; -}[] = [ - { value: "none", label: "사용 안함", icon: }, - { value: "filter", label: "필터", icon: }, - { value: "row", label: "행", icon: }, - { value: "column", label: "열", icon: }, - { value: "data", label: "데이터", icon: }, -]; - -const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [ - { value: "sum", label: "합계" }, - { value: "count", label: "개수" }, - { value: "avg", label: "평균" }, - { value: "min", label: "최소" }, - { value: "max", label: "최대" }, - { value: "countDistinct", label: "고유 개수" }, -]; - -const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ - { value: "absoluteValue", label: "절대값" }, - { value: "percentOfRowTotal", label: "행 총계 %" }, - { value: "percentOfColumnTotal", label: "열 총계 %" }, - { value: "percentOfGrandTotal", label: "전체 총계 %" }, - { value: "runningTotalByRow", label: "행 누계" }, - { value: "runningTotalByColumn", label: "열 누계" }, - { value: "differenceFromPrevious", label: "이전 대비 차이" }, - { 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: , - date: , - boolean: , -}; - -// ==================== 필드 아이템 ==================== - -interface FieldItemProps { - field: AvailableField; - config?: PivotFieldConfig; - onAreaChange: (area: PivotAreaType | "none") => void; - onSummaryChange?: (summary: AggregationType) => void; - onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; -} - -const FieldItem: React.FC = ({ - field, - config, - onAreaChange, - onSummaryChange, - onDisplayModeChange, -}) => { - const currentArea = config?.area || "none"; - const isSelected = currentArea !== "none"; - - return ( -
- {/* 데이터 타입 아이콘 */} -
- {DATA_TYPE_ICONS[field.dataType] || } -
- - {/* 필드명 */} -
-
{field.caption}
-
- {field.field} -
-
- - {/* 영역 선택 */} - - - {/* 집계 함수 선택 (데이터 영역인 경우) */} - {currentArea === "data" && onSummaryChange && ( - - )} - - {/* 표시 모드 선택 (데이터 영역인 경우) */} - {currentArea === "data" && onDisplayModeChange && ( - - )} -
- ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const FieldChooser: React.FC = ({ - open, - onOpenChange, - availableFields, - selectedFields, - onFieldsChange, -}) => { - const [searchQuery, setSearchQuery] = useState(""); - const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( - "all" - ); - - // 필터링된 필드 목록 - const filteredFields = useMemo(() => { - let result = availableFields; - - // 검색어 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (f) => - f.caption.toLowerCase().includes(query) || - f.field.toLowerCase().includes(query) - ); - } - - // 선택 상태 필터 - if (filterType === "selected") { - result = result.filter((f) => - selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) - ); - } else if (filterType === "unselected") { - result = result.filter( - (f) => - !selectedFields.some( - (sf) => sf.field === f.field && sf.visible !== false - ) - ); - } - - return result; - }, [availableFields, selectedFields, searchQuery, filterType]); - - // 필드 영역 변경 - const handleAreaChange = ( - field: AvailableField, - area: PivotAreaType | "none" - ) => { - const existingConfig = selectedFields.find((f) => f.field === field.field); - - if (area === "none") { - // 필드 제거 또는 숨기기 - if (existingConfig) { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, visible: false } : f - ); - onFieldsChange(newFields); - } - } else { - // 필드 추가 또는 영역 변경 - if (existingConfig) { - const newFields = selectedFields.map((f) => - f.field === field.field - ? { ...f, area, visible: true } - : f - ); - onFieldsChange(newFields); - } else { - // 새 필드 추가 - const newField: PivotFieldConfig = { - field: field.field, - caption: field.caption, - area, - dataType: field.dataType, - visible: true, - summaryType: area === "data" ? "sum" : undefined, - areaIndex: selectedFields.filter((f) => f.area === area).length, - }; - onFieldsChange([...selectedFields, newField]); - } - } - }; - - // 집계 함수 변경 - const handleSummaryChange = ( - field: AvailableField, - summaryType: AggregationType - ) => { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, summaryType } : f - ); - onFieldsChange(newFields); - }; - - // 표시 모드 변경 - const handleDisplayModeChange = ( - field: AvailableField, - displayMode: SummaryDisplayMode - ) => { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f - ); - onFieldsChange(newFields); - }; - - // 모든 필드 선택 해제 - const handleClearAll = () => { - const newFields = selectedFields.map((f) => ({ ...f, visible: false })); - onFieldsChange(newFields); - }; - - // 통계 - const stats = useMemo(() => { - const visible = selectedFields.filter((f) => f.visible !== false); - return { - total: availableFields.length, - selected: visible.length, - filter: visible.filter((f) => f.area === "filter").length, - row: visible.filter((f) => f.area === "row").length, - column: visible.filter((f) => f.area === "column").length, - data: visible.filter((f) => f.area === "data").length, - }; - }, [availableFields, selectedFields]); - - return ( - - - - 필드 선택기 - - 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. - - - - {/* 통계 */} -
- 전체: {stats.total} - - 선택됨: {stats.selected} - - 필터: {stats.filter} - 행: {stats.row} - 열: {stats.column} - 데이터: {stats.data} -
- - {/* 검색 및 필터 */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9" - /> -
- - - - -
- - {/* 필드 목록 */} - -
- {filteredFields.length === 0 ? ( -
- 검색 결과가 없습니다. -
- ) : ( - filteredFields.map((field) => { - const config = selectedFields.find( - (f) => f.field === field.field && f.visible !== false - ); - return ( - handleAreaChange(field, area)} - onSummaryChange={ - config?.area === "data" - ? (summary) => handleSummaryChange(field, summary) - : undefined - } - onDisplayModeChange={ - config?.area === "data" - ? (mode) => handleDisplayModeChange(field, mode) - : undefined - } - /> - ); - }) - )} -
-
- - {/* 푸터 */} -
- -
-
-
- ); -}; - -export default FieldChooser; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx deleted file mode 100644 index 7cc4e085..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/FieldPanel.tsx +++ /dev/null @@ -1,577 +0,0 @@ -"use client"; - -/** - * FieldPanel 컴포넌트 - * 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터) - * 드래그 앤 드롭으로 필드 재배치 가능 - */ - -import React, { useState } from "react"; -import { - DndContext, - DragOverlay, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragStartEvent, - DragEndEvent, - DragOverEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - horizontalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig, PivotAreaType } from "../types"; -import { - X, - Filter, - Columns, - Rows, - BarChart3, - GripVertical, - ChevronDown, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -// ==================== 타입 ==================== - -interface FieldPanelProps { - fields: PivotFieldConfig[]; - onFieldsChange: (fields: PivotFieldConfig[]) => void; - onFieldRemove?: (field: PivotFieldConfig) => void; - onFieldSettingsChange?: (field: PivotFieldConfig) => void; - collapsed?: boolean; - onToggleCollapse?: () => void; -} - -interface FieldChipProps { - field: PivotFieldConfig; - onRemove: () => void; - onSettingsChange?: (field: PivotFieldConfig) => void; -} - -interface DroppableAreaProps { - area: PivotAreaType; - fields: PivotFieldConfig[]; - title: string; - icon: React.ReactNode; - onFieldRemove: (field: PivotFieldConfig) => void; - onFieldSettingsChange?: (field: PivotFieldConfig) => void; - isOver?: boolean; -} - -// ==================== 영역 설정 ==================== - -const AREA_CONFIG: Record< - PivotAreaType, - { title: string; icon: React.ReactNode; color: string } -> = { - filter: { - title: "필터", - icon: , - color: "bg-amber-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", - }, - column: { - title: "열", - icon: , - color: "bg-primary/10 border-primary/20 dark:bg-primary/10 dark:border-primary/30", - }, - row: { - title: "행", - icon: , - color: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800", - }, - data: { - title: "데이터", - icon: , - color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", - }, -}; - -// ==================== 필드 칩 (드래그 가능) ==================== - -const SortableFieldChip: React.FC = ({ - field, - onRemove, - onSettingsChange, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: `${field.area}-${field.field}` }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
- {/* 드래그 핸들 */} - - - {/* 필드 라벨 */} - - - - - - {field.area === "data" && ( - <> - - onSettingsChange?.({ ...field, summaryType: "sum" }) - } - > - 합계 - - - onSettingsChange?.({ ...field, summaryType: "count" }) - } - > - 개수 - - - onSettingsChange?.({ ...field, summaryType: "avg" }) - } - > - 평균 - - - onSettingsChange?.({ ...field, summaryType: "min" }) - } - > - 최소 - - - onSettingsChange?.({ ...field, summaryType: "max" }) - } - > - 최대 - - - - )} - - onSettingsChange?.({ - ...field, - sortOrder: field.sortOrder === "asc" ? "desc" : "asc", - }) - } - > - {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} - - - onSettingsChange?.({ ...field, visible: false })} - > - 필드 숨기기 - - - - - {/* 삭제 버튼 */} - -
- ); -}; - -// ==================== 드롭 영역 ==================== - -const DroppableArea: React.FC = ({ - area, - fields, - title, - icon, - onFieldRemove, - onFieldSettingsChange, - isOver, -}) => { - const config = AREA_CONFIG[area]; - const areaFields = fields.filter((f) => f.area === area && f.visible !== false); - const fieldIds = areaFields.map((f) => `${area}-${f.field}`); - - return ( -
- {/* 영역 헤더 */} -
- {icon} - {title} - {areaFields.length > 0 && ( - - {areaFields.length} - - )} -
- - {/* 필드 목록 */} - -
- {areaFields.length === 0 ? ( - - 필드를 여기로 드래그 - - ) : ( - areaFields.map((field) => ( - onFieldRemove(field)} - onSettingsChange={onFieldSettingsChange} - /> - )) - )} -
-
-
- ); -}; - -// ==================== 유틸리티 ==================== - -function getSummaryLabel(type: string): string { - const labels: Record = { - sum: "합계", - count: "개수", - avg: "평균", - min: "최소", - max: "최대", - countDistinct: "고유", - }; - return labels[type] || type; -} - -// ==================== 메인 컴포넌트 ==================== - -export const FieldPanel: React.FC = ({ - fields, - onFieldsChange, - onFieldRemove, - onFieldSettingsChange, - collapsed = false, - onToggleCollapse, -}) => { - const [activeId, setActiveId] = useState(null); - const [overArea, setOverArea] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - // 드래그 시작 - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as string); - }; - - // 드래그 오버 - const handleDragOver = (event: DragOverEvent) => { - const { over } = event; - if (!over) { - setOverArea(null); - return; - } - - // 드롭 영역 감지 - const overId = over.id as string; - const targetArea = overId.split("-")[0] as PivotAreaType; - if (["filter", "column", "row", "data"].includes(targetArea)) { - setOverArea(targetArea); - } - }; - - // 드래그 종료 - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - setOverArea(null); - - if (!over) return; - - const activeId = active.id as string; - const overId = over.id as string; - - // 필드 정보 파싱 - const [sourceArea, sourceField] = activeId.split("-") as [ - PivotAreaType, - string - ]; - const [targetArea] = overId.split("-") as [PivotAreaType, string]; - - // 같은 영역 내 정렬 - if (sourceArea === targetArea) { - const areaFields = fields.filter((f) => f.area === sourceArea); - const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); - const targetIndex = areaFields.findIndex( - (f) => `${f.area}-${f.field}` === overId - ); - - if (sourceIndex !== targetIndex && targetIndex >= 0) { - // 순서 변경 - const newFields = [...fields]; - const fieldToMove = newFields.find( - (f) => f.field === sourceField && f.area === sourceArea - ); - if (fieldToMove) { - fieldToMove.areaIndex = targetIndex; - // 다른 필드들 인덱스 조정 - newFields - .filter((f) => f.area === sourceArea && f.field !== sourceField) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) - .forEach((f, idx) => { - f.areaIndex = idx >= targetIndex ? idx + 1 : idx; - }); - } - onFieldsChange(newFields); - } - return; - } - - // 다른 영역으로 이동 - if (["filter", "column", "row", "data"].includes(targetArea)) { - const newFields = fields.map((f) => { - if (f.field === sourceField && f.area === sourceArea) { - return { - ...f, - area: targetArea as PivotAreaType, - areaIndex: fields.filter((ff) => ff.area === targetArea).length, - }; - } - return f; - }); - onFieldsChange(newFields); - } - }; - - // 필드 제거 - const handleFieldRemove = (field: PivotFieldConfig) => { - if (onFieldRemove) { - onFieldRemove(field); - } else { - // 기본 동작: visible을 false로 설정 - const newFields = fields.map((f) => - f.field === field.field && f.area === field.area - ? { ...f, visible: false } - : f - ); - onFieldsChange(newFields); - } - }; - - // 필드 설정 변경 - const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { - if (onFieldSettingsChange) { - onFieldSettingsChange(updatedField); - } - const newFields = fields.map((f) => - f.field === updatedField.field && f.area === updatedField.area - ? updatedField - : f - ); - onFieldsChange(newFields); - }; - - // 활성 필드 찾기 (드래그 중인 필드) - const activeField = activeId - ? 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} - -
- -
- ); - } - - return ( - -
- {/* 4개 영역 배치: 2x2 그리드 */} -
- {/* 필터 영역 */} - - - {/* 열 영역 */} - - - {/* 행 영역 */} - - - {/* 데이터 영역 */} - -
- - {/* 접기 버튼 */} - {onToggleCollapse && ( -
- -
- )} -
- - {/* 드래그 오버레이 */} - - {activeField ? ( -
- - {activeField.caption} -
- ) : null} -
-
- ); -}; - -export default FieldPanel; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx deleted file mode 100644 index e3185f5a..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/FilterPopup.tsx +++ /dev/null @@ -1,265 +0,0 @@ -"use client"; - -/** - * FilterPopup 컴포넌트 - * 피벗 필드의 값을 필터링하는 팝업 - */ - -import React, { useState, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotFieldConfig } from "../types"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Label } from "@/components/ui/label"; -import { - Search, - Filter, - Check, - X, - CheckSquare, - Square, -} from "lucide-react"; - -// ==================== 타입 ==================== - -interface FilterPopupProps { - field: PivotFieldConfig; - data: any[]; - onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; - trigger?: React.ReactNode; -} - -// ==================== 메인 컴포넌트 ==================== - -export const FilterPopup: React.FC = ({ - field, - data, - onFilterChange, - trigger, -}) => { - const [open, setOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedValues, setSelectedValues] = useState>( - new Set(field.filterValues || []) - ); - const [filterType, setFilterType] = useState<"include" | "exclude">( - field.filterType || "include" - ); - - // 고유 값 추출 - const uniqueValues = useMemo(() => { - const values = new Set(); - data.forEach((row) => { - const value = row[field.field]; - if (value !== null && value !== undefined) { - values.add(value); - } - }); - return Array.from(values).sort((a, b) => { - if (typeof a === "number" && typeof b === "number") return a - b; - return String(a).localeCompare(String(b), "ko"); - }); - }, [data, field.field]); - - // 필터링된 값 목록 - const filteredValues = useMemo(() => { - if (!searchQuery) return uniqueValues; - const query = searchQuery.toLowerCase(); - return uniqueValues.filter((val) => - String(val).toLowerCase().includes(query) - ); - }, [uniqueValues, searchQuery]); - - // 값 토글 - const handleValueToggle = (value: any) => { - const newSelected = new Set(selectedValues); - if (newSelected.has(value)) { - newSelected.delete(value); - } else { - newSelected.add(value); - } - setSelectedValues(newSelected); - }; - - // 모두 선택 - const handleSelectAll = () => { - setSelectedValues(new Set(filteredValues)); - }; - - // 모두 해제 - const handleClearAll = () => { - setSelectedValues(new Set()); - }; - - // 적용 - const handleApply = () => { - onFilterChange(field, Array.from(selectedValues), filterType); - setOpen(false); - }; - - // 초기화 - const handleReset = () => { - setSelectedValues(new Set()); - setFilterType("include"); - onFilterChange(field, [], "include"); - setOpen(false); - }; - - // 필터 활성 상태 - const isFilterActive = field.filterValues && field.filterValues.length > 0; - - // 선택된 항목 수 - const selectedCount = selectedValues.size; - const totalCount = uniqueValues.length; - - return ( - - - {trigger || ( - - )} - - -
-
- {field.caption} 필터 -
- - -
-
- - {/* 검색 */} -
- - setSearchQuery(e.target.value)} - className="pl-8 h-8 text-sm" - /> -
- - {/* 전체 선택/해제 */} -
- - {selectedCount} / {totalCount} 선택됨 - -
- - -
-
-
- - {/* 값 목록 */} - -
- {filteredValues.length === 0 ? ( -
- 결과가 없습니다 -
- ) : ( - filteredValues.map((value) => ( - - )) - )} -
-
- - {/* 버튼 */} -
- -
- - -
-
-
-
- ); -}; - -export default FilterPopup; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx deleted file mode 100644 index b2999bd8..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/PivotChart.tsx +++ /dev/null @@ -1,386 +0,0 @@ -"use client"; - -/** - * PivotChart 컴포넌트 - * 피벗 데이터를 차트로 시각화 - */ - -import React, { useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types"; -import { pathToKey } from "../utils/pivotEngine"; -import { - BarChart, - Bar, - LineChart, - Line, - AreaChart, - Area, - PieChart, - Pie, - Cell, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from "recharts"; - -// ==================== 타입 ==================== - -interface PivotChartProps { - pivotResult: PivotResult; - config: PivotChartConfig; - dataFields: PivotFieldConfig[]; - className?: string; -} - -// ==================== 색상 ==================== - -const COLORS = [ - "#4472C4", // 파랑 - "#ED7D31", // 주황 - "#A5A5A5", // 회색 - "#FFC000", // 노랑 - "#5B9BD5", // 하늘 - "#70AD47", // 초록 - "#264478", // 진한 파랑 - "#9E480E", // 진한 주황 - "#636363", // 진한 회색 - "#997300", // 진한 노랑 -]; - -// ==================== 데이터 변환 ==================== - -function transformDataForChart( - pivotResult: PivotResult, - dataFields: PivotFieldConfig[] -): any[] { - const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; - - // 행 기준 차트 데이터 생성 - return flatRows.map((row) => { - const dataPoint: any = { - name: row.caption, - path: row.path, - }; - - // 각 열에 대한 데이터 추가 - flatColumns.forEach((col) => { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = dataMatrix.get(cellKey); - - if (values && values.length > 0) { - const columnName = col.caption || "전체"; - dataPoint[columnName] = values[0].value; - } - }); - - // 총계 추가 - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - if (rowTotal && rowTotal.length > 0) { - dataPoint["총계"] = rowTotal[0].value; - } - - return dataPoint; - }); -} - -function transformDataForPie( - pivotResult: PivotResult, - dataFields: PivotFieldConfig[] -): any[] { - const { flatRows, grandTotals } = pivotResult; - - return flatRows.map((row, idx) => { - const rowTotal = grandTotals.row.get(pathToKey(row.path)); - return { - name: row.caption, - value: rowTotal?.[0]?.value || 0, - color: COLORS[idx % COLORS.length], - }; - }); -} - -// ==================== 차트 컴포넌트 ==================== - -const CustomTooltip: React.FC = ({ active, payload, label }) => { - if (!active || !payload || !payload.length) return null; - - return ( -
-

{label}

- {payload.map((entry: any, idx: number) => ( -

- {entry.name}: {entry.value?.toLocaleString()} -

- ))} -
- ); -}; - -// 막대 차트 -const PivotBarChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; - stacked?: boolean; -}> = ({ data, columns, height, showLegend, stacked }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 선 차트 -const PivotLineChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; -}> = ({ data, columns, height, showLegend }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 영역 차트 -const PivotAreaChart: React.FC<{ - data: any[]; - columns: string[]; - height: number; - showLegend: boolean; -}> = ({ data, columns, height, showLegend }) => { - return ( - - - - - value.toLocaleString()} - /> - } /> - {showLegend && ( - - )} - {columns.map((col, idx) => ( - - ))} - - - ); -}; - -// 파이 차트 -const PivotPieChart: React.FC<{ - data: any[]; - height: number; - showLegend: boolean; -}> = ({ data, height, showLegend }) => { - return ( - - - - `${name} (${(percent * 100).toFixed(1)}%)` - } - labelLine - > - {data.map((entry, idx) => ( - - ))} - - } /> - {showLegend && ( - - )} - - - ); -}; - -// ==================== 메인 컴포넌트 ==================== - -export const PivotChart: React.FC = ({ - pivotResult, - config, - dataFields, - className, -}) => { - // 차트 데이터 변환 - const chartData = useMemo(() => { - if (config.type === "pie") { - return transformDataForPie(pivotResult, dataFields); - } - return transformDataForChart(pivotResult, dataFields); - }, [pivotResult, dataFields, config.type]); - - // 열 이름 목록 (파이 차트 제외) - const columns = useMemo(() => { - if (config.type === "pie" || chartData.length === 0) return []; - - const firstItem = chartData[0]; - return Object.keys(firstItem).filter( - (key) => key !== "name" && key !== "path" - ); - }, [chartData, config.type]); - - const height = config.height || 300; - const showLegend = config.showLegend !== false; - - if (!config.enabled) { - return null; - } - - return ( -
- {/* 차트 렌더링 */} - {config.type === "bar" && ( - - )} - - {config.type === "stackedBar" && ( - - )} - - {config.type === "line" && ( - - )} - - {config.type === "area" && ( - - )} - - {config.type === "pie" && ( - - )} -
- ); -}; - -export default PivotChart; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/components/index.ts b/frontend/lib/registry/components/v2-pivot-grid/components/index.ts deleted file mode 100644 index 9272e7db..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/components/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * PivotGrid 서브 컴포넌트 내보내기 - */ - -export { FieldPanel } from "./FieldPanel"; -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/v2-pivot-grid/hooks/index.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/index.ts deleted file mode 100644 index a9a1a4eb..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/hooks/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * PivotGrid 커스텀 훅 내보내기 - */ - -export { - useVirtualScroll, - useVirtualColumnScroll, - useVirtual2DScroll, -} from "./useVirtualScroll"; - -export type { - VirtualScrollOptions, - VirtualScrollResult, - VirtualColumnScrollOptions, - VirtualColumnScrollResult, - Virtual2DScrollOptions, - Virtual2DScrollResult, -} from "./useVirtualScroll"; - -export { usePivotState } from "./usePivotState"; - -export type { - PivotStateConfig, - SavedPivotState, - UsePivotStateResult, -} from "./usePivotState"; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts deleted file mode 100644 index dab5ef4d..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/hooks/usePivotState.ts +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -/** - * PivotState 훅 - * 피벗 그리드 상태 저장/복원 관리 - */ - -import { useState, useEffect, useCallback } from "react"; -import { PivotFieldConfig, PivotGridState, SortDirection } from "../types"; - -// ==================== 타입 ==================== - -export interface PivotStateConfig { - enabled: boolean; - storageKey?: string; - storageType?: "localStorage" | "sessionStorage"; -} - -export interface SavedPivotState { - version: string; - timestamp: number; - fields: PivotFieldConfig[]; - expandedRowPaths: string[][]; - expandedColumnPaths: string[][]; - filterConfig: Record; - sortConfig: { - field: string; - direction: SortDirection; - } | null; -} - -export interface UsePivotStateResult { - // 상태 - fields: PivotFieldConfig[]; - pivotState: PivotGridState; - - // 상태 변경 - setFields: (fields: PivotFieldConfig[]) => void; - setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; - - // 저장/복원 - saveState: () => void; - loadState: () => boolean; - clearState: () => void; - hasStoredState: () => boolean; - - // 상태 정보 - lastSaved: Date | null; - isDirty: boolean; -} - -// ==================== 상수 ==================== - -const STATE_VERSION = "1.0.0"; -const DEFAULT_STORAGE_KEY = "pivot-grid-state"; - -// ==================== 훅 ==================== - -export function usePivotState( - initialFields: PivotFieldConfig[], - config: PivotStateConfig -): UsePivotStateResult { - const { - enabled, - storageKey = DEFAULT_STORAGE_KEY, - storageType = "localStorage", - } = config; - - // 상태 - const [fields, setFieldsInternal] = useState(initialFields); - const [pivotState, setPivotStateInternal] = useState({ - expandedRowPaths: [], - expandedColumnPaths: [], - sortConfig: null, - filterConfig: {}, - }); - const [lastSaved, setLastSaved] = useState(null); - const [isDirty, setIsDirty] = useState(false); - const [initialStateLoaded, setInitialStateLoaded] = useState(false); - - // 스토리지 가져오기 - const getStorage = useCallback(() => { - if (typeof window === "undefined") return null; - return storageType === "localStorage" ? localStorage : sessionStorage; - }, [storageType]); - - // 저장된 상태 확인 - const hasStoredState = useCallback((): boolean => { - const storage = getStorage(); - if (!storage) return false; - return storage.getItem(storageKey) !== null; - }, [getStorage, storageKey]); - - // 상태 저장 - const saveState = useCallback(() => { - if (!enabled) return; - - const storage = getStorage(); - if (!storage) return; - - const stateToSave: SavedPivotState = { - version: STATE_VERSION, - timestamp: Date.now(), - fields, - expandedRowPaths: pivotState.expandedRowPaths, - expandedColumnPaths: pivotState.expandedColumnPaths, - filterConfig: pivotState.filterConfig, - sortConfig: pivotState.sortConfig, - }; - - try { - storage.setItem(storageKey, JSON.stringify(stateToSave)); - setLastSaved(new Date()); - setIsDirty(false); - console.log("✅ 피벗 상태 저장됨:", storageKey); - } catch (error) { - console.error("❌ 피벗 상태 저장 실패:", error); - } - }, [enabled, getStorage, storageKey, fields, pivotState]); - - // 상태 불러오기 - const loadState = useCallback((): boolean => { - if (!enabled) return false; - - const storage = getStorage(); - if (!storage) return false; - - try { - const saved = storage.getItem(storageKey); - if (!saved) return false; - - const parsedState: SavedPivotState = JSON.parse(saved); - - // 버전 체크 - if (parsedState.version !== STATE_VERSION) { - console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨"); - return false; - } - - // 상태 복원 - setFieldsInternal(parsedState.fields); - setPivotStateInternal({ - expandedRowPaths: parsedState.expandedRowPaths, - expandedColumnPaths: parsedState.expandedColumnPaths, - sortConfig: parsedState.sortConfig, - filterConfig: parsedState.filterConfig, - }); - setLastSaved(new Date(parsedState.timestamp)); - setIsDirty(false); - - console.log("✅ 피벗 상태 복원됨:", storageKey); - return true; - } catch (error) { - console.error("❌ 피벗 상태 복원 실패:", error); - return false; - } - }, [enabled, getStorage, storageKey]); - - // 상태 초기화 - const clearState = useCallback(() => { - const storage = getStorage(); - if (!storage) return; - - try { - storage.removeItem(storageKey); - setLastSaved(null); - console.log("🗑️ 피벗 상태 삭제됨:", storageKey); - } catch (error) { - console.error("❌ 피벗 상태 삭제 실패:", error); - } - }, [getStorage, storageKey]); - - // 필드 변경 (dirty 플래그 설정) - const setFields = useCallback((newFields: PivotFieldConfig[]) => { - setFieldsInternal(newFields); - setIsDirty(true); - }, []); - - // 피벗 상태 변경 (dirty 플래그 설정) - const setPivotState = useCallback( - (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { - setPivotStateInternal(newState); - setIsDirty(true); - }, - [] - ); - - // 초기 로드 - useEffect(() => { - if (!initialStateLoaded && enabled && hasStoredState()) { - loadState(); - setInitialStateLoaded(true); - } - }, [enabled, hasStoredState, loadState, initialStateLoaded]); - - // 초기 필드 동기화 (저장된 상태가 없을 때) - useEffect(() => { - if (initialStateLoaded) return; - if (!hasStoredState() && initialFields.length > 0) { - setFieldsInternal(initialFields); - setInitialStateLoaded(true); - } - }, [initialFields, hasStoredState, initialStateLoaded]); - - // 자동 저장 (변경 시) - useEffect(() => { - if (!enabled || !isDirty) return; - - const timeout = setTimeout(() => { - saveState(); - }, 1000); // 1초 디바운스 - - return () => clearTimeout(timeout); - }, [enabled, isDirty, saveState]); - - return { - fields, - pivotState, - setFields, - setPivotState, - saveState, - loadState, - clearState, - hasStoredState, - lastSaved, - isDirty, - }; -} - -export default usePivotState; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts deleted file mode 100644 index 7f79cd3e..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/hooks/useVirtualScroll.ts +++ /dev/null @@ -1,312 +0,0 @@ -"use client"; - -/** - * Virtual Scroll 훅 - * 대용량 피벗 데이터의 가상 스크롤 처리 - */ - -import { useState, useEffect, useRef, useMemo, useCallback } from "react"; - -// ==================== 타입 ==================== - -export interface VirtualScrollOptions { - itemCount: number; // 전체 아이템 수 - itemHeight: number; // 각 아이템 높이 (px) - containerHeight: number; // 컨테이너 높이 (px) - overscan?: number; // 버퍼 아이템 수 (기본: 5) -} - -export interface VirtualScrollResult { - // 현재 보여야 할 아이템 범위 - startIndex: number; - endIndex: number; - - // 가상 스크롤 관련 값 - totalHeight: number; // 전체 높이 - offsetTop: number; // 상단 오프셋 - - // 보여지는 아이템 목록 - visibleItems: number[]; - - // 이벤트 핸들러 - onScroll: (scrollTop: number) => void; - - // 컨테이너 ref - containerRef: React.RefObject; -} - -// ==================== 훅 ==================== - -export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { - const { - itemCount, - itemHeight, - containerHeight, - overscan = 5, - } = options; - - const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - - // 보이는 아이템 수 - const visibleCount = Math.ceil(containerHeight / itemHeight); - - // 시작/끝 인덱스 계산 - const { startIndex, endIndex } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); - const end = Math.min( - itemCount - 1, - Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan - ); - return { startIndex: start, endIndex: end }; - }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); - - // 전체 높이 - const totalHeight = itemCount * itemHeight; - - // 상단 오프셋 - const offsetTop = startIndex * itemHeight; - - // 보이는 아이템 인덱스 배열 - const visibleItems = useMemo(() => { - const items: number[] = []; - for (let i = startIndex; i <= endIndex; i++) { - items.push(i); - } - return items; - }, [startIndex, endIndex]); - - // 스크롤 핸들러 - const onScroll = useCallback((newScrollTop: number) => { - setScrollTop(newScrollTop); - }, []); - - // 스크롤 이벤트 리스너 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleScroll = () => { - setScrollTop(container.scrollTop); - }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - - return () => { - container.removeEventListener("scroll", handleScroll); - }; - }, []); - - return { - startIndex, - endIndex, - totalHeight, - offsetTop, - visibleItems, - onScroll, - containerRef, - }; -} - -// ==================== 열 가상 스크롤 ==================== - -export interface VirtualColumnScrollOptions { - columnCount: number; // 전체 열 수 - columnWidth: number; // 각 열 너비 (px) - containerWidth: number; // 컨테이너 너비 (px) - overscan?: number; -} - -export interface VirtualColumnScrollResult { - startIndex: number; - endIndex: number; - totalWidth: number; - offsetLeft: number; - visibleColumns: number[]; - onScroll: (scrollLeft: number) => void; -} - -export function useVirtualColumnScroll( - options: VirtualColumnScrollOptions -): VirtualColumnScrollResult { - const { - columnCount, - columnWidth, - containerWidth, - overscan = 3, - } = options; - - const [scrollLeft, setScrollLeft] = useState(0); - - const { startIndex, endIndex } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); - const end = Math.min( - columnCount - 1, - Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan - ); - return { startIndex: start, endIndex: end }; - }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); - - const totalWidth = columnCount * columnWidth; - const offsetLeft = startIndex * columnWidth; - - const visibleColumns = useMemo(() => { - const cols: number[] = []; - for (let i = startIndex; i <= endIndex; i++) { - cols.push(i); - } - return cols; - }, [startIndex, endIndex]); - - const onScroll = useCallback((newScrollLeft: number) => { - setScrollLeft(newScrollLeft); - }, []); - - return { - startIndex, - endIndex, - totalWidth, - offsetLeft, - visibleColumns, - onScroll, - }; -} - -// ==================== 2D 가상 스크롤 (행 + 열) ==================== - -export interface Virtual2DScrollOptions { - rowCount: number; - columnCount: number; - rowHeight: number; - columnWidth: number; - containerHeight: number; - containerWidth: number; - rowOverscan?: number; - columnOverscan?: number; -} - -export interface Virtual2DScrollResult { - // 행 범위 - rowStartIndex: number; - rowEndIndex: number; - totalHeight: number; - offsetTop: number; - visibleRows: number[]; - - // 열 범위 - columnStartIndex: number; - columnEndIndex: number; - totalWidth: number; - offsetLeft: number; - visibleColumns: number[]; - - // 스크롤 핸들러 - onScroll: (scrollTop: number, scrollLeft: number) => void; - - // 컨테이너 ref - containerRef: React.RefObject; -} - -export function useVirtual2DScroll( - options: Virtual2DScrollOptions -): Virtual2DScrollResult { - const { - rowCount, - columnCount, - rowHeight, - columnWidth, - containerHeight, - containerWidth, - rowOverscan = 5, - columnOverscan = 3, - } = options; - - const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - const [scrollLeft, setScrollLeft] = useState(0); - - // 행 계산 - const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); - const end = Math.min( - rowCount - 1, - Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan - ); - - const rows: number[] = []; - for (let i = start; i <= end; i++) { - rows.push(i); - } - - return { - rowStartIndex: start, - rowEndIndex: end, - visibleRows: rows, - }; - }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); - - // 열 계산 - const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { - const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); - const end = Math.min( - columnCount - 1, - Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan - ); - - const cols: number[] = []; - for (let i = start; i <= end; i++) { - cols.push(i); - } - - return { - columnStartIndex: start, - columnEndIndex: end, - visibleColumns: cols, - }; - }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); - - const totalHeight = rowCount * rowHeight; - const totalWidth = columnCount * columnWidth; - const offsetTop = rowStartIndex * rowHeight; - const offsetLeft = columnStartIndex * columnWidth; - - const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { - setScrollTop(newScrollTop); - setScrollLeft(newScrollLeft); - }, []); - - // 스크롤 이벤트 리스너 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleScroll = () => { - setScrollTop(container.scrollTop); - setScrollLeft(container.scrollLeft); - }; - - container.addEventListener("scroll", handleScroll, { passive: true }); - - return () => { - container.removeEventListener("scroll", handleScroll); - }; - }, []); - - return { - rowStartIndex, - rowEndIndex, - totalHeight, - offsetTop, - visibleRows, - columnStartIndex, - columnEndIndex, - totalWidth, - offsetLeft, - visibleColumns, - onScroll, - containerRef, - }; -} - -export default useVirtualScroll; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/index.ts b/frontend/lib/registry/components/v2-pivot-grid/index.ts deleted file mode 100644 index 50d5691e..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * PivotGrid 컴포넌트 모듈 - * 다차원 데이터 분석을 위한 피벗 테이블 - */ - -// 타입 내보내기 -export type { - // 기본 타입 - PivotAreaType, - AggregationType, - SummaryDisplayMode, - SortDirection, - DateGroupInterval, - FieldDataType, - DataSourceType, - // 필드 설정 - PivotFieldFormat, - PivotFieldConfig, - // 데이터 소스 - PivotFilterCondition, - PivotJoinConfig, - PivotDataSourceConfig, - // 표시 설정 - PivotTotalsConfig, - FieldChooserConfig, - PivotChartConfig, - PivotStyleConfig, - PivotExportConfig, - // Props - PivotGridProps, - // 결과 데이터 - PivotCellData, - PivotHeaderNode, - PivotCellValue, - PivotResult, - PivotFlatRow, - PivotFlatColumn, - // 상태 - PivotGridState, - // Config - PivotGridComponentConfig, -} from "./types"; - -// 컴포넌트 내보내기 -export { PivotGridComponent } from "./PivotGridComponent"; -export { V2PivotGridConfigPanel as PivotGridConfigPanel } from "@/components/v2/config-panels/V2PivotGridConfigPanel"; - -// 유틸리티 -export { - aggregate, - sum, - count, - avg, - min, - max, - countDistinct, - formatNumber, - formatDate, - getAggregationLabel, -} from "./utils/aggregation"; - -export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine"; diff --git a/frontend/lib/registry/components/v2-pivot-grid/types.ts b/frontend/lib/registry/components/v2-pivot-grid/types.ts deleted file mode 100644 index 87ba2414..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/types.ts +++ /dev/null @@ -1,408 +0,0 @@ -/** - * PivotGrid 컴포넌트 타입 정의 - * 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트 - */ - -// ==================== 기본 타입 ==================== - -// 필드 영역 타입 -export type PivotAreaType = "row" | "column" | "data" | "filter"; - -// 집계 함수 타입 -export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; - -// 요약 표시 모드 -export type SummaryDisplayMode = - | "absoluteValue" // 절대값 (기본) - | "percentOfColumnTotal" // 열 총계 대비 % - | "percentOfRowTotal" // 행 총계 대비 % - | "percentOfGrandTotal" // 전체 총계 대비 % - | "percentOfColumnGrandTotal" // 열 대총계 대비 % - | "percentOfRowGrandTotal" // 행 대총계 대비 % - | "runningTotalByRow" // 행 방향 누계 - | "runningTotalByColumn" // 열 방향 누계 - | "differenceFromPrevious" // 이전 대비 차이 - | "percentDifferenceFromPrevious"; // 이전 대비 % 차이 - -// 정렬 방향 -export type SortDirection = "asc" | "desc" | "none"; - -// 날짜 그룹 간격 -export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day"; - -// 필드 데이터 타입 -export type FieldDataType = "string" | "number" | "date" | "boolean"; - -// 데이터 소스 타입 -export type DataSourceType = "table" | "api" | "static"; - -// ==================== 필드 설정 ==================== - -// 필드 포맷 설정 -export interface PivotFieldFormat { - type: "number" | "currency" | "percent" | "date" | "text"; - precision?: number; // 소수점 자릿수 - thousandSeparator?: boolean; // 천단위 구분자 - prefix?: string; // 접두사 (예: "$", "₩") - suffix?: string; // 접미사 (예: "%", "원") - dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD") -} - -// 필드 설정 -export interface PivotFieldConfig { - // 기본 정보 - field: string; // 데이터 필드명 - caption: string; // 표시 라벨 - area: PivotAreaType; // 배치 영역 - areaIndex?: number; // 영역 내 순서 - - // 데이터 타입 - dataType?: FieldDataType; // 데이터 타입 - - // 집계 설정 (data 영역용) - summaryType?: AggregationType; // 집계 함수 - summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드 - showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭) - - // 정렬 설정 - sortBy?: "value" | "caption"; // 정렬 기준 - sortOrder?: SortDirection; // 정렬 방향 - sortBySummary?: string; // 요약값 기준 정렬 (data 필드명) - - // 날짜 그룹화 설정 - groupInterval?: DateGroupInterval; // 날짜 그룹 간격 - groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성) - - // 표시 설정 - visible?: boolean; // 표시 여부 - width?: number; // 컬럼 너비 - expanded?: boolean; // 기본 확장 상태 - - // 포맷 설정 - format?: PivotFieldFormat; // 값 포맷 - - // 필터 설정 - filterValues?: any[]; // 선택된 필터 값 - filterType?: "include" | "exclude"; // 필터 타입 - allowFiltering?: boolean; // 필터링 허용 - allowSorting?: boolean; // 정렬 허용 - - // 계층 관련 - displayFolder?: string; // 필드 선택기에서 폴더 구조 - isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) - - // 계산 필드 - isCalculated?: boolean; // 계산 필드 여부 - calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]") -} - -// ==================== 데이터 소스 설정 ==================== - -// 필터 조건 -export interface PivotFilterCondition { - field: string; - operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; - value?: any; - valueFromField?: string; // formData에서 값 가져오기 -} - -// 조인 설정 -export interface PivotJoinConfig { - joinType: "INNER" | "LEFT" | "RIGHT"; - targetTable: string; - sourceColumn: string; - targetColumn: string; - columns: string[]; // 가져올 컬럼들 -} - -// 데이터 소스 설정 -export interface PivotDataSourceConfig { - type: DataSourceType; - - // 테이블 기반 - tableName?: string; // 테이블명 - - // API 기반 - apiEndpoint?: string; // API 엔드포인트 - apiMethod?: "GET" | "POST"; // HTTP 메서드 - - // 정적 데이터 - staticData?: any[]; // 정적 데이터 - - // 필터 조건 - filterConditions?: PivotFilterCondition[]; - - // 조인 설정 - joinConfigs?: PivotJoinConfig[]; -} - -// ==================== 표시 설정 ==================== - -// 총합계 표시 설정 -export interface PivotTotalsConfig { - // 행 총합계 - showRowGrandTotals?: boolean; // 행 총합계 표시 - showRowTotals?: boolean; // 행 소계 표시 - rowTotalsPosition?: "first" | "last"; // 소계 위치 - rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단) - - // 열 총합계 - showColumnGrandTotals?: boolean; // 열 총합계 표시 - showColumnTotals?: boolean; // 열 소계 표시 - columnTotalsPosition?: "first" | "last"; // 소계 위치 - columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측) -} - -// 필드 선택기 설정 -export interface FieldChooserConfig { - enabled: boolean; // 활성화 여부 - allowSearch?: boolean; // 검색 허용 - layout?: "default" | "simplified"; // 레이아웃 - height?: number; // 높이 - applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점 -} - -// 차트 연동 설정 -export interface PivotChartConfig { - enabled: boolean; // 차트 표시 여부 - type: "bar" | "line" | "area" | "pie" | "stackedBar"; - position: "top" | "bottom" | "left" | "right"; - height?: number; - showLegend?: boolean; - animate?: boolean; -} - -// 조건부 서식 규칙 -export interface ConditionalFormatRule { - id: string; - type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; - field?: string; // 적용할 데이터 필드 (없으면 전체) - - // colorScale: 값 범위에 따른 색상 그라데이션 - colorScale?: { - minColor: string; // 최소값 색상 (예: "#ff0000") - midColor?: string; // 중간값 색상 (선택) - maxColor: string; // 최대값 색상 (예: "#00ff00") - }; - - // dataBar: 값에 따른 막대 표시 - dataBar?: { - color: string; // 막대 색상 - showValue?: boolean; // 값 표시 여부 - minValue?: number; // 최소값 (없으면 자동) - maxValue?: number; // 최대값 (없으면 자동) - }; - - // iconSet: 값에 따른 아이콘 표시 - iconSet?: { - type: "arrows" | "traffic" | "rating" | "flags"; - thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100) - reverse?: boolean; // 아이콘 순서 반전 - }; - - // cellValue: 조건에 따른 스타일 - cellValue?: { - operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; - value1: number; - value2?: number; // between 연산자용 - backgroundColor?: string; - textColor?: string; - bold?: boolean; - }; -} - -// 스타일 설정 -export interface PivotStyleConfig { - theme: "default" | "compact" | "modern"; - headerStyle: "default" | "dark" | "light"; - cellPadding: "compact" | "normal" | "comfortable"; - borderStyle: "none" | "light" | "heavy"; - alternateRowColors?: boolean; - highlightTotals?: boolean; // 총합계 강조 - conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 - mergeCells?: boolean; // 같은 값 셀 병합 -} - -// ==================== 내보내기 설정 ==================== - -export interface PivotExportConfig { - excel?: boolean; - pdf?: boolean; - fileName?: string; -} - -// ==================== 메인 Props ==================== - -export interface PivotGridProps { - // 기본 설정 - id?: string; - title?: string; - - // 데이터 소스 - dataSource?: PivotDataSourceConfig; - - // 필드 설정 - fields?: PivotFieldConfig[]; - - // 표시 설정 - totals?: PivotTotalsConfig; - style?: PivotStyleConfig; - - // 필드 선택기 - fieldChooser?: FieldChooserConfig; - - // 차트 연동 - chart?: PivotChartConfig; - - // 기능 설정 - allowSortingBySummary?: boolean; // 요약값 기준 정렬 - allowFiltering?: boolean; // 필터링 허용 - allowExpandAll?: boolean; // 전체 확장/축소 허용 - wordWrapEnabled?: boolean; // 텍스트 줄바꿈 - - // 크기 설정 - height?: string | number; - maxHeight?: string; - - // 상태 저장 - stateStoring?: { - enabled: boolean; - storageKey?: string; // localStorage 키 - }; - - // 내보내기 - exportConfig?: PivotExportConfig; - - // 데이터 (외부 주입용) - data?: any[]; - - // 이벤트 - onCellClick?: (cellData: PivotCellData) => void; - onCellDoubleClick?: (cellData: PivotCellData) => void; - onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; - onExpandChange?: (expandedPaths: string[][]) => void; - onDataChange?: (data: any[]) => void; -} - -// ==================== 결과 데이터 구조 ==================== - -// 셀 데이터 -export interface PivotCellData { - value: any; // 셀 값 - rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"]) - columnPath: string[]; // 열 경로 (예: ["2024", "Q1"]) - field?: string; // 데이터 필드명 - aggregationType?: AggregationType; - isTotal?: boolean; // 총합계 여부 - isGrandTotal?: boolean; // 대총합 여부 -} - -// 헤더 노드 (트리 구조) -export interface PivotHeaderNode { - value: any; // 원본 값 - caption: string; // 표시 텍스트 - level: number; // 깊이 - children?: PivotHeaderNode[]; // 자식 노드 - isExpanded: boolean; // 확장 상태 - path: string[]; // 경로 (드릴다운용) - subtotal?: PivotCellValue[]; // 소계 - span?: number; // colspan/rowspan -} - -// 셀 값 -export interface PivotCellValue { - field: string; // 데이터 필드 - value: number | null; // 집계 값 - formattedValue: string; // 포맷된 값 -} - -// 피벗 결과 데이터 구조 -export interface PivotResult { - // 행 헤더 트리 - rowHeaders: PivotHeaderNode[]; - - // 열 헤더 트리 - columnHeaders: PivotHeaderNode[]; - - // 데이터 매트릭스 (rowPath + columnPath → values) - dataMatrix: Map; - - // 플랫 행 목록 (렌더링용) - flatRows: PivotFlatRow[]; - - // 플랫 열 목록 (렌더링용) - flatColumns: PivotFlatColumn[]; - - // 총합계 - grandTotals: { - row: Map; // 행별 총합 - column: Map; // 열별 총합 - grand: PivotCellValue[]; // 대총합 - }; -} - -// 플랫 행 (렌더링용) -export interface PivotFlatRow { - path: string[]; - level: number; - caption: string; - isExpanded: boolean; - hasChildren: boolean; - isTotal?: boolean; -} - -// 플랫 열 (렌더링용) -export interface PivotFlatColumn { - path: string[]; - level: number; - caption: string; - span: number; - isTotal?: boolean; -} - -// ==================== 상태 관리 ==================== - -export interface PivotGridState { - expandedRowPaths: string[][]; // 확장된 행 경로들 - expandedColumnPaths: string[][]; // 확장된 열 경로들 - sortConfig: { - field: string; - direction: SortDirection; - } | null; - filterConfig: Record; // 필드별 필터값 -} - -// ==================== 컴포넌트 Config (화면관리용) ==================== - -export interface PivotGridComponentConfig { - // 데이터 소스 - dataSource?: PivotDataSourceConfig; - - // 필드 설정 - fields?: PivotFieldConfig[]; - - // 표시 설정 - totals?: PivotTotalsConfig; - style?: PivotStyleConfig; - - // 필드 선택기 - fieldChooser?: FieldChooserConfig; - - // 차트 연동 - chart?: PivotChartConfig; - - // 기능 설정 - allowSortingBySummary?: boolean; - allowFiltering?: boolean; - allowExpandAll?: boolean; - wordWrapEnabled?: boolean; - - // 크기 설정 - height?: string | number; - maxHeight?: string; - - // 내보내기 - exportConfig?: PivotExportConfig; -} - - diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts deleted file mode 100644 index 063efe89..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * PivotGrid 집계 함수 유틸리티 - * 다양한 집계 연산을 수행합니다. - */ - -import { getFormatRules } from "@/lib/formatting"; - -import { AggregationType, PivotFieldFormat } from "../types"; - -// ==================== 집계 함수 ==================== - -/** - * 합계 계산 - */ -export function sum(values: number[]): number { - return values.reduce((acc, val) => acc + (val || 0), 0); -} - -/** - * 개수 계산 - */ -export function count(values: any[]): number { - return values.length; -} - -/** - * 평균 계산 - */ -export function avg(values: number[]): number { - if (values.length === 0) return 0; - return sum(values) / values.length; -} - -/** - * 최소값 계산 - */ -export function min(values: number[]): number { - if (values.length === 0) return 0; - return Math.min(...values.filter((v) => v !== null && v !== undefined)); -} - -/** - * 최대값 계산 - */ -export function max(values: number[]): number { - if (values.length === 0) return 0; - return Math.max(...values.filter((v) => v !== null && v !== undefined)); -} - -/** - * 고유값 개수 계산 - */ -export function countDistinct(values: any[]): number { - return new Set(values.filter((v) => v !== null && v !== undefined)).size; -} - -/** - * 집계 타입에 따른 집계 수행 - */ -export function aggregate( - values: any[], - type: AggregationType = "sum" -): number { - const numericValues = values - .map((v) => (typeof v === "number" ? v : parseFloat(v))) - .filter((v) => !isNaN(v)); - - switch (type) { - case "sum": - return sum(numericValues); - case "count": - return count(values); - case "avg": - return avg(numericValues); - case "min": - return min(numericValues); - case "max": - return max(numericValues); - case "countDistinct": - return countDistinct(values); - default: - return sum(numericValues); - } -} - -// ==================== 포맷 함수 ==================== - -/** - * 숫자 포맷팅 - */ -export function formatNumber( - value: number | null | undefined, - format?: PivotFieldFormat -): string { - if (value === null || value === undefined) return "-"; - - const { - type = "number", - precision = 0, - thousandSeparator = true, - prefix = "", - suffix = "", - } = format || {}; - - let formatted: string; - - const locale = getFormatRules().number.locale; - - switch (type) { - case "currency": - formatted = value.toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - break; - - case "percent": - formatted = (value * 100).toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - break; - - case "number": - default: - if (thousandSeparator) { - formatted = value.toLocaleString(locale, { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); - } else { - formatted = value.toFixed(precision); - } - break; - } - - return `${prefix}${formatted}${suffix}`; -} - -/** - * 날짜 포맷팅 - */ -export function formatDate( - value: Date | string | null | undefined, - format: string = getFormatRules().date.display -): string { - if (!value) return "-"; - - const date = typeof value === "string" ? new Date(value) : value; - - if (isNaN(date.getTime())) return "-"; - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const quarter = Math.ceil((date.getMonth() + 1) / 3); - - return format - .replace("YYYY", String(year)) - .replace("MM", month) - .replace("DD", day) - .replace("Q", `Q${quarter}`); -} - -/** - * 집계 타입 라벨 반환 - */ -export function getAggregationLabel(type: AggregationType): string { - const labels: Record = { - sum: "합계", - count: "개수", - avg: "평균", - min: "최소", - max: "최대", - countDistinct: "고유값", - }; - return labels[type] || "합계"; -} - - diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts deleted file mode 100644 index a9195d92..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/conditionalFormat.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * 조건부 서식 유틸리티 - * 셀 값에 따른 스타일 계산 - */ - -import { ConditionalFormatRule } from "../types"; - -// ==================== 타입 ==================== - -export interface CellFormatStyle { - backgroundColor?: string; - textColor?: string; - fontWeight?: string; - dataBarWidth?: number; // 0-100% - dataBarColor?: string; - icon?: string; // 이모지 또는 아이콘 이름 -} - -// ==================== 색상 유틸리티 ==================== - -/** - * HEX 색상을 RGB로 변환 - */ -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null; -} - -/** - * RGB를 HEX로 변환 - */ -function rgbToHex(r: number, g: number, b: number): string { - return ( - "#" + - [r, g, b] - .map((x) => { - const hex = Math.round(x).toString(16); - return hex.length === 1 ? "0" + hex : hex; - }) - .join("") - ); -} - -/** - * 두 색상 사이의 보간 - */ -function interpolateColor( - color1: string, - color2: string, - factor: number -): string { - const rgb1 = hexToRgb(color1); - const rgb2 = hexToRgb(color2); - - if (!rgb1 || !rgb2) return color1; - - const r = rgb1.r + (rgb2.r - rgb1.r) * factor; - const g = rgb1.g + (rgb2.g - rgb1.g) * factor; - const b = rgb1.b + (rgb2.b - rgb1.b) * factor; - - return rgbToHex(r, g, b); -} - -// ==================== 조건부 서식 계산 ==================== - -/** - * Color Scale 스타일 계산 - */ -function applyColorScale( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.colorScale) return {}; - - const { minColor, midColor, maxColor } = rule.colorScale; - const range = maxValue - minValue; - - if (range === 0) { - return { backgroundColor: minColor }; - } - - const normalizedValue = (value - minValue) / range; - - let backgroundColor: string; - - if (midColor) { - // 3색 그라데이션 - if (normalizedValue <= 0.5) { - backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); - } else { - backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); - } - } else { - // 2색 그라데이션 - backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); - } - - // 배경색에 따른 텍스트 색상 결정 - const rgb = hexToRgb(backgroundColor); - const textColor = - rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 - ? "#000000" - : "#ffffff"; - - return { backgroundColor, textColor }; -} - -/** - * Data Bar 스타일 계산 - */ -function applyDataBar( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.dataBar) return {}; - - const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; - - const min = ruleMin ?? minValue; - const max = ruleMax ?? maxValue; - const range = max - min; - - if (range === 0) { - return { dataBarWidth: 100, dataBarColor: color }; - } - - const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); - - return { - dataBarWidth: width, - dataBarColor: color, - }; -} - -/** - * Icon Set 스타일 계산 - */ -function applyIconSet( - value: number, - minValue: number, - maxValue: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.iconSet) return {}; - - const { type, thresholds, reverse } = rule.iconSet; - const range = maxValue - minValue; - const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; - - // 아이콘 정의 - const iconSets: Record = { - arrows: ["↓", "→", "↑"], - traffic: ["🔴", "🟡", "🟢"], - rating: ["⭐", "⭐⭐", "⭐⭐⭐"], - flags: ["🚩", "🏳️", "🏁"], - }; - - const icons = iconSets[type] || iconSets.arrows; - const sortedIcons = reverse ? [...icons].reverse() : icons; - - // 임계값에 따른 아이콘 선택 - let iconIndex = 0; - for (let i = 0; i < thresholds.length; i++) { - if (percentage >= thresholds[i]) { - iconIndex = i + 1; - } - } - iconIndex = Math.min(iconIndex, sortedIcons.length - 1); - - return { - icon: sortedIcons[iconIndex], - }; -} - -/** - * Cell Value 조건 스타일 계산 - */ -function applyCellValue( - value: number, - rule: ConditionalFormatRule -): CellFormatStyle { - if (!rule.cellValue) return {}; - - const { operator, value1, value2, backgroundColor, textColor, bold } = - rule.cellValue; - - let matches = false; - - switch (operator) { - case ">": - matches = value > value1; - break; - case ">=": - matches = value >= value1; - break; - case "<": - matches = value < value1; - break; - case "<=": - matches = value <= value1; - break; - case "=": - matches = value === value1; - break; - case "!=": - matches = value !== value1; - break; - case "between": - matches = value2 !== undefined && value >= value1 && value <= value2; - break; - } - - if (!matches) return {}; - - return { - backgroundColor, - textColor, - fontWeight: bold ? "bold" : undefined, - }; -} - -// ==================== 메인 함수 ==================== - -/** - * 조건부 서식 적용 - */ -export function getConditionalStyle( - value: number | null | undefined, - field: string, - rules: ConditionalFormatRule[], - allValues: number[] // 해당 필드의 모든 값 (min/max 계산용) -): CellFormatStyle { - if (value === null || value === undefined || isNaN(value)) { - return {}; - } - - if (!rules || rules.length === 0) { - return {}; - } - - // min/max 계산 - const numericValues = allValues.filter((v) => !isNaN(v)); - const minValue = Math.min(...numericValues); - const maxValue = Math.max(...numericValues); - - let resultStyle: CellFormatStyle = {}; - - // 해당 필드에 적용되는 규칙 필터링 및 적용 - for (const rule of rules) { - // 필드 필터 확인 - if (rule.field && rule.field !== field) { - continue; - } - - let ruleStyle: CellFormatStyle = {}; - - switch (rule.type) { - case "colorScale": - ruleStyle = applyColorScale(value, minValue, maxValue, rule); - break; - case "dataBar": - ruleStyle = applyDataBar(value, minValue, maxValue, rule); - break; - case "iconSet": - ruleStyle = applyIconSet(value, minValue, maxValue, rule); - break; - case "cellValue": - ruleStyle = applyCellValue(value, rule); - break; - } - - // 스타일 병합 (나중 규칙이 우선) - resultStyle = { ...resultStyle, ...ruleStyle }; - } - - return resultStyle; -} - -/** - * 조건부 서식 스타일을 React 스타일 객체로 변환 - */ -export function formatStyleToReact( - style: CellFormatStyle -): React.CSSProperties { - const result: React.CSSProperties = {}; - - if (style.backgroundColor) { - result.backgroundColor = style.backgroundColor; - } - if (style.textColor) { - result.color = style.textColor; - } - if (style.fontWeight) { - result.fontWeight = style.fontWeight as any; - } - - return result; -} - -export default getConditionalStyle; - diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts deleted file mode 100644 index 6069a3a5..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/exportExcel.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Excel 내보내기 유틸리티 - * 피벗 테이블 데이터를 Excel 파일로 내보내기 - * xlsx 라이브러리 사용 (브라우저 호환) - */ - -import * as XLSX from "xlsx"; -import { - PivotResult, - PivotFieldConfig, - PivotTotalsConfig, -} from "../types"; -import { pathToKey } from "./pivotEngine"; - -// ==================== 타입 ==================== - -export interface ExportOptions { - fileName?: string; - sheetName?: string; - title?: string; - subtitle?: string; - includeHeaders?: boolean; - includeTotals?: boolean; -} - -// ==================== 메인 함수 ==================== - -/** - * 피벗 데이터를 Excel로 내보내기 - */ -export async function exportPivotToExcel( - pivotResult: PivotResult, - fields: PivotFieldConfig[], - totals: PivotTotalsConfig, - options: ExportOptions = {} -): Promise { - const { - fileName = "pivot_export", - sheetName = "Pivot", - title, - includeHeaders = true, - includeTotals = true, - } = options; - - // 필드 분류 - const rowFields = fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - // 데이터 배열 생성 - const data: any[][] = []; - - // 제목 추가 - if (title) { - data.push([title]); - data.push([]); // 빈 행 - } - - // 헤더 행 - if (includeHeaders) { - const headerRow: any[] = [ - rowFields.map((f) => f.caption).join(" / ") || "항목", - ]; - - // 열 헤더 - for (const col of pivotResult.flatColumns) { - headerRow.push(col.caption || "(전체)"); - } - - // 총계 헤더 - if (totals?.showRowGrandTotals && includeTotals) { - headerRow.push("총계"); - } - - data.push(headerRow); - } - - // 데이터 행 - for (const row of pivotResult.flatRows) { - const excelRow: any[] = []; - - // 행 헤더 (들여쓰기 포함) - const indent = " ".repeat(row.level); - excelRow.push(indent + row.caption); - - // 데이터 셀 - for (const col of pivotResult.flatColumns) { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; - const values = pivotResult.dataMatrix.get(cellKey); - - if (values && values.length > 0) { - excelRow.push(values[0].value); - } else { - excelRow.push(""); - } - } - - // 행 총계 - if (totals?.showRowGrandTotals && includeTotals) { - const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); - if (rowTotal && rowTotal.length > 0) { - excelRow.push(rowTotal[0].value); - } else { - excelRow.push(""); - } - } - - data.push(excelRow); - } - - // 열 총계 행 - if (totals?.showColumnGrandTotals && includeTotals) { - const totalRow: any[] = ["총계"]; - - for (const col of pivotResult.flatColumns) { - const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); - if (colTotal && colTotal.length > 0) { - totalRow.push(colTotal[0].value); - } else { - totalRow.push(""); - } - } - - // 대총합 - if (totals?.showRowGrandTotals) { - const grandTotal = pivotResult.grandTotals.grand; - if (grandTotal && grandTotal.length > 0) { - totalRow.push(grandTotal[0].value); - } else { - totalRow.push(""); - } - } - - data.push(totalRow); - } - - // 워크시트 생성 - const worksheet = XLSX.utils.aoa_to_sheet(data); - - // 컬럼 너비 설정 - const colWidths: XLSX.ColInfo[] = []; - const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); - for (let i = 0; i < maxCols; i++) { - colWidths.push({ wch: i === 0 ? 25 : 15 }); - } - worksheet["!cols"] = colWidths; - - // 워크북 생성 - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - - // 파일 다운로드 - XLSX.writeFile(workbook, `${fileName}.xlsx`); -} - -/** - * Drill Down 데이터를 Excel로 내보내기 - */ -export async function exportDrillDownToExcel( - data: any[], - columns: { field: string; caption: string }[], - options: ExportOptions = {} -): Promise { - const { - fileName = "drilldown_export", - sheetName = "Data", - title, - } = options; - - // 데이터 배열 생성 - const sheetData: any[][] = []; - - // 제목 - if (title) { - sheetData.push([title]); - sheetData.push([]); // 빈 행 - } - - // 헤더 - const headerRow = columns.map((col) => col.caption); - sheetData.push(headerRow); - - // 데이터 - for (const row of data) { - const dataRow = columns.map((col) => row[col.field] ?? ""); - sheetData.push(dataRow); - } - - // 워크시트 생성 - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - - // 컬럼 너비 설정 - const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); - worksheet["!cols"] = colWidths; - - // 워크북 생성 - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - - // 파일 다운로드 - XLSX.writeFile(workbook, `${fileName}.xlsx`); -} diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts deleted file mode 100644 index 2c0a83d6..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./aggregation"; -export * from "./pivotEngine"; -export * from "./exportExcel"; -export * from "./conditionalFormat"; - - diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts deleted file mode 100644 index 209f1606..00000000 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts +++ /dev/null @@ -1,812 +0,0 @@ -/** - * PivotGrid 데이터 처리 엔진 - * 원시 데이터를 피벗 구조로 변환합니다. - */ - -import { - PivotFieldConfig, - PivotResult, - PivotHeaderNode, - PivotFlatRow, - PivotFlatColumn, - PivotCellValue, - DateGroupInterval, - AggregationType, - SummaryDisplayMode, -} from "../types"; -import { aggregate, formatNumber, formatDate } from "./aggregation"; - -// ==================== 헬퍼 함수 ==================== - -/** - * 필드 값 추출 (날짜 그룹핑 포함) - */ -function getFieldValue( - row: Record, - field: PivotFieldConfig -): string { - const rawValue = row[field.field]; - - if (rawValue === null || rawValue === undefined) { - return "(빈 값)"; - } - - // 날짜 그룹핑 처리 - if (field.groupInterval && field.dataType === "date") { - const date = new Date(rawValue); - if (isNaN(date.getTime())) return String(rawValue); - - switch (field.groupInterval) { - case "year": - return String(date.getFullYear()); - case "quarter": - return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; - case "month": - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; - case "week": - const weekNum = getWeekNumber(date); - return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; - case "day": - return formatDate(date); - default: - return String(rawValue); - } - } - - return String(rawValue); -} - -/** - * 주차 계산 - */ -function getWeekNumber(date: Date): number { - const d = new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - ); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); -} - -/** - * 경로를 키로 변환 - */ -export function pathToKey(path: string[]): string { - return path.join("||"); -} - -/** - * 키를 경로로 변환 - */ -export function keyToPath(key: string): string[] { - return key.split("||"); -} - -// ==================== 헤더 생성 ==================== - -/** - * 계층적 헤더 노드 생성 - */ -function buildHeaderTree( - data: Record[], - fields: PivotFieldConfig[], - expandedPaths: Set -): PivotHeaderNode[] { - if (fields.length === 0) return []; - - // 첫 번째 필드로 그룹화 - const firstField = fields[0]; - const groups = new Map[]>(); - - data.forEach((row) => { - const value = getFieldValue(row, firstField); - if (!groups.has(value)) { - groups.set(value, []); - } - groups.get(value)!.push(row); - }); - - // 정렬 - const sortedKeys = Array.from(groups.keys()).sort((a, b) => { - if (firstField.sortOrder === "desc") { - return b.localeCompare(a, "ko"); - } - return a.localeCompare(b, "ko"); - }); - - // 노드 생성 - const nodes: PivotHeaderNode[] = []; - const remainingFields = fields.slice(1); - - for (const key of sortedKeys) { - const groupData = groups.get(key)!; - const path = [key]; - const pathKey = pathToKey(path); - - const node: PivotHeaderNode = { - value: key, - caption: key, - level: 0, - isExpanded: expandedPaths.has(pathKey), - path: path, - span: 1, - }; - - // 자식 노드 생성 (확장된 경우만) - if (remainingFields.length > 0 && node.isExpanded) { - node.children = buildChildNodes( - groupData, - remainingFields, - path, - expandedPaths, - 1 - ); - // span 계산 - node.span = calculateSpan(node.children); - } - - nodes.push(node); - } - - return nodes; -} - -/** - * 자식 노드 재귀 생성 - */ -function buildChildNodes( - data: Record[], - fields: PivotFieldConfig[], - parentPath: string[], - expandedPaths: Set, - level: number -): PivotHeaderNode[] { - if (fields.length === 0) return []; - - const field = fields[0]; - const groups = new Map[]>(); - - data.forEach((row) => { - const value = getFieldValue(row, field); - if (!groups.has(value)) { - groups.set(value, []); - } - groups.get(value)!.push(row); - }); - - const sortedKeys = Array.from(groups.keys()).sort((a, b) => { - if (field.sortOrder === "desc") { - return b.localeCompare(a, "ko"); - } - return a.localeCompare(b, "ko"); - }); - - const nodes: PivotHeaderNode[] = []; - const remainingFields = fields.slice(1); - - for (const key of sortedKeys) { - const groupData = groups.get(key)!; - const path = [...parentPath, key]; - const pathKey = pathToKey(path); - - const node: PivotHeaderNode = { - value: key, - caption: key, - level: level, - isExpanded: expandedPaths.has(pathKey), - path: path, - span: 1, - }; - - if (remainingFields.length > 0 && node.isExpanded) { - node.children = buildChildNodes( - groupData, - remainingFields, - path, - expandedPaths, - level + 1 - ); - node.span = calculateSpan(node.children); - } - - nodes.push(node); - } - - return nodes; -} - -/** - * span 계산 (colspan/rowspan) - */ -function calculateSpan(children?: PivotHeaderNode[]): number { - if (!children || children.length === 0) return 1; - return children.reduce((sum, child) => sum + (child.span ?? 1), 0); -} - -// ==================== 플랫 구조 변환 ==================== - -/** - * 헤더 트리를 플랫 행으로 변환 - */ -function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { - const result: PivotFlatRow[] = []; - - function traverse(node: PivotHeaderNode) { - result.push({ - path: node.path, - level: node.level, - caption: node.caption, - isExpanded: node.isExpanded, - hasChildren: !!(node.children && node.children.length > 0), - }); - - if (node.isExpanded && node.children) { - for (const child of node.children) { - traverse(child); - } - } - } - - for (const node of nodes) { - traverse(node); - } - - return result; -} - -/** - * 헤더 트리를 플랫 열로 변환 (각 레벨별) - */ -function flattenColumns( - nodes: PivotHeaderNode[], - maxLevel: number -): PivotFlatColumn[][] { - const levels: PivotFlatColumn[][] = Array.from( - { length: maxLevel + 1 }, - () => [] - ); - - function traverse(node: PivotHeaderNode, currentLevel: number) { - levels[currentLevel].push({ - path: node.path, - level: currentLevel, - caption: node.caption, - span: node.span ?? 1, - }); - - if (node.children && node.isExpanded) { - for (const child of node.children) { - traverse(child, currentLevel + 1); - } - } else if (currentLevel < maxLevel) { - // 확장되지 않은 노드는 다음 레벨들에서 span으로 처리 - for (let i = currentLevel + 1; i <= maxLevel; i++) { - levels[i].push({ - path: node.path, - level: i, - caption: "", - span: node.span ?? 1, - }); - } - } - } - - for (const node of nodes) { - traverse(node, 0); - } - - return levels; -} - -/** - * 열 헤더의 최대 깊이 계산 - */ -function getMaxColumnLevel( - nodes: PivotHeaderNode[], - totalFields: number -): number { - let maxLevel = 0; - - function traverse(node: PivotHeaderNode, level: number) { - maxLevel = Math.max(maxLevel, level); - if (node.children && node.isExpanded) { - for (const child of node.children) { - traverse(child, level + 1); - } - } - } - - for (const node of nodes) { - traverse(node, 0); - } - - return Math.min(maxLevel, totalFields - 1); -} - -// ==================== 데이터 매트릭스 생성 ==================== - -/** - * 데이터 매트릭스 생성 - */ -function buildDataMatrix( - data: Record[], - rowFields: PivotFieldConfig[], - columnFields: PivotFieldConfig[], - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][] -): Map { - const matrix = new Map(); - - // 각 셀에 대해 해당하는 데이터 집계 - for (const row of flatRows) { - for (const colPath of flatColumnLeaves) { - const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; - - // 해당 행/열 경로에 맞는 데이터 필터링 - const filteredData = data.filter((record) => { - // 행 조건 확인 - for (let i = 0; i < row.path.length; i++) { - const field = rowFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== row.path[i]) return false; - } - - // 열 조건 확인 - for (let i = 0; i < colPath.length; i++) { - const field = columnFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== colPath[i]) return false; - } - - return true; - }); - - // 데이터 필드별 집계 - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate( - values, - dataField.summaryType || "sum" - ); - const formattedValue = formatNumber( - aggregatedValue, - dataField.format - ); - - return { - field: dataField.field, - value: aggregatedValue, - formattedValue, - }; - }); - - matrix.set(cellKey, cellValues); - } - } - - return matrix; -} - -/** - * 열 leaf 노드 경로 추출 - */ -function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { - const leaves: string[][] = []; - - function traverse(node: PivotHeaderNode) { - if (!node.isExpanded || !node.children || node.children.length === 0) { - leaves.push(node.path); - } else { - for (const child of node.children) { - traverse(child); - } - } - } - - for (const node of nodes) { - traverse(node); - } - - // 열 필드가 없을 경우 빈 경로 추가 - if (leaves.length === 0) { - leaves.push([]); - } - - return leaves; -} - -// ==================== Summary Display Mode 적용 ==================== - -/** - * Summary Display Mode에 따른 값 변환 - */ -function applyDisplayMode( - value: number, - displayMode: SummaryDisplayMode | undefined, - rowTotal: number, - columnTotal: number, - grandTotal: number, - prevValue: number | null, - runningTotal: number, - format?: PivotFieldConfig["format"] -): { value: number; formattedValue: string } { - if (!displayMode || displayMode === "absoluteValue") { - return { - value, - formattedValue: formatNumber(value, format), - }; - } - - let resultValue: number; - let formatOverride: PivotFieldConfig["format"] | undefined; - - switch (displayMode) { - case "percentOfRowTotal": - resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfColumnTotal": - resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfGrandTotal": - resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfRowGrandTotal": - resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "percentOfColumnGrandTotal": - resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - case "runningTotalByRow": - case "runningTotalByColumn": - resultValue = runningTotal; - break; - - case "differenceFromPrevious": - resultValue = prevValue === null ? 0 : value - prevValue; - break; - - case "percentDifferenceFromPrevious": - resultValue = prevValue === null || prevValue === 0 - ? 0 - : ((value - prevValue) / Math.abs(prevValue)) * 100; - formatOverride = { type: "percent", precision: 2, suffix: "%" }; - break; - - default: - resultValue = value; - } - - return { - value: resultValue, - formattedValue: formatNumber(resultValue, formatOverride || format), - }; -} - -/** - * 데이터 매트릭스에 Summary Display Mode 적용 - */ -function applyDisplayModeToMatrix( - matrix: Map, - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][], - rowTotals: Map, - columnTotals: Map, - grandTotals: PivotCellValue[] -): Map { - // displayMode가 있는 데이터 필드가 있는지 확인 - const hasDisplayMode = dataFields.some( - (df) => df.summaryDisplayMode || df.showValuesAs - ); - if (!hasDisplayMode) return matrix; - - const newMatrix = new Map(); - - // 누계를 위한 추적 (행별, 열별) - const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 - const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 - - // 행 순서대로 처리 - for (const row of flatRows) { - // 이전 열 값 추적 (차이 계산용) - const prevColValues: (number | null)[] = dataFields.map(() => null); - - for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { - const colPath = flatColumnLeaves[colIdx]; - const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; - const values = matrix.get(cellKey); - - if (!values) { - newMatrix.set(cellKey, []); - continue; - } - - const rowKey = pathToKey(row.path); - const colKey = pathToKey(colPath); - - // 총합 가져오기 - const rowTotal = rowTotals.get(rowKey); - const colTotal = columnTotals.get(colKey); - - const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { - const dataField = dataFields[fieldIdx]; - const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; - - if (!displayMode || displayMode === "absoluteValue") { - prevColValues[fieldIdx] = val.value; - return val; - } - - // 누계 계산 - // 행 방향 누계 - if (!rowRunningTotals.has(rowKey)) { - rowRunningTotals.set(rowKey, dataFields.map(() => 0)); - } - const rowRunning = rowRunningTotals.get(rowKey)!; - rowRunning[fieldIdx] += val.value || 0; - - // 열 방향 누계 - if (!colRunningTotals.has(colKey)) { - colRunningTotals.set(colKey, new Map()); - } - const colRunning = colRunningTotals.get(colKey)!; - if (!colRunning.has(fieldIdx)) { - colRunning.set(fieldIdx, 0); - } - colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); - - const result = applyDisplayMode( - val.value || 0, - displayMode, - rowTotal?.[fieldIdx]?.value || 0, - colTotal?.[fieldIdx]?.value || 0, - grandTotals[fieldIdx]?.value || 0, - prevColValues[fieldIdx], - displayMode === "runningTotalByRow" - ? rowRunning[fieldIdx] - : colRunning.get(fieldIdx) || 0, - dataField.format - ); - - prevColValues[fieldIdx] = val.value; - - return { - field: val.field, - value: result.value, - formattedValue: result.formattedValue, - }; - }); - - newMatrix.set(cellKey, newValues); - } - } - - return newMatrix; -} - -// ==================== 총합계 계산 ==================== - -/** - * 총합계 계산 - */ -function calculateGrandTotals( - data: Record[], - rowFields: PivotFieldConfig[], - columnFields: PivotFieldConfig[], - dataFields: PivotFieldConfig[], - flatRows: PivotFlatRow[], - flatColumnLeaves: string[][] -): { - row: Map; - column: Map; - grand: PivotCellValue[]; -} { - const rowTotals = new Map(); - const columnTotals = new Map(); - - // 행별 총합 (각 행의 모든 열 합계) - for (const row of flatRows) { - const filteredData = data.filter((record) => { - for (let i = 0; i < row.path.length; i++) { - const field = rowFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== row.path[i]) return false; - } - return true; - }); - - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - rowTotals.set(pathToKey(row.path), cellValues); - } - - // 열별 총합 (각 열의 모든 행 합계) - for (const colPath of flatColumnLeaves) { - const filteredData = data.filter((record) => { - for (let i = 0; i < colPath.length; i++) { - const field = columnFields[i]; - if (!field) continue; - const value = getFieldValue(record, field); - if (value !== colPath[i]) return false; - } - return true; - }); - - const cellValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = filteredData.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - columnTotals.set(pathToKey(colPath), cellValues); - } - - // 대총합 - const grandValues: PivotCellValue[] = dataFields.map((dataField) => { - const values = data.map((r) => r[dataField.field]); - const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); - return { - field: dataField.field, - value: aggregatedValue, - formattedValue: formatNumber(aggregatedValue, dataField.format), - }; - }); - - return { - row: rowTotals, - column: columnTotals, - grand: grandValues, - }; -} - -// ==================== 메인 함수 ==================== - -/** - * 피벗 데이터 처리 - */ -export function processPivotData( - data: Record[], - fields: PivotFieldConfig[], - expandedRowPaths: string[][] = [], - expandedColumnPaths: string[][] = [] -): PivotResult { - // 영역별 필드 분리 - const rowFields = fields - .filter((f) => f.area === "row" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const columnFields = fields - .filter((f) => f.area === "column" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const dataFields = fields - .filter((f) => f.area === "data" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - const filterFields = fields.filter( - (f) => f.area === "filter" && f.visible !== false - ); - - // 필터 적용 - let filteredData = data; - for (const filterField of filterFields) { - if (filterField.filterValues && filterField.filterValues.length > 0) { - filteredData = filteredData.filter((row) => { - const value = getFieldValue(row, filterField); - if (filterField.filterType === "exclude") { - return !filterField.filterValues!.includes(value); - } - return filterField.filterValues!.includes(value); - }); - } - } - - // 확장 경로 Set 변환 - const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); - const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); - - // 기본 확장: 첫 번째 레벨 모두 확장 - if (expandedRowPaths.length === 0 && rowFields.length > 0) { - const firstField = rowFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedRowSet.add(val)); - } - - if (expandedColumnPaths.length === 0 && columnFields.length > 0) { - const firstField = columnFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedColSet.add(val)); - } - - // 헤더 트리 생성 - const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); - const columnHeaders = buildHeaderTree( - filteredData, - columnFields, - expandedColSet - ); - - // 플랫 구조 변환 - const flatRows = flattenRows(rowHeaders); - const flatColumnLeaves = getColumnLeaves(columnHeaders); - const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); - const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); - - // 데이터 매트릭스 생성 - let dataMatrix = buildDataMatrix( - filteredData, - rowFields, - columnFields, - dataFields, - flatRows, - flatColumnLeaves - ); - - // 총합계 계산 - const grandTotals = calculateGrandTotals( - filteredData, - rowFields, - columnFields, - dataFields, - flatRows, - flatColumnLeaves - ); - - // Summary Display Mode 적용 - dataMatrix = applyDisplayModeToMatrix( - dataMatrix, - dataFields, - flatRows, - flatColumnLeaves, - grandTotals.row, - grandTotals.column, - grandTotals.grand - ); - - return { - rowHeaders, - columnHeaders, - dataMatrix, - flatRows, - flatColumns: flatColumnLeaves.map((path, idx) => ({ - path, - level: path.length - 1, - caption: path[path.length - 1] || "", - span: 1, - })), - grandTotals, - }; -} - - diff --git a/frontend/lib/registry/components/v2-table-grouped/README.md b/frontend/lib/registry/components/v2-table-grouped/README.md deleted file mode 100644 index fc39733e..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# v2-table-grouped (그룹화 테이블) - -## 개요 - -데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다. - -## 주요 기능 - -- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑 -- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글 -- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시 -- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택 -- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼 - -## 사용 예시 - -```tsx -import { TableGroupedComponent } from "./TableGroupedComponent"; - - console.log("선택:", event.selectedItems)} - onRowClick={(event) => console.log("행 클릭:", event.row)} -/> -``` - -## 설정 옵션 - -### 기본 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `selectedTable` | string | - | 데이터 테이블명 | -| `useCustomTable` | boolean | false | 커스텀 테이블 사용 여부 | -| `customTableName` | string | - | 커스텀 테이블명 | -| `columns` | ColumnConfig[] | [] | 표시할 컬럼 설정 | - -### 그룹화 설정 (groupConfig) - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `groupByColumn` | string | - | 그룹화 기준 컬럼 (필수) | -| `groupLabelFormat` | string | "{value}" | 그룹 라벨 형식 | -| `defaultExpanded` | boolean | true | 초기 펼침 상태 | -| `sortDirection` | "asc" \| "desc" | "asc" | 그룹 정렬 방향 | -| `summary.showCount` | boolean | true | 개수 표시 여부 | -| `summary.sumColumns` | string[] | [] | 합계 컬럼 목록 | -| `summary.avgColumns` | string[] | [] | 평균 컬럼 목록 | - -### 표시 설정 - -| 옵션 | 타입 | 기본값 | 설명 | -|------|------|--------|------| -| `showCheckbox` | boolean | false | 체크박스 표시 | -| `checkboxMode` | "single" \| "multi" | "multi" | 선택 모드 | -| `showExpandAllButton` | boolean | true | 전체 펼치기/접기 버튼 | -| `groupHeaderStyle` | "default" \| "compact" \| "card" | "default" | 그룹 헤더 스타일 | -| `rowClickable` | boolean | true | 행 클릭 가능 여부 | -| `maxHeight` | number | 600 | 최대 높이 (px) | -| `emptyMessage` | string | "데이터가 없습니다." | 빈 데이터 메시지 | - -## 이벤트 - -### onSelectionChange - -선택 상태가 변경될 때 호출됩니다. - -```typescript -interface SelectionChangeEvent { - selectedGroups: string[]; // 선택된 그룹 키 목록 - selectedItems: any[]; // 선택된 아이템 전체 - isAllSelected: boolean; // 전체 선택 여부 -} -``` - -### onGroupToggle - -그룹 펼치기/접기 시 호출됩니다. - -```typescript -interface GroupToggleEvent { - groupKey: string; // 그룹 키 - expanded: boolean; // 펼침 상태 -} -``` - -### onRowClick - -행 클릭 시 호출됩니다. - -```typescript -interface RowClickEvent { - row: any; // 클릭된 행 데이터 - groupKey: string; // 그룹 키 - indexInGroup: number; // 그룹 내 인덱스 -} -``` - -## 그룹 라벨 형식 - -`groupLabelFormat`에서 사용 가능한 플레이스홀더: - -- `{value}`: 그룹화 컬럼의 값 -- `{컬럼명}`: 해당 컬럼의 값 - -**예시:** -``` -groupLabelFormat: "{item_name} ({item_code}) - {category}" -// 결과: "제품A (P001) - 완제품" -``` - -## 파일 구조 - -``` -v2-table-grouped/ -├── index.ts # Definition -├── types.ts # 타입 정의 -├── config.ts # 기본 설정값 -├── TableGroupedComponent.tsx # 메인 컴포넌트 -├── TableGroupedConfigPanel.tsx # 설정 패널 -├── TableGroupedRenderer.tsx # 레지스트리 등록 -├── components/ -│ └── GroupHeader.tsx # 그룹 헤더 -├── hooks/ -│ └── useGroupedData.ts # 그룹화 로직 훅 -└── README.md -``` - -## v2-table-list와의 차이점 - -| 항목 | v2-table-list | v2-table-grouped | -|------|---------------|------------------| -| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) | -| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 | -| 선택 | 개별 행 | 그룹 단위 / 개별 단위 | -| 요약 | 전체 합계 (선택) | 그룹별 요약 | -| 용도 | 일반 데이터 목록 | 카테고리별 분류 데이터 | - -## 관련 컴포넌트 - -- `v2-table-list`: 기본 테이블 (그룹화 없음) -- `v2-pivot-grid`: 피벗 테이블 (다차원 집계) -- `v2-split-panel-layout`: 마스터-디테일 레이아웃 diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx deleted file mode 100644 index d47cbf90..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx +++ /dev/null @@ -1,537 +0,0 @@ -"use client"; - -import React, { useCallback, useEffect, useState, useMemo } from "react"; -import { Loader2, FoldVertical, UnfoldVertical } from "lucide-react"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { TableGroupedComponentProps } from "./types"; -import { useGroupedData } from "./hooks/useGroupedData"; -import { GroupHeader } from "./components/GroupHeader"; -import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useTableOptions } from "@/contexts/TableOptionsContext"; -import type { DataProvidable, DataReceivable, DataReceivableComponentType } from "@/types/data-transfer"; -import { v2EventBus, V2_EVENTS } from "@/lib/v2-core/events"; - -/** - * v2-table-grouped 메인 컴포넌트 - * - * 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다. - */ -export function TableGroupedComponent({ - config, - isDesignMode = false, - formData, - onSelectionChange, - onGroupToggle, - onRowClick, - externalData, - isLoading: externalLoading, - error: externalError, - componentId, -}: TableGroupedComponentProps) { - // 화면 컨텍스트 (데이터 제공자로 등록) - const screenContext = useScreenContextOptional(); - - // TableOptions Context (검색필터 연동) - const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); - - // 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) - const [linkedFilterValues, setLinkedFilterValues] = useState>({}); - - // 필터 및 그룹 설정 상태 (검색필터 연동용) - const [filters, setFilters] = useState([]); - const [grouping, setGrouping] = useState([]); - const [columnVisibility, setColumnVisibility] = useState([]); - - // 그룹화 데이터 훅 (검색 필터 전달) - const { - groups, - isLoading: hookLoading, - error: hookError, - toggleGroup, - expandAll, - collapseAll, - toggleItemSelection, - toggleGroupSelection, - toggleAllSelection, - selectedItems, - isAllSelected, - isIndeterminate, - refresh, - rawData, - totalCount, - groupCount, - } = useGroupedData(config, externalData, linkedFilterValues); - - const isLoading = externalLoading ?? hookLoading; - const error = externalError ?? hookError; - - // 필터링된 데이터 (훅에서 이미 필터 적용됨) - const filteredData = rawData; - - // 연결된 필터 감시 - useEffect(() => { - const linkedFilters = config.linkedFilters; - - if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { - return; - } - - // 연결된 소스 컴포넌트들의 값을 주기적으로 확인 - const checkLinkedFilters = () => { - const newFilterValues: Record = {}; - let hasChanges = false; - - linkedFilters.forEach((filter) => { - if (filter.enabled === false) return; - - const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId); - if (sourceProvider) { - const selectedData = sourceProvider.getSelectedData(); - if (selectedData && selectedData.length > 0) { - const sourceField = filter.sourceField || "value"; - const value = selectedData[0][sourceField]; - - if (value !== linkedFilterValues[filter.targetColumn]) { - newFilterValues[filter.targetColumn] = value; - hasChanges = true; - } else { - newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn]; - } - } - } - }); - - if (hasChanges) { - setLinkedFilterValues(newFilterValues); - } - }; - - // 초기 확인 - checkLinkedFilters(); - - // 주기적 확인 (100ms 간격) - const intervalId = setInterval(checkLinkedFilters, 100); - - return () => { - clearInterval(intervalId); - }; - }, [screenContext, config.linkedFilters, linkedFilterValues]); - - // DataProvidable 인터페이스 구현 - const dataProvider: DataProvidable = useMemo( - () => ({ - component_id: componentId || "", - component_type: "table-grouped", - - getSelectedData: () => { - return selectedItems; - }, - - getAllData: () => { - return filteredData; - }, - - clearSelection: () => { - toggleAllSelection(); - }, - }), - [componentId, selectedItems, filteredData, toggleAllSelection] - ); - - // DataReceivable 인터페이스 구현 - const dataReceiver: DataReceivable = useMemo( - () => ({ - component_id: componentId || "", - component_type: "table" as DataReceivableComponentType, - - receiveData: async (_receivedData: any[], _config: any) => { - // 현재는 외부 데이터 수신 시 새로고침만 수행 - refresh(); - }, - - getData: () => { - return filteredData; - }, - }), - [componentId, refresh, filteredData] - ); - - // 화면 컨텍스트에 데이터 제공자/수신자로 등록 - useEffect(() => { - if (screenContext && componentId) { - screenContext.registerDataProvider(componentId, dataProvider); - screenContext.registerDataReceiver(componentId, dataReceiver); - - return () => { - screenContext.unregisterDataProvider(componentId); - screenContext.unregisterDataReceiver(componentId); - }; - } - }, [screenContext, componentId, dataProvider, dataReceiver]); - - // 테이블 ID (검색필터 연동용) - const tableId = componentId || `table-grouped-${config.selectedTable || "default"}`; - - // TableOptionsContext에 테이블 등록 (검색필터가 테이블을 찾을 수 있도록) - useEffect(() => { - if (isDesignMode || !config.selectedTable) return; - - const columnsToRegister = config.columns || []; - - // 고유 값 조회 함수 - const getColumnUniqueValues = async (columnName: string) => { - const uniqueValues = new Set(); - rawData.forEach((row) => { - const value = row[columnName]; - if (value !== null && value !== undefined && value !== "") { - uniqueValues.add(String(value)); - } - }); - return Array.from(uniqueValues) - .map((value) => ({ value, label: value })) - .sort((a, b) => a.label.localeCompare(b.label)); - }; - - const registration = { - table_id: tableId, - label: config.selectedTable, - table_name: config.selectedTable, - data_count: totalCount, - columns: columnsToRegister.map((col) => ({ - column_name: col.columnName, - column_label: col.displayName || col.columnName, - input_type: "text", - visible: col.visible !== false, - width: col.width || 150, - sortable: true, - filterable: true, - })), - onFilterChange: setFilters, - onGroupChange: setGrouping, - onColumnVisibilityChange: setColumnVisibility, - getColumnUniqueValues, - }; - - registerTable(registration); - - return () => { - unregisterTable(tableId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableId, config.selectedTable, config.columns, totalCount, rawData, registerTable, isDesignMode]); - - // 데이터 건수 변경 시 업데이트 - useEffect(() => { - if (!isDesignMode && config.selectedTable) { - updateTableDataCount(tableId, totalCount); - } - }, [tableId, totalCount, updateTableDataCount, config.selectedTable, isDesignMode]); - - // 필터 변경 시 검색 조건 적용 - useEffect(() => { - if (filters.length > 0) { - const newFilterValues: Record = {}; - filters.forEach((filter: any) => { - if (filter.value) { - newFilterValues[filter.columnName] = filter.value; - } - }); - setLinkedFilterValues((prev) => ({ ...prev, ...newFilterValues })); - } - }, [filters]); - - // 컬럼 설정 - const columns = config.columns || []; - const visibleColumns = columns.filter((col) => col.visible !== false); - - // 체크박스 컬럼 포함 시 총 컬럼 수 - const totalColumnCount = visibleColumns.length + (config.showCheckbox ? 1 : 0); - - // 아이템 ID 추출 함수 - const getItemId = useCallback( - (item: any): string => { - if (item.id !== undefined) return String(item.id); - const firstCol = columns[0]?.columnName; - if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]); - return JSON.stringify(item); - }, - [columns] - ); - - // 선택 변경 시 콜백 및 이벤트 발송 - useEffect(() => { - // 기존 콜백 호출 - if (onSelectionChange && selectedItems.length >= 0) { - onSelectionChange({ - selectedGroups: groups - .filter((g) => g.selected) - .map((g) => g.groupKey), - selectedItems, - isAllSelected, - }); - } - - // TABLE_SELECTION_CHANGE 이벤트 발송 (선택 데이터 변경 시 다른 컴포넌트에 알림) - v2EventBus.emit(V2_EVENTS.TABLE_SELECTION_CHANGE, { - tableName: config.selectedTable || "", - selectedRows: selectedItems, - selectedRowIds: selectedItems.map((item: any) => item.id).filter(Boolean), - source: componentId || tableId, - }); - - console.log("[TableGroupedComponent] 선택 변경 이벤트 발송:", { - componentId: componentId || tableId, - tableName: config.selectedTable, - selectedCount: selectedItems.length, - }); - }, [selectedItems, groups, isAllSelected, onSelectionChange, componentId, tableId, config.selectedTable]); - - // 그룹 토글 핸들러 - const handleGroupToggle = useCallback( - (groupKey: string) => { - toggleGroup(groupKey); - if (onGroupToggle) { - const group = groups.find((g) => g.groupKey === groupKey); - onGroupToggle({ - groupKey, - expanded: !group?.expanded, - }); - } - }, - [toggleGroup, onGroupToggle, groups] - ); - - // 행 클릭 핸들러 - const handleRowClick = useCallback( - (row: any, groupKey: string, indexInGroup: number) => { - if (!config.rowClickable) return; - if (onRowClick) { - onRowClick({ row, groupKey, indexInGroup }); - } - }, - [config.rowClickable, onRowClick] - ); - - // refreshTable 이벤트 구독 - useEffect(() => { - const handleRefresh = () => { - refresh(); - }; - - window.addEventListener("refreshTable", handleRefresh); - return () => { - window.removeEventListener("refreshTable", handleRefresh); - }; - }, [refresh]); - - // 디자인 모드 렌더링 - if (isDesignMode) { - return ( -
-
- - 그룹화 테이블 - {config.groupConfig?.groupByColumn && ( - - (그룹: {config.groupConfig.groupByColumn}) - - )} -
-
- 테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"} -
-
- ); - } - - // 로딩 상태 - if (isLoading) { - return ( -
- - 로딩 중... -
- ); - } - - // 에러 상태 - if (error) { - return ( -
- {error} -
- ); - } - - // 데이터 없음 - if (groups.length === 0) { - return ( -
- {config.emptyMessage || "데이터가 없습니다."} -
- ); - } - - return ( -
- {/* 툴바 */} - {config.showExpandAllButton && ( -
-
- - -
-
- {groupCount}개 그룹 | 총 {totalCount}건 -
-
- )} - - {/* 테이블 */} -
- - {/* 테이블 헤더 */} - - - {/* 전체 선택 체크박스 */} - {config.showCheckbox && ( - - )} - {/* 컬럼 헤더 */} - {visibleColumns.map((col) => ( - - ))} - - - - {/* 테이블 바디 */} - - {groups.map((group) => ( - - {/* 그룹 헤더 */} - handleGroupToggle(group.groupKey)} - onSelectToggle={ - config.showCheckbox - ? () => toggleGroupSelection(group.groupKey) - : undefined - } - style={config.groupHeaderStyle} - columnCount={totalColumnCount} - /> - - {/* 그룹 아이템 (펼쳐진 경우만) */} - {group.expanded && - group.items.map((item, idx) => { - const itemId = getItemId(item); - const isSelected = group.selectedItemIds?.includes(itemId); - - return ( - handleRowClick(item, group.groupKey, idx)} - > - {/* 체크박스 */} - {config.showCheckbox && ( - - )} - - {/* 데이터 컬럼 */} - {visibleColumns.map((col) => { - const value = item[col.columnName]; - let displayValue: React.ReactNode = value; - - // 포맷 적용 - if (col.format === "number" && typeof value === "number") { - displayValue = value.toLocaleString(); - } else if (col.format === "currency" && typeof value === "number") { - displayValue = `₩${value.toLocaleString()}`; - } else if (col.format === "date" && value) { - displayValue = new Date(value).toLocaleDateString("ko-KR"); - } else if (col.format === "boolean") { - displayValue = value ? "예" : "아니오"; - } - - return ( - - ); - })} - - ); - })} - - ))} - -
- - - {col.displayName || col.columnName} -
e.stopPropagation()} - > - - toggleItemSelection(group.groupKey, itemId) - } - /> - - {displayValue ?? "-"} -
-
-
- ); -} - -export default TableGroupedComponent; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx deleted file mode 100644 index 8ee96647..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx +++ /dev/null @@ -1,718 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -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 { cn } from "@/lib/utils"; -import { tableTypeApi } from "@/lib/api/screen"; -import { TableGroupedConfig, LinkedFilterConfig } from "./types"; -import { ColumnConfig } from "../v2-table-list/types"; -import { - groupHeaderStyleOptions, - checkboxModeOptions, - sortDirectionOptions, -} from "./config"; -import { Trash2, Plus } from "lucide-react"; - -interface TableGroupedConfigPanelProps { - config: TableGroupedConfig; - onChange: (newConfig: Partial) => void; -} - -/** - * v2-table-grouped 설정 패널 - */ -// 테이블 정보 타입 -interface TableInfo { - tableName: string; - displayName: string; -} - -export function TableGroupedConfigPanel({ - config, - onChange, -}: TableGroupedConfigPanelProps) { - // 테이블 목록 (라벨명 포함) - const [tables, setTables] = useState([]); - const [tableColumns, setTableColumns] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); - const [loadingColumns, setLoadingColumns] = useState(false); - const [tableSelectOpen, setTableSelectOpen] = useState(false); - - // 테이블 목록 로드 - useEffect(() => { - const loadTables = async () => { - setLoadingTables(true); - try { - const tableList = await tableTypeApi.getTables(); - if (tableList && Array.isArray(tableList)) { - setTables( - tableList.map((t: any) => ({ - tableName: t.table_name, - displayName: t.display_name || t.table_name, - })) - ); - } - } catch (err) { - console.error("테이블 목록 로드 실패:", err); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - // 선택된 테이블의 컬럼 로드 - useEffect(() => { - const tableName = config.useCustomTable - ? config.customTableName - : config.selectedTable; - - if (!tableName) { - setTableColumns([]); - return; - } - - const loadColumns = async () => { - setLoadingColumns(true); - try { - const columns = await tableTypeApi.getColumns(tableName); - if (columns && Array.isArray(columns)) { - const cols: ColumnConfig[] = columns.map( - (col: any, idx: number) => ({ - columnName: col.column_name, - displayName: col.display_name || col.column_name, - visible: true, - sortable: true, - searchable: false, - align: "left" as const, - order: idx, - }) - ); - setTableColumns(cols); - - // 컬럼 설정이 없으면 자동 설정 - if (!config.columns || config.columns.length === 0) { - onChange({ ...config, columns: cols }); - } - } - } catch (err) { - console.error("컬럼 로드 실패:", err); - } finally { - setLoadingColumns(false); - } - }; - loadColumns(); - }, [config.selectedTable, config.customTableName, config.useCustomTable]); - - // 설정 업데이트 헬퍼 - const updateConfig = (updates: Partial) => { - onChange({ ...config, ...updates }); - }; - - // 그룹 설정 업데이트 헬퍼 - const updateGroupConfig = ( - updates: Partial - ) => { - onChange({ - ...config, - groupConfig: { ...config.groupConfig, ...updates }, - }); - }; - - // 컬럼 가시성 토글 - const toggleColumnVisibility = (columnName: string) => { - const updatedColumns = (config.columns || []).map((col) => - col.columnName === columnName ? { ...col, visible: !col.visible } : col - ); - updateConfig({ columns: updatedColumns }); - }; - - // 합계 컬럼 토글 - const toggleSumColumn = (columnName: string) => { - const currentSumCols = config.groupConfig?.summary?.sumColumns || []; - const newSumCols = currentSumCols.includes(columnName) - ? currentSumCols.filter((c) => c !== columnName) - : [...currentSumCols, columnName]; - - updateGroupConfig({ - summary: { - ...config.groupConfig?.summary, - sumColumns: newSumCols, - }, - }); - }; - - // 연결 필터 추가 - const addLinkedFilter = () => { - const newFilter: LinkedFilterConfig = { - sourceComponentId: "", - sourceField: "value", - targetColumn: "", - enabled: true, - }; - updateConfig({ - linkedFilters: [...(config.linkedFilters || []), newFilter], - }); - }; - - // 연결 필터 제거 - const removeLinkedFilter = (index: number) => { - const filters = [...(config.linkedFilters || [])]; - filters.splice(index, 1); - updateConfig({ linkedFilters: filters }); - }; - - // 연결 필터 업데이트 - const updateLinkedFilter = ( - index: number, - updates: Partial - ) => { - const filters = [...(config.linkedFilters || [])]; - filters[index] = { ...filters[index], ...updates }; - updateConfig({ linkedFilters: filters }); - }; - - return ( -
- - {/* 테이블 설정 */} - - - 테이블 설정 - - - {/* 커스텀 테이블 사용 */} -
- - - updateConfig({ useCustomTable: checked }) - } - /> -
- - {/* 테이블 선택 */} - {config.useCustomTable ? ( -
- - - updateConfig({ customTableName: e.target.value }) - } - placeholder="테이블명 입력" - className="h-8 text-xs" - /> -
- ) : ( -
- - - - - - - { - // 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0 - const lowerSearch = search.toLowerCase(); - if (value.toLowerCase().includes(lowerSearch)) { - return 1; - } - return 0; - }} - > - - - - 테이블을 찾을 수 없습니다. - - - {tables.map((table) => ( - { - updateConfig({ selectedTable: table.tableName }); - setTableSelectOpen(false); - }} - className="text-xs" - > - -
- {table.displayName} - - {table.tableName} - -
-
- ))} -
-
-
-
-
-
- )} -
-
- - {/* 그룹화 설정 */} - - - 그룹화 설정 - - - {/* 그룹화 기준 컬럼 */} -
- - -
- - {/* 그룹 라벨 형식 */} -
- - - updateGroupConfig({ groupLabelFormat: e.target.value }) - } - placeholder="{value} ({컬럼명})" - className="h-8 text-xs" - /> -

- {"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값 -

-
- - {/* 기본 펼침 상태 */} -
- - - updateGroupConfig({ defaultExpanded: checked }) - } - /> -
- - {/* 그룹 정렬 */} -
- - -
- - {/* 개수 표시 */} -
- - - updateGroupConfig({ - summary: { - ...config.groupConfig?.summary, - showCount: checked, - }, - }) - } - /> -
- - {/* 합계 컬럼 */} -
- -
- {tableColumns.map((col) => ( -
- toggleSumColumn(col.columnName)} - /> - -
- ))} -
-
-
-
- - {/* 표시 설정 */} - - - 표시 설정 - - - {/* 체크박스 표시 */} -
- - - updateConfig({ showCheckbox: checked }) - } - /> -
- - {/* 체크박스 모드 */} - {config.showCheckbox && ( -
- - -
- )} - - {/* 그룹 헤더 스타일 */} -
- - -
- - {/* 전체 펼치기/접기 버튼 */} -
- - - updateConfig({ showExpandAllButton: checked }) - } - /> -
- - {/* 행 클릭 가능 */} -
- - - updateConfig({ rowClickable: checked }) - } - /> -
- - {/* 최대 높이 */} -
- - - updateConfig({ maxHeight: parseInt(e.target.value) || 600 }) - } - className="h-8 text-xs" - /> -
- - {/* 빈 데이터 메시지 */} -
- - - updateConfig({ emptyMessage: e.target.value }) - } - placeholder="데이터가 없습니다." - className="h-8 text-xs" - /> -
-
-
- - {/* 컬럼 설정 */} - - - 컬럼 설정 - - -
- {(config.columns || tableColumns).map((col) => ( -
- - toggleColumnVisibility(col.columnName) - } - /> - -
- ))} -
-
-
- - {/* 연동 설정 */} - - - 연동 설정 - - -
-
- - -
- - {(config.linkedFilters || []).length === 0 ? ( -

- 연결된 필터가 없습니다. -

- ) : ( -
- {(config.linkedFilters || []).map((filter, idx) => ( -
-
- - 필터 #{idx + 1} - -
- - updateLinkedFilter(idx, { enabled: checked }) - } - /> - -
-
- -
- - - updateLinkedFilter(idx, { - sourceComponentId: e.target.value, - }) - } - placeholder="예: search-filter-1" - className="h-7 text-xs" - /> -
- -
- - - updateLinkedFilter(idx, { - sourceField: e.target.value, - }) - } - placeholder="value" - className="h-7 text-xs" - /> -
- -
- - -
-
- ))} -
- )} - -

- 다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다. -

-
-
-
-
-
- ); -} - -export default TableGroupedConfigPanel; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx deleted file mode 100644 index 245d8ee6..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import React from "react"; -import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; -import { V2TableGroupedDefinition } from "./index"; -import { TableGroupedComponent } from "./TableGroupedComponent"; - -/** - * TableGrouped 렌더러 - * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 - */ -export class TableGroupedRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = V2TableGroupedDefinition; - - render(): React.ReactElement { - return ( - - ); - } - - // 설정 변경 핸들러 - protected handleConfigChange = (config: any) => { - console.log("📥 TableGroupedRenderer에서 설정 변경 받음:", config); - - // 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림) - if (this.props.onConfigChange) { - this.props.onConfigChange(config); - } - - this.updateComponent({ config }); - }; - - // 값 변경 처리 - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; -} - -// 자동 등록 실행 -TableGroupedRenderer.registerSelf(); - -// 강제 등록 (디버깅용) -if (typeof window !== "undefined") { - setTimeout(() => { - try { - TableGroupedRenderer.registerSelf(); - } catch (error) { - console.error("❌ TableGrouped 강제 등록 실패:", error); - } - }, 1000); -} diff --git a/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx b/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx deleted file mode 100644 index f7119f4e..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import React from "react"; -import { ChevronDown, ChevronRight, Minus } from "lucide-react"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { GroupState, TableGroupedConfig } from "../types"; - -interface GroupHeaderProps { - /** 그룹 상태 */ - group: GroupState; - /** 설정 */ - config: TableGroupedConfig; - /** 그룹 토글 핸들러 */ - onToggle: () => void; - /** 그룹 선택 토글 핸들러 */ - onSelectToggle?: () => void; - /** 그룹 헤더 스타일 */ - style?: "default" | "compact" | "card"; - /** 컬럼 개수 (colspan용) */ - columnCount?: number; -} - -/** - * 그룹 헤더 컴포넌트 - * 그룹 펼치기/접기, 체크박스, 요약 정보 표시 - */ -export function GroupHeader({ - group, - config, - onToggle, - onSelectToggle, - style = "default", - columnCount = 1, -}: GroupHeaderProps) { - const { showCheckbox } = config; - const { summary } = group; - - // 일부 선택 여부 - const isIndeterminate = - group.selectedItemIds && - group.selectedItemIds.length > 0 && - group.selectedItemIds.length < group.items.length; - - // 요약 텍스트 생성 - const summaryText = React.useMemo(() => { - const parts: string[] = []; - - // 개수 - if (config.groupConfig?.summary?.showCount !== false) { - parts.push(`${summary.count}건`); - } - - // 합계 - if (summary.sum) { - for (const [col, value] of Object.entries(summary.sum)) { - const displayName = - config.columns?.find((c) => c.columnName === col)?.displayName || col; - parts.push(`${displayName}: ${value.toLocaleString()}`); - } - } - - return parts.join(" | "); - }, [summary, config]); - - // 스타일별 클래스 - const headerClasses = cn( - "flex items-center gap-2 cursor-pointer select-none transition-colors", - { - // default 스타일 - "px-3 py-2 bg-muted/50 hover:bg-muted border-b": style === "default", - // compact 스타일 - "px-2 py-1 bg-muted/30 hover:bg-muted/50 border-b text-sm": - style === "compact", - // card 스타일 - "px-4 py-3 bg-card border rounded-t-lg shadow-sm hover:shadow": - style === "card", - } - ); - - return ( - - { - // 체크박스 클릭 시 토글 방지 - if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) { - return; - } - onToggle(); - }} - > -
- {/* 펼치기/접기 아이콘 */} - - {group.expanded ? ( - - ) : ( - - )} - - - {/* 체크박스 */} - {showCheckbox && onSelectToggle && ( - { - e.stopPropagation(); - onSelectToggle(); - }} - > - - {isIndeterminate && ( - - )} - - )} - - {/* 그룹 라벨 */} - {group.groupLabel} - - {/* 요약 정보 */} - {summaryText && ( - - {summaryText} - - )} -
- - - ); -} - -export default GroupHeader; diff --git a/frontend/lib/registry/components/v2-table-grouped/config.ts b/frontend/lib/registry/components/v2-table-grouped/config.ts deleted file mode 100644 index fb38744c..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { TableGroupedConfig } from "./types"; - -/** - * v2-table-grouped 기본 설정값 - */ -export const defaultTableGroupedConfig: Partial = { - // 그룹화 기본 설정 - groupConfig: { - groupByColumn: "", - groupLabelFormat: "{value}", - defaultExpanded: true, - sortDirection: "asc", - summary: { - showCount: true, - sumColumns: [], - }, - }, - - // 체크박스 기본 설정 - showCheckbox: false, - checkboxMode: "multi", - - // 페이지네이션 기본 설정 - pagination: { - enabled: false, - pageSize: 10, - }, - - // UI 기본 설정 - isReadOnly: false, - rowClickable: true, - showExpandAllButton: true, - groupHeaderStyle: "default", - emptyMessage: "데이터가 없습니다.", - - // 높이 기본 설정 - height: "auto", - maxHeight: 600, -}; - -/** - * 그룹 헤더 스타일 옵션 - */ -export const groupHeaderStyleOptions = [ - { value: "default", label: "기본" }, - { value: "compact", label: "컴팩트" }, - { value: "card", label: "카드" }, -]; - -/** - * 체크박스 모드 옵션 - */ -export const checkboxModeOptions = [ - { value: "single", label: "단일 선택" }, - { value: "multi", label: "다중 선택" }, -]; - -/** - * 정렬 방향 옵션 - */ -export const sortDirectionOptions = [ - { value: "asc", label: "오름차순" }, - { value: "desc", label: "내림차순" }, -]; diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts deleted file mode 100644 index e2f415e7..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts +++ /dev/null @@ -1,411 +0,0 @@ -"use client"; - -import { useState, useCallback, useMemo, useEffect } from "react"; -import { - TableGroupedConfig, - GroupState, - GroupSummary, - UseGroupedDataResult, -} from "../types"; -import { apiClient } from "@/lib/api/client"; - -/** - * 그룹 요약 데이터 계산 - */ -function calculateSummary( - items: any[], - config: TableGroupedConfig -): GroupSummary { - const summary: GroupSummary = { - count: items.length, - }; - - const summaryConfig = config.groupConfig?.summary; - if (!summaryConfig) return summary; - - // 합계 계산 - if (summaryConfig.sumColumns && summaryConfig.sumColumns.length > 0) { - summary.sum = {}; - for (const col of summaryConfig.sumColumns) { - summary.sum[col] = items.reduce((acc, item) => { - const val = parseFloat(item[col]); - return acc + (isNaN(val) ? 0 : val); - }, 0); - } - } - - // 평균 계산 - if (summaryConfig.avgColumns && summaryConfig.avgColumns.length > 0) { - summary.avg = {}; - for (const col of summaryConfig.avgColumns) { - const validItems = items.filter( - (item) => item[col] !== null && item[col] !== undefined - ); - const sum = validItems.reduce((acc, item) => { - const val = parseFloat(item[col]); - return acc + (isNaN(val) ? 0 : val); - }, 0); - summary.avg[col] = validItems.length > 0 ? sum / validItems.length : 0; - } - } - - // 최대값 계산 - if (summaryConfig.maxColumns && summaryConfig.maxColumns.length > 0) { - summary.max = {}; - for (const col of summaryConfig.maxColumns) { - const values = items - .map((item) => parseFloat(item[col])) - .filter((v) => !isNaN(v)); - summary.max[col] = values.length > 0 ? Math.max(...values) : 0; - } - } - - // 최소값 계산 - if (summaryConfig.minColumns && summaryConfig.minColumns.length > 0) { - summary.min = {}; - for (const col of summaryConfig.minColumns) { - const values = items - .map((item) => parseFloat(item[col])) - .filter((v) => !isNaN(v)); - summary.min[col] = values.length > 0 ? Math.min(...values) : 0; - } - } - - return summary; -} - -/** - * 그룹 라벨 포맷팅 - */ -function formatGroupLabel( - groupValue: any, - item: any, - format?: string -): string { - if (!format) { - return String(groupValue ?? "(빈 값)"); - } - - // {value}를 그룹 값으로 치환 - let label = format.replace("{value}", String(groupValue ?? "(빈 값)")); - - // {컬럼명} 패턴을 해당 컬럼 값으로 치환 - const columnPattern = /\{([^}]+)\}/g; - label = label.replace(columnPattern, (match, columnName) => { - if (columnName === "value") return String(groupValue ?? ""); - return String(item?.[columnName] ?? ""); - }); - - return label; -} - -/** - * 데이터를 그룹화하는 훅 - */ -export function useGroupedData( - config: TableGroupedConfig, - externalData?: any[], - searchFilters?: Record -): UseGroupedDataResult { - // 원본 데이터 - const [rawData, setRawData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // 그룹 펼침 상태 관리 - const [expandedGroups, setExpandedGroups] = useState>(new Set()); - // 사용자가 수동으로 펼침/접기를 조작했는지 여부 - const [isManuallyControlled, setIsManuallyControlled] = useState(false); - - // 선택 상태 관리 - const [selectedItemIds, setSelectedItemIds] = useState>( - new Set() - ); - - // 테이블명 결정 - const tableName = config.useCustomTable - ? config.customTableName - : config.selectedTable; - - // 데이터 로드 - const fetchData = useCallback(async () => { - if (externalData) { - setRawData(externalData); - return; - } - - if (!tableName) { - setRawData([]); - return; - } - - setIsLoading(true); - setError(null); - - try { - const response = await apiClient.post( - `/table-management/tables/${tableName}/data`, - { - page: 1, - size: 10000, // 그룹화를 위해 전체 데이터 로드 - autoFilter: true, - search: searchFilters || {}, - } - ); - - let responseData = response.data?.data?.data || response.data?.data || []; - responseData = Array.isArray(responseData) ? responseData : []; - - // dataFilter 적용 (클라이언트 사이드 필터링) - if (config.dataFilter && config.dataFilter.length > 0) { - responseData = responseData.filter((item: any) => { - return config.dataFilter!.every((f) => { - const val = item[f.column]; - switch (f.operator) { - case "eq": return val === f.value; - case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value; - case "gt": return Number(val) > Number(f.value); - case "lt": return Number(val) < Number(f.value); - case "gte": return Number(val) >= Number(f.value); - case "lte": return Number(val) <= Number(f.value); - case "like": return String(val ?? "").includes(String(f.value)); - case "in": return Array.isArray(f.value) ? f.value.includes(val) : false; - default: return true; - } - }); - }); - } - - setRawData(responseData); - } catch (err: any) { - setError(err.message || "데이터 로드 중 오류 발생"); - setRawData([]); - } finally { - setIsLoading(false); - } - }, [tableName, externalData, searchFilters, config.dataFilter]); - - // 초기 데이터 로드 - useEffect(() => { - fetchData(); - }, [fetchData]); - - // 외부 데이터 변경 시 동기화 - useEffect(() => { - if (externalData) { - setRawData(externalData); - } - }, [externalData]); - - // 그룹화된 데이터 계산 - const groups = useMemo((): GroupState[] => { - const groupByColumn = config.groupConfig?.groupByColumn; - if (!groupByColumn || rawData.length === 0) { - return []; - } - - // 데이터를 그룹별로 분류 - const groupMap = new Map(); - - for (const item of rawData) { - const groupValue = item[groupByColumn]; - const groupKey = String(groupValue ?? "__null__"); - - if (!groupMap.has(groupKey)) { - groupMap.set(groupKey, []); - } - groupMap.get(groupKey)!.push(item); - } - - // 그룹 배열 생성 - const groupArray: GroupState[] = []; - const defaultExpanded = config.groupConfig?.defaultExpanded ?? true; - - for (const [groupKey, items] of groupMap.entries()) { - const firstItem = items[0]; - const groupValue = - groupKey === "__null__" ? null : firstItem[groupByColumn]; - - // 펼침 상태 결정: 수동 조작 전에는 defaultExpanded, 수동 조작 후에는 expandedGroups 참조 - const isExpanded = isManuallyControlled - ? expandedGroups.has(groupKey) - : defaultExpanded; - - groupArray.push({ - groupKey, - groupLabel: formatGroupLabel( - groupValue, - firstItem, - config.groupConfig?.groupLabelFormat - ), - expanded: isExpanded, - items, - summary: calculateSummary(items, config), - selected: items.every((item) => - selectedItemIds.has(getItemId(item, config)) - ), - selectedItemIds: items - .filter((item) => selectedItemIds.has(getItemId(item, config))) - .map((item) => getItemId(item, config)), - }); - } - - // 정렬 - const sortDirection = config.groupConfig?.sortDirection ?? "asc"; - groupArray.sort((a, b) => { - const comparison = a.groupLabel.localeCompare(b.groupLabel, "ko"); - return sortDirection === "asc" ? comparison : -comparison; - }); - - return groupArray; - }, [rawData, config, expandedGroups, selectedItemIds, isManuallyControlled]); - - // 아이템 ID 추출 - function getItemId(item: any, cfg: TableGroupedConfig): string { - // id 또는 첫 번째 컬럼을 ID로 사용 - if (item.id !== undefined) return String(item.id); - const firstCol = cfg.columns?.[0]?.columnName; - if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]); - return JSON.stringify(item); - } - - // 그룹 토글 - const toggleGroup = useCallback((groupKey: string) => { - setIsManuallyControlled(true); - setExpandedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); - }, []); - - // 전체 펼치기 - const expandAll = useCallback(() => { - setIsManuallyControlled(true); - setExpandedGroups(new Set(groups.map((g) => g.groupKey))); - }, [groups]); - - // 전체 접기 - const collapseAll = useCallback(() => { - setIsManuallyControlled(true); - setExpandedGroups(new Set()); - }, []); - - // 아이템 선택 토글 - const toggleItemSelection = useCallback( - (groupKey: string, itemId: string) => { - setSelectedItemIds((prev) => { - const next = new Set(prev); - if (next.has(itemId)) { - next.delete(itemId); - } else { - // 단일 선택 모드 - if (config.checkboxMode === "single") { - next.clear(); - } - next.add(itemId); - } - return next; - }); - }, - [config.checkboxMode] - ); - - // 그룹 전체 선택 토글 - const toggleGroupSelection = useCallback( - (groupKey: string) => { - const group = groups.find((g) => g.groupKey === groupKey); - if (!group) return; - - setSelectedItemIds((prev) => { - const next = new Set(prev); - const groupItemIds = group.items.map((item) => getItemId(item, config)); - const allSelected = groupItemIds.every((id) => next.has(id)); - - if (allSelected) { - // 전체 해제 - for (const id of groupItemIds) { - next.delete(id); - } - } else { - // 전체 선택 - if (config.checkboxMode === "single") { - next.clear(); - next.add(groupItemIds[0]); - } else { - for (const id of groupItemIds) { - next.add(id); - } - } - } - return next; - }); - }, - [groups, config] - ); - - // 전체 선택 토글 - const toggleAllSelection = useCallback(() => { - const allItemIds = rawData.map((item) => getItemId(item, config)); - const allSelected = allItemIds.every((id) => selectedItemIds.has(id)); - - if (allSelected) { - setSelectedItemIds(new Set()); - } else { - if (config.checkboxMode === "single" && allItemIds.length > 0) { - setSelectedItemIds(new Set([allItemIds[0]])); - } else { - setSelectedItemIds(new Set(allItemIds)); - } - } - }, [rawData, config, selectedItemIds]); - - // 선택된 아이템 목록 - const selectedItems = useMemo(() => { - return rawData.filter((item) => - selectedItemIds.has(getItemId(item, config)) - ); - }, [rawData, selectedItemIds, config]); - - // 모두 선택 여부 - const isAllSelected = useMemo(() => { - if (rawData.length === 0) return false; - return rawData.every((item) => - selectedItemIds.has(getItemId(item, config)) - ); - }, [rawData, selectedItemIds, config]); - - // 일부 선택 여부 - const isIndeterminate = useMemo(() => { - if (rawData.length === 0) return false; - const selectedCount = rawData.filter((item) => - selectedItemIds.has(getItemId(item, config)) - ).length; - return selectedCount > 0 && selectedCount < rawData.length; - }, [rawData, selectedItemIds, config]); - - return { - groups, - isLoading, - error, - toggleGroup, - expandAll, - collapseAll, - toggleItemSelection, - toggleGroupSelection, - toggleAllSelection, - selectedItems, - isAllSelected, - isIndeterminate, - refresh: fetchData, - rawData, - totalCount: rawData.length, - groupCount: groups.length, - }; -} - -export default useGroupedData; diff --git a/frontend/lib/registry/components/v2-table-grouped/index.ts b/frontend/lib/registry/components/v2-table-grouped/index.ts deleted file mode 100644 index c7dbd762..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import React from "react"; -import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { ComponentCategory } from "@/types/component"; -import type { WebType } from "@/types/screen"; -import { TableGroupedComponent } from "./TableGroupedComponent"; -import { V2TableGroupedConfigPanel } from "@/components/v2/config-panels/V2TableGroupedConfigPanel"; -import { TableGroupedConfig } from "./types"; - -/** - * V2 그룹화 테이블 컴포넌트 Definition - * - * 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다. - * v2-table-list를 기반으로 확장된 컴포넌트입니다. - */ -export const V2TableGroupedDefinition = createComponentDefinition({ - id: "v2-table-grouped", - hidden: true, // Phase E: 통합 컴포넌트로 대체됨 - name: "그룹화 테이블", - name_eng: "Grouped Table Component", - description: "데이터를 그룹화하여 접기/펼치기 기능을 제공하는 테이블", - category: ComponentCategory.DISPLAY, - web_type: "text", - component: TableGroupedComponent, - default_config: { - // 테이블 설정 - selectedTable: "", - useCustomTable: false, - customTableName: "", - - // 그룹화 설정 - groupConfig: { - groupByColumn: "", - groupLabelFormat: "{value}", - defaultExpanded: true, - sortDirection: "asc", - summary: { - showCount: true, - sumColumns: [], - }, - }, - - // 컬럼 설정 - columns: [], - - // 체크박스 설정 - showCheckbox: false, - checkboxMode: "multi", - - // 페이지네이션 설정 - pagination: { - enabled: false, - pageSize: 10, - }, - - // UI 설정 - isReadOnly: false, - rowClickable: true, - showExpandAllButton: true, - groupHeaderStyle: "default", - emptyMessage: "데이터가 없습니다.", - height: "auto", - maxHeight: 600, - }, - default_size: { width: 800, height: 500 }, - config_panel: V2TableGroupedConfigPanel, - icon: "Layers", - tags: ["테이블", "그룹화", "접기", "펼치기", "목록"], - version: "1.0.0", - author: "개발팀", - documentation: "", -}); - -// 타입 내보내기 -export type { TableGroupedConfig } from "./types"; diff --git a/frontend/lib/registry/components/v2-table-grouped/types.ts b/frontend/lib/registry/components/v2-table-grouped/types.ts deleted file mode 100644 index 20bfc77b..00000000 --- a/frontend/lib/registry/components/v2-table-grouped/types.ts +++ /dev/null @@ -1,299 +0,0 @@ -"use client"; - -import { ComponentConfig } from "@/types/component"; -import { ColumnConfig, EntityJoinInfo } from "../v2-table-list/types"; - -/** - * 그룹 요약 설정 - */ -export interface GroupSummaryConfig { - /** 합계를 계산할 컬럼 목록 */ - sumColumns?: string[]; - /** 개수 표시 여부 */ - showCount?: boolean; - /** 평균 컬럼 목록 */ - avgColumns?: string[]; - /** 최대값 컬럼 목록 */ - maxColumns?: string[]; - /** 최소값 컬럼 목록 */ - minColumns?: string[]; -} - -/** - * 그룹화 설정 - */ -export interface GroupConfig { - /** 그룹화 기준 컬럼 */ - groupByColumn: string; - - /** 그룹 표시 형식 (예: "{item_name} ({item_code})") */ - groupLabelFormat?: string; - - /** 그룹 요약 설정 */ - summary?: GroupSummaryConfig; - - /** 초기 펼침 상태 (기본값: true) */ - defaultExpanded?: boolean; - - /** 중첩 그룹 (다중 그룹화) - 향후 확장 */ - nestedGroup?: GroupConfig; - - /** 그룹 정렬 방식 */ - sortDirection?: "asc" | "desc"; - - /** 그룹 정렬 컬럼 (기본: groupByColumn) */ - sortColumn?: string; -} - -/** - * 그룹화 테이블 설정 (ComponentConfig 기반) - */ -export interface TableGroupedConfig extends ComponentConfig { - /** 테이블명 */ - selectedTable?: string; - - /** 커스텀 테이블 사용 여부 */ - useCustomTable?: boolean; - - /** 커스텀 테이블명 */ - customTableName?: string; - - /** 그룹화 설정 */ - groupConfig: GroupConfig; - - /** 컬럼 설정 */ - columns?: ColumnConfig[]; - - /** 체크박스 표시 여부 */ - showCheckbox?: boolean; - - /** 체크박스 모드 */ - checkboxMode?: "single" | "multi"; - - /** 페이지네이션 (그룹 단위) */ - pagination?: { - enabled: boolean; - pageSize: number; - }; - - /** 기본 정렬 설정 */ - defaultSort?: { - column: string; - direction: "asc" | "desc"; - }; - - /** 읽기 전용 */ - isReadOnly?: boolean; - - /** 행 클릭 가능 여부 */ - rowClickable?: boolean; - - /** 높이 설정 */ - height?: number | string; - - /** 최대 높이 */ - maxHeight?: number | string; - - /** 전체 펼치기/접기 버튼 표시 */ - showExpandAllButton?: boolean; - - /** 그룹 헤더 스타일 */ - groupHeaderStyle?: "default" | "compact" | "card"; - - /** 빈 데이터 메시지 */ - emptyMessage?: string; - - /** Entity 조인 컬럼 정보 */ - entityJoinColumns?: Array<{ - columnName: string; - entityJoinInfo: EntityJoinInfo; - }>; - - /** 데이터 필터 */ - dataFilter?: { - column: string; - operator: "eq" | "ne" | "gt" | "lt" | "gte" | "lte" | "like" | "in"; - value: any; - }[]; - - /** 연결된 필터 설정 (다른 컴포넌트와 연동) */ - linkedFilters?: LinkedFilterConfig[]; -} - -/** - * 연결된 필터 설정 - */ -export interface LinkedFilterConfig { - /** 소스 컴포넌트 ID */ - sourceComponentId: string; - /** 소스 필드 */ - sourceField?: string; - /** 대상 컬럼 */ - targetColumn: string; - /** 활성화 여부 */ - enabled?: boolean; -} - -/** - * 그룹 요약 데이터 - */ -export interface GroupSummary { - /** 개수 */ - count: number; - /** 합계 (컬럼별) */ - sum?: Record; - /** 평균 (컬럼별) */ - avg?: Record; - /** 최대값 (컬럼별) */ - max?: Record; - /** 최소값 (컬럼별) */ - min?: Record; -} - -/** - * 그룹 상태 - */ -export interface GroupState { - /** 그룹 키 (groupByColumn 값) */ - groupKey: string; - - /** 그룹 표시 라벨 */ - groupLabel: string; - - /** 펼침 여부 */ - expanded: boolean; - - /** 그룹 내 데이터 */ - items: any[]; - - /** 그룹 요약 데이터 */ - summary: GroupSummary; - - /** 그룹 선택 여부 */ - selected?: boolean; - - /** 그룹 내 선택된 아이템 ID 목록 */ - selectedItemIds?: string[]; -} - -/** - * 선택 이벤트 데이터 - */ -export interface SelectionChangeEvent { - /** 선택된 그룹 키 목록 */ - selectedGroups: string[]; - /** 선택된 아이템 (전체) */ - selectedItems: any[]; - /** 모두 선택 여부 */ - isAllSelected: boolean; -} - -/** - * 그룹 토글 이벤트 - */ -export interface GroupToggleEvent { - /** 그룹 키 */ - groupKey: string; - /** 펼침 상태 */ - expanded: boolean; -} - -/** - * 행 클릭 이벤트 - */ -export interface RowClickEvent { - /** 클릭된 행 데이터 */ - row: any; - /** 그룹 키 */ - groupKey: string; - /** 그룹 내 인덱스 */ - indexInGroup: number; -} - -/** - * TableGroupedComponent Props - */ -export interface TableGroupedComponentProps { - /** 컴포넌트 설정 */ - config: TableGroupedConfig; - - /** 디자인 모드 여부 */ - isDesignMode?: boolean; - - /** 폼 데이터 (formData 전달용) */ - formData?: Record; - - /** 선택 변경 이벤트 */ - onSelectionChange?: (event: SelectionChangeEvent) => void; - - /** 그룹 토글 이벤트 */ - onGroupToggle?: (event: GroupToggleEvent) => void; - - /** 행 클릭 이벤트 */ - onRowClick?: (event: RowClickEvent) => void; - - /** 외부에서 주입된 데이터 (선택) */ - externalData?: any[]; - - /** 로딩 상태 (외부 제어) */ - isLoading?: boolean; - - /** 에러 상태 (외부 제어) */ - error?: string; - - /** 컴포넌트 ID */ - componentId?: string; -} - -/** - * useGroupedData 훅 반환 타입 - */ -export interface UseGroupedDataResult { - /** 그룹화된 데이터 */ - groups: GroupState[]; - - /** 로딩 상태 */ - isLoading: boolean; - - /** 에러 */ - error: string | null; - - /** 그룹 펼치기/접기 토글 */ - toggleGroup: (groupKey: string) => void; - - /** 전체 펼치기 */ - expandAll: () => void; - - /** 전체 접기 */ - collapseAll: () => void; - - /** 아이템 선택 토글 */ - toggleItemSelection: (groupKey: string, itemId: string) => void; - - /** 그룹 전체 선택 토글 */ - toggleGroupSelection: (groupKey: string) => void; - - /** 전체 선택 토글 */ - toggleAllSelection: () => void; - - /** 선택된 아이템 목록 */ - selectedItems: any[]; - - /** 모두 선택 여부 */ - isAllSelected: boolean; - - /** 일부 선택 여부 */ - isIndeterminate: boolean; - - /** 데이터 새로고침 */ - refresh: () => void; - - /** 원본 데이터 */ - rawData: any[]; - - /** 전체 데이터 개수 */ - totalCount: number; - - /** 그룹 개수 */ - groupCount: number; -} diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 404114b8..487ca9cb 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -378,14 +378,6 @@ const v2CategoryManagerOverridesSchema = z }) .passthrough(); -// v2-pivot-grid -const v2PivotGridOverridesSchema = z - .object({ - fields: z.array(z.any()).default([]), - dataSource: z.any().optional(), - }) - .passthrough(); - // v2-location-swap-selector const v2LocationSwapSelectorOverridesSchema = z .object({ @@ -422,26 +414,6 @@ const v2AggregationWidgetOverridesSchema = z }) .passthrough(); -// v2-card-display -const v2CardDisplayOverridesSchema = z - .object({ - cardsPerRow: z.number().default(3), - cardSpacing: z.number().default(16), - cardStyle: z - .object({ - showTitle: z.boolean().default(true), - showSubtitle: z.boolean().default(true), - showDescription: z.boolean().default(true), - showImage: z.boolean().default(false), - showActions: z.boolean().default(true), - }) - .default({ showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }), - columnMapping: z.record(z.string(), z.any()).default({}), - dataSource: z.string().default("table"), - staticData: z.array(z.any()).default([]), - }) - .passthrough(); - // v2-table-search-widget const v2TableSearchWidgetOverridesSchema = z .object({ @@ -672,10 +644,8 @@ const componentOverridesSchemaRegistry: Record> = { maxDepth: 3, showActions: true, }, - "v2-pivot-grid": { - fields: [], - }, "v2-location-swap-selector": { dataSource: { type: "static", tableName: "", valueField: "location_code", labelField: "location_name" }, departureField: "departure", @@ -810,14 +777,6 @@ const componentDefaultsRegistry: Record> = { autoRefresh: false, refreshOnFormChange: true, }, - "v2-card-display": { - cardsPerRow: 3, - cardSpacing: 16, - cardStyle: { showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }, - columnMapping: {}, - dataSource: "table", - staticData: [], - }, "v2-table-search-widget": { title: "테이블 검색", autoSelectFirstTable: true, diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 59f812d0..3a54e7d0 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -63,8 +63,6 @@ const CONFIG_PANEL_MAP: Record Promise> = { // ========== 레이아웃/컨테이너 ========== "accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"), - "card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"), - "v2-card-display": () => import("@/lib/registry/components/v2-card-display/CardDisplayConfigPanel"), "section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"), "v2-section-card": () => import("@/lib/registry/components/v2-section-card/SectionCardConfigPanel"), "section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"), @@ -79,8 +77,6 @@ const CONFIG_PANEL_MAP: Record Promise> = { // ========== 테이블/리스트 ========== "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), "v2-table-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"), - "pivot-grid": () => import("@/lib/registry/components/pivot-grid/PivotGridConfigPanel"), - "v2-pivot-grid": () => import("@/lib/registry/components/v2-pivot-grid/PivotGridConfigPanel"), "table-search-widget": () => import("@/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel"), "v2-table-search-widget": () => import("@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel"), "tax-invoice-list": () => import("@/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel"), @@ -157,9 +153,8 @@ const CONFIG_PANEL_ALIAS: Record = { "text-input": "input", "number-input": "input", "date-input": "input", "select-basic": "input", "checkbox-basic": "input", "textarea-basic": "input", "v2-aggregation-widget": "stats", "aggregation-widget": "stats", - "v2-status-count": "stats", "v2-card-display": "stats", + "v2-status-count": "stats", "v2-table-list": "table", "table-list": "table", - "v2-table-grouped": "table", "v2-pivot-grid": "table", "v2-tabs-widget": "container", "v2-section-card": "container", "v2-section-paper": "container", "v2-repeat-container": "container", "section-card": "container", "section-paper": "container", diff --git a/frontend/lib/utils/templateMigrate.ts b/frontend/lib/utils/templateMigrate.ts index 17ccd068..54dc20a9 100644 --- a/frontend/lib/utils/templateMigrate.ts +++ b/frontend/lib/utils/templateMigrate.ts @@ -50,12 +50,9 @@ const LEGACY_TO_UNIFIED: Record = { 'v2-aggregation-widget': 'stats', 'aggregation-widget': 'stats', 'v2-status-count': 'stats', - 'v2-card-display': 'stats', 'card-display': 'stats', 'v2-table-list': 'table', 'table-list': 'table', - 'v2-table-grouped': 'table', - 'v2-pivot-grid': 'table', 'v2-tabs-widget': 'container', 'v2-section-card': 'container', 'v2-section-paper': 'container', diff --git a/frontend/types/component-events.ts b/frontend/types/component-events.ts index 395144e6..d1f2ac91 100644 --- a/frontend/types/component-events.ts +++ b/frontend/types/component-events.ts @@ -76,15 +76,6 @@ export interface RefreshTableDetail { component_id?: string; } -/** - * 카드 디스플레이 새로고침 이벤트 - * 발행: buttonActions, InteractiveScreenViewerDynamic - * 구독: v2-card-display - */ -export interface RefreshCardDisplayDetail { - component_id?: string; -} - /** * 컴포넌트 간 데이터 전달 이벤트 * 발행: buttonActions @@ -156,7 +147,6 @@ export const V2_EVENTS = { // UI 갱신 이벤트 REFRESH_TABLE: "refreshTable", - REFRESH_CARD_DISPLAY: "refreshCardDisplay", // 데이터 전달 이벤트 COMPONENT_DATA_TRANSFER: "componentDataTransfer", @@ -190,7 +180,6 @@ declare global { // UI 갱신 이벤트 [V2_EVENTS.REFRESH_TABLE]: CustomEvent; - [V2_EVENTS.REFRESH_CARD_DISPLAY]: CustomEvent; // 데이터 전달 이벤트 [V2_EVENTS.COMPONENT_DATA_TRANSFER]: CustomEvent;