From 7d204bfffd90e5b2cb5d50cb0cf5d13207d15689 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 21 May 2026 11:55:08 +0900 Subject: [PATCH] refactor: complete canonical table cleanup --- .../app/(main)/screens/[screenId]/page.tsx | 2 +- .../screen-embedding/ScreenSplitPanel.tsx | 2 +- frontend/components/screen/InvyoneStudio.tsx | 4 +- .../screen/RealtimePreviewDynamic.tsx | 8 +- frontend/components/screen/ScreenNode.tsx | 5 +- .../config-panels/button-config/ActionTab.tsx | 9 +- .../screen/config-panels/button/DataTab.tsx | 2 +- .../screen/modals/MultilangSettingsModal.tsx | 26 +- .../screen/panels/ComponentsPanel.tsx | 23 +- .../screen/panels/V2PropertiesPanel.tsx | 7 +- .../table-options/TableSettingsModal.tsx | 16 +- .../components/screen/widgets/TabsWidget.tsx | 2 +- .../components/v2/V2ComponentRenderer.tsx | 8 +- frontend/components/v2/V2ComponentsDemo.tsx | 51 +- frontend/components/v2/V2List.tsx | 176 - .../v2/config-panels/InvDataConfigPanel.tsx | 83 +- .../InvLegacyButtonConfigPanel.tsx | 2 +- .../v2/config-panels/V2ListConfigPanel.tsx | 333 - .../config-panels/V2TableListConfigPanel.tsx | 1518 ---- frontend/components/v2/config-panels/index.ts | 2 +- frontend/components/v2/index.ts | 9 +- .../components/v2/registerV2Components.ts | 23 +- frontend/contexts/TableOptionsContext.tsx | 27 +- frontend/lib/api/screenGroup.ts | 2 +- frontend/lib/fieldConfig/adapters.ts | 103 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- .../button-primary/ButtonPrimaryComponent.tsx | 2 +- frontend/lib/registry/components/index.ts | 8 +- .../table-search-widget/TableSearchWidget.tsx | 2 +- .../components/table/InvTableConfigPanel.tsx | 1526 +++- .../components/table/TableComponent.tsx | 2604 +++++- .../table/_shared/CardModeRenderer.tsx | 259 - .../table/_shared/SingleTableWithSticky.tsx | 328 +- .../table/_shared/TableListComponent.tsx | 6838 ---------------- .../table/_shared/TableListConfigPanel.tsx | 1361 ---- .../table/_shared/V2TableListComponent.tsx | 7222 ----------------- .../_shared/V2TableListContainerWrapper.tsx | 92 - .../table/_shared/tableListConfigTypes.ts | 357 - .../components/table/cell-renderers.tsx | 530 ++ .../lib/registry/components/table/index.ts | 17 +- .../lib/registry/components/table/types.ts | 402 +- .../registry/components/table/useTableData.ts | 141 +- .../components/table/views/CardView.tsx | 320 +- .../components/table/views/GroupedView.tsx | 235 +- .../UniversalFormModalConfigPanel.tsx | 2 +- .../ButtonPrimaryComponent.tsx | 2 +- .../TableSearchWidget.tsx | 2 +- frontend/lib/schemas/componentConfig.ts | 69 +- frontend/lib/utils/buttonActions.ts | 26 +- frontend/lib/utils/componentTypeUtils.ts | 16 +- .../lib/utils/getComponentConfigPanel.tsx | 7 +- frontend/lib/utils/responsiveDefaults.ts | 12 +- frontend/lib/utils/templateMigrate.ts | 2 - frontend/stores/tableDisplayStore.ts | 72 + frontend/types/component-events.ts | 8 +- frontend/types/invyone-component.ts | 2 +- frontend/types/screen-management.ts | 2 +- frontend/types/table-options.ts | 2 +- frontend/types/v2-components.ts | 75 +- 59 files changed, 5897 insertions(+), 19093 deletions(-) delete mode 100644 frontend/components/v2/V2List.tsx delete mode 100644 frontend/components/v2/config-panels/V2ListConfigPanel.tsx delete mode 100644 frontend/components/v2/config-panels/V2TableListConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/CardModeRenderer.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/TableListComponent.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx delete mode 100644 frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts create mode 100644 frontend/lib/registry/components/table/cell-renderers.tsx diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index e07d85f7..fc4b2a5a 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -429,7 +429,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { } // 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드) - // canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip + // canonical table / widgetType=table 등 table-like 컴포넌트 모두 동일하게 skip const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp)); if (hasTableWidget) { diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index 6518dc4d..58fc0be5 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -3,7 +3,7 @@ * 좌측과 우측에 화면을 임베드합니다. * * 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다. - * 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치 + * 예: 좌측 화면에 canonical TableComponent + Button(transferData 액션) 배치 */ "use client"; diff --git a/frontend/components/screen/InvyoneStudio.tsx b/frontend/components/screen/InvyoneStudio.tsx index b0117789..ec658100 100644 --- a/frontend/components/screen/InvyoneStudio.tsx +++ b/frontend/components/screen/InvyoneStudio.tsx @@ -4102,9 +4102,7 @@ export default function InvyoneStudio({ "divider-basic": 1, // 구분선 (100%) "divider-line": 1, // 구분선 (100%) "accordion-basic": 1, // 아코디언 (100%) - "table": 1, // canonical 테이블 (100%) - "table-list": 1, // legacy 테이블 리스트 (100%) - "v2-table-list": 1, // hidden legacy 테이블 리스트 (100%) + "table": 1, // canonical 테이블 (100%) — 옛 ID 들은 isTableLikeComponentType 헬퍼가 흡수 "data-table": 1, // 데이터 테이블 (100%) "datatable": 1, // 데이터 테이블 (100%) "image-display": 4 / 12, // 이미지 표시 (33%) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 70c50131..421d8629 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -343,7 +343,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 // 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정) const getWidth = () => { - // 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함) + // 모든 컴포넌트는 size.width 픽셀 사용 (canonical table 포함) const width = `${size?.width || 100}px`; return width; }; @@ -373,8 +373,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ // 런타임 모드에서 컴포넌트 타입별 높이 처리 if (!isDesignMode) { // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) - // ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' / - // 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록. + // ★ table 계열 (canonical 'table' / 'data-table' / 'datatable') 은 helper 로 통일. + // 그 외 layout/split/tabs 는 명시 목록. const fillParentExtraTypes = [ "container", "grouped-table", "card-list", @@ -396,7 +396,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ } // 1순위: size.height가 있으면 우선 사용 - // (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장) + // (canonical 'table' 등 table-like 컴포넌트 모두 최소 200px 보장) if (size?.height && size.height > 0) { if (isTableLikeComponentType(sizingType)) { return `${Math.max(size.height, 200)}px`; diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 4ae88f85..3494ab8a 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -225,11 +225,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { }; // ========== 컴포넌트 종류별 미니어처 색상 ========== -// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' / -// hidden 'v2-table-list' / 'button-primary' 등) +// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / 'button-primary' 등) const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"]; const getComponentColor = (componentKind: string) => { - // 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등) + // 테이블/그리드 관련 (canonical table 등 table-like 컴포넌트) if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) { return "bg-primary/20 border-primary/40"; } diff --git a/frontend/components/screen/config-panels/button-config/ActionTab.tsx b/frontend/components/screen/config-panels/button-config/ActionTab.tsx index 2fc189f0..7d57eeea 100644 --- a/frontend/components/screen/config-panels/button-config/ActionTab.tsx +++ b/frontend/components/screen/config-panels/button-config/ActionTab.tsx @@ -30,7 +30,7 @@ import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/appro import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types"; import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils"; -// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable +// canonical table / data-table / datatable 등 table-like 컴포넌트 // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 호환 대상으로 함께 인식. const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; @@ -368,10 +368,7 @@ export const ActionTab: React.FC = ({ sourceTableName = getTableNameFromTableLikeComponent(comp) || null; if (sourceTableName) break; } - if (compType === "v2-list") { - sourceTableName = compConfig.dataSource?.table || compConfig.table_name || null; - if (sourceTableName) break; - } + // 옛 통합 목록 분기 폐기 (Phase F.8) — canonical table 사용. } setModalActionSourceTable(sourceTableName); @@ -529,7 +526,7 @@ export const ActionTab: React.FC = ({ } } - // 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두) + // 테이블 계열 (canonical table 등 table-like 컴포넌트 모두) if (isTableLikeComponent(comp)) { sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name; if (sourceTableName) { diff --git a/frontend/components/screen/config-panels/button/DataTab.tsx b/frontend/components/screen/config-panels/button/DataTab.tsx index 09b0a52d..d5cb8222 100644 --- a/frontend/components/screen/config-panels/button/DataTab.tsx +++ b/frontend/components/screen/config-panels/button/DataTab.tsx @@ -36,7 +36,7 @@ export interface DataTabProps { >; } -// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable +// canonical table / data-table / datatable 등 table-like 컴포넌트 // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 호환 대상으로 함께 인식. const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; diff --git a/frontend/components/screen/modals/MultilangSettingsModal.tsx b/frontend/components/screen/modals/MultilangSettingsModal.tsx index 0352d359..0a16bac7 100644 --- a/frontend/components/screen/modals/MultilangSettingsModal.tsx +++ b/frontend/components/screen/modals/MultilangSettingsModal.tsx @@ -55,6 +55,7 @@ import { Progress } from "@/components/ui/progress"; import { ComponentData } from "@/types/screen"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils"; // 다국어 키 타입 interface LangKey { @@ -146,16 +147,16 @@ interface MultilangSettingsModalProps { onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void; } -// 타입별 아이콘 매핑 -// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘. +// 타입별 아이콘 매핑. +// Phase E.3 — canonical / legacy / hidden 등 table-like 는 모두 isTableLikeComponentType 헬퍼로 +// 흡수해 같은 Table2 아이콘. hard-coded 옛 ID literal 제거. const getTypeIcon = (type: string) => { + if (isTableLikeComponentType(type)) { + return ; + } switch (type) { case "button": return ; - case "table": - case "table-list": - case "v2-table-list": - return ; case "split-panel-layout": return ; case "filter": @@ -194,12 +195,11 @@ const getTypeLabel = (type: string) => { } }; -// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등) -// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류. +// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등). +// Phase E.3 — table-like 는 isTableLikeComponentType 헬퍼 (isInputComponent 안 분기) 가 흡수. +// 여기는 canonical "table" 만 명시 — 옛 ID literal 은 헬퍼가 처리. const NON_INPUT_COMPONENT_TYPES = new Set([ "table", - "table-list", - "v2-table-list", "split-panel-layout", "tab-panel", "container", @@ -245,6 +245,12 @@ const isInputComponent = (comp: any): boolean => { const compType = comp.componentType || comp.type; const webType = comp.webType || comp.componentConfig?.webType; + // Phase E.3 — table-like (canonical "table" / legacy ID 등) 는 입력 아님. + // 헬퍼가 옛 ID 도 흡수하므로 NON_INPUT_COMPONENT_TYPES 에 직접 나열할 필요 없음. + if (isTableLikeComponentType(compType)) { + return false; + } + // 명시적으로 제외되는 컴포넌트 타입 if (NON_INPUT_COMPONENT_TYPES.has(compType)) { return false; diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index d239f577..563ea2de 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -39,18 +39,12 @@ export function ComponentsPanel({ }: ComponentsPanelProps) { const [searchQuery, setSearchQuery] = useState(""); - // 레지스트리에서 모든 컴포넌트 조회 + // 레지스트리에서 모든 컴포넌트 조회. + // Phase E.3 — 새 생성 경로는 canonical 'table' (displayMode='table') 뿐. + // 옛 layout JSON 호환은 BlockRenderer / DynamicComponentRenderer / templateMigrate 의 + // alias 라우팅 + TableComponent 의 early delegation 으로 처리되며 팔레트와는 무관. const allComponents = useMemo(() => { - const components = ComponentRegistry.getAllComponents(); - // ★ 새 생성 경로는 canonical 'table' (displayMode='table'). - // v2-table-list / table-list registration shell 은 2026-05-20 cleanup 으로 삭제되어 - // ComponentRegistry 에는 더 이상 등록되지 않는다. 옛 저장 layout 의 v2-table-list / - // table-list 는 BlockRenderer / DynamicComponentRenderer / templateMigrate 의 alias - // 라우팅으로 canonical 'table' 정의를 통해 들어온 뒤, TableComponent 의 early - // delegation 으로 _shared/{TableListComponent,V2TableListContainerWrapper} 본체에서 - // 기능 손실 없이 렌더된다. 아래 hiddenComponents 의 옛 ID 들은 만약 외부 코드가 - // register 를 추가하더라도 팔레트에는 노출되지 않도록 한 안전망이다. - return components; + return ComponentRegistry.getAllComponents(); }, []); // ── 기본 컴포넌트 (v2 하드코딩) ── @@ -142,7 +136,6 @@ export function ComponentsPanel({ "button-primary", // → v2-button-primary "split-panel-layout", // → v2-split-panel-layout // aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요 - "table-list", // legacy hidden — 새 생성 경로는 canonical 'table' "text-display", // → v2-text-display "divider-line", // → v2-divider-line // ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider` @@ -176,9 +169,10 @@ export function ComponentsPanel({ // 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') + // Phase E.3 — 옛 hidden table ID 들은 ComponentRegistry 에 등록되지 않으므로 + // hidden 목록에 둘 필요 없음. canonical 'table' 만 새 생성 경로. "v2-split-panel-layout", // → table (displayMode='split') - // table-list, split-panel-layout, split-panel-layout2, modal-repeater-table, + // split-panel-layout, split-panel-layout2, modal-repeater-table, // simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김 // ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container` // v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget: @@ -235,7 +229,6 @@ export function ComponentsPanel({ const s = 14; const icons: Record = { table: , - "v2-table-list": , stats: , title: , divider: , diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index dc5c154b..0f96addf 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -224,7 +224,8 @@ export const V2PropertiesPanel: React.FC = ({ if (componentId?.startsWith("v2-")) { const v2ConfigPanels: Record void }>> = { // V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거. - // v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — ComponentRegistry fallback 사용 + // 옛 통합 목록 / repeater / 옛 표 형식 / v2-date 는 InvField · canonical table 등으로 흡수 + // (ComponentRegistry fallback 으로 라우팅) "v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel, "v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel, // v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수. @@ -249,9 +250,7 @@ export const V2PropertiesPanel: React.FC = ({ const extraProps: Record = {}; const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; - if (componentId === "v2-list") { - extraProps.currentTableName = currentTableName; - } + // 옛 통합 목록 extraProps 분기 폐기 (Phase F.8) — canonical table 로 흡수. if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; extraProps.screenTableName = resolvedTableName; diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index 764b4bcc..8c19fc37 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -97,6 +97,9 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters setGroupSumEnabled(false); setGroupByColumn(""); } + } else { + setGroupSumEnabled(false); + setGroupByColumn(""); } if (savedFilters) { @@ -252,9 +255,19 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters const groupSumConfig: GroupSumConfig = { enabled: groupSumEnabled, group_by_column: groupByColumn, + group_by_column_label: table.columns.find((col) => col.column_name === groupByColumn)?.column_label, }; localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig)); + // Phase D.8 — group-sum 콜백 호출 (canonical TableComponent 가 GroupedView 강제 + summary 렌더) + if (table.onGroupSumChange) { + if (groupSumEnabled && groupByColumn) { + table.onGroupSumChange(groupSumConfig); + } else { + table.onGroupSumChange(null); + } + } + // 활성화된 필터만 콜백 const activeFilters: TableFilter[] = columnFilters .filter((f) => f.enabled) @@ -265,6 +278,8 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters filter_type: f.filterType, width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25, })); + // Phase D.8 — canonical TableComponent.onFilterChange 도 호출 (이전엔 onFiltersApplied 만) + table.onFilterChange(activeFilters); onFiltersApplied?.(activeFilters); // 3. 그룹화 저장 @@ -657,4 +672,3 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters ); }; - diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 78a6898b..ebc38354 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -144,7 +144,7 @@ export function TabsWidget({ const inlineComponents = tab.components || []; if (inlineComponents.length > 0) { // 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출 - // (canonical table / legacy table-list / hidden v2-table-list 모두 인식, + // (canonical table 등 table-like 컴포넌트 인식, // camelCase / snake_case 양쪽 모두 처리) const tableComp = inlineComponents.find((c) => isTableLikeComponent(c)); const selectedTable = getTableNameFromTableLikeComponent(tableComp); diff --git a/frontend/components/v2/V2ComponentRenderer.tsx b/frontend/components/v2/V2ComponentRenderer.tsx index e76d6843..4738c3e4 100644 --- a/frontend/components/v2/V2ComponentRenderer.tsx +++ b/frontend/components/v2/V2ComponentRenderer.tsx @@ -11,14 +11,13 @@ import React, { forwardRef, useMemo } from "react"; import { V2ComponentProps, isV2Text, - isV2List, isV2Layout, isV2Group, isV2Biz, isV2Hierarchy, } from "@/types/v2-components"; // 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수. -import { V2List } from "./V2List"; +// V2List 는 Phase F.8 (2026-05-21) 에서 제거 — canonical table 로 흡수. import { V2Layout } from "./V2Layout"; import { V2Group } from "./V2Group"; import { V2Biz } from "./V2Biz"; @@ -48,10 +47,7 @@ export const V2ComponentRenderer = forwardRef; - } + // V2List — Phase F.8 폐기. canonical table 로 흡수. if (isV2Layout(props)) { return ; diff --git a/frontend/components/v2/V2ComponentsDemo.tsx b/frontend/components/v2/V2ComponentsDemo.tsx index ebe79651..8d962539 100644 --- a/frontend/components/v2/V2ComponentsDemo.tsx +++ b/frontend/components/v2/V2ComponentsDemo.tsx @@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator"; import { ArrowLeft } from "lucide-react"; // V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨) -import { V2List } from "./V2List"; +// V2List 는 Phase F.8 에서 폐기 — canonical table 로 흡수. import { V2Layout } from "./V2Layout"; import { V2Group } from "./V2Group"; // V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수. @@ -32,17 +32,10 @@ interface V2ComponentsDemoProps { } export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) { - const [activeTab, setActiveTab] = useState("list"); - - // 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨) + const [activeTab, setActiveTab] = useState("layout"); - // 샘플 데이터 - const sampleTableData = [ - { id: 1, name: "홍길동", email: "hong@test.com", status: "active", date: "2024-01-15" }, - { id: 2, name: "김철수", email: "kim@test.com", status: "inactive", date: "2024-02-20" }, - { id: 3, name: "이영희", email: "lee@test.com", status: "active", date: "2024-03-10" }, - { id: 4, name: "박민수", email: "park@test.com", status: "pending", date: "2024-04-05" }, - ]; + // 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨) + // sampleTableData / List 탭은 Phase F.8 에서 제거 — canonical table demo 별도 화면. const sampleHierarchyData: HierarchyNode[] = [ { @@ -92,7 +85,6 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
- List Layout Group Biz @@ -101,40 +93,7 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) { {/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */} {/* 옛 입력/선택 탭 — Phase D.3 에서 폐기. canonical `input` 데모는 별도 화면에서 확인 */} - - {/* V2List 탭 */} - - - - V2List - - 통합 리스트 컴포넌트 - table, card, list - - - - console.log("Row clicked:", row)} - /> - - - + {/* List 탭 — Phase F.8 에서 폐기. canonical table demo 는 별도 화면에서 확인 */} {/* V2Layout 탭 */} diff --git a/frontend/components/v2/V2List.tsx b/frontend/components/v2/V2List.tsx deleted file mode 100644 index 116a80a5..00000000 --- a/frontend/components/v2/V2List.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -/** - * V2List - * - * 통합 리스트 컴포넌트 - * 기존 TableListComponent를 래핑하여 동일한 기능 제공 - */ - -import React, { forwardRef, useMemo } from "react"; -import { TableListComponent } from "@/lib/registry/components/table/_shared/TableListComponent"; -import { V2ListProps } from "@/types/v2-components"; - -/** - * 메인 V2List 컴포넌트 - * 기존 TableListComponent의 모든 기능을 그대로 사용 - */ -export const V2List = forwardRef((props, ref) => { - const { id, style, size, config: configProp, onRowSelect } = props; - - // config가 없으면 기본값 사용 - const config = configProp || { - viewMode: "table" as const, - source: "static" as const, - columns: [], - }; - - // 테이블명 추출 (여러 가능한 경로에서 시도) - const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName; - - // columns 형식 변환 (V2ListConfigPanel 형식 -> TableListComponent 형식) - const tableColumns = useMemo( - () => - (config.columns || []).map((col: any, index: number) => ({ - columnName: col.key || col.field || "", - displayName: col.title || col.header || col.key || col.field || "", - width: col.width ? parseInt(col.width, 10) : undefined, - visible: true, - sortable: true, - searchable: true, - align: "left" as const, - order: index, - isEntityJoin: col.isJoinColumn || false, - thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true) - })), - [config.columns], - ); - - // TableListComponent에 전달할 component 객체 생성 - const componentObj = useMemo( - () => ({ - id: id || "v2-list", - type: "table-list", - config: { - selectedTable: tableName, - tableName: tableName, - columns: tableColumns, - displayMode: config.viewMode === "card" ? "card" : "table", - cardConfig: { - idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id", - titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "", - subtitleColumn: config.cardConfig?.subtitleColumn || undefined, - descriptionColumn: config.cardConfig?.descriptionColumn || undefined, - imageColumn: config.cardConfig?.imageColumn || undefined, - cardsPerRow: config.cardConfig?.cardsPerRow || 3, - cardSpacing: 16, - showActions: false, - }, - showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김 - showFooter: false, - checkbox: { - enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장) - position: "left" as const, - showHeader: true, - }, - height: "auto" as const, // auto로 변경하여 스크롤 가능하게 - autoWidth: true, - stickyHeader: true, - autoLoad: true, - horizontalScroll: { - enabled: true, - minColumnWidth: 100, - maxColumnWidth: 300, - }, - pagination: { - enabled: config.pagination !== false, - pageSize: config.pageSize || 10, - position: "bottom" as const, - showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능 - pageSizeOptions: [5, 10, 20, 50, 100], - }, - filter: { - enabled: false, // 필터 비활성화 (필요시 활성화) - position: "top" as const, - searchPlaceholder: "검색...", - }, - actions: { - enabled: false, - items: [], - }, - tableStyle: { - striped: false, - bordered: true, - hover: true, - compact: false, - }, - toolbar: { - showRefresh: true, - showExport: false, - showColumnToggle: false, - }, - }, - style: {}, - gridColumns: 1, - }), - [ - id, - tableName, - tableColumns, - config.viewMode, - config.pagination, - config.pageSize, - config.cardConfig, - onRowSelect, - ], - ); - - // 테이블이 없으면 안내 메시지 - if (!tableName) { - return ( -
-

테이블이 설정되지 않았습니다.

-
- ); - } - - return ( -
- { - onRowSelect(selectedData); - } - : undefined - } - /> -
- ); -}); - -V2List.displayName = "V2List"; diff --git a/frontend/components/v2/config-panels/InvDataConfigPanel.tsx b/frontend/components/v2/config-panels/InvDataConfigPanel.tsx index fd2015a6..bbf15d5b 100644 --- a/frontend/components/v2/config-panels/InvDataConfigPanel.tsx +++ b/frontend/components/v2/config-panels/InvDataConfigPanel.tsx @@ -1,35 +1,31 @@ "use client"; /** - * InvDataConfigPanel — "데이터 조회/선택" 카테고리 통합 ConfigPanel + * InvDataConfigPanel — "데이터 입력" 카테고리 통합 ConfigPanel * * 등록 대상 (v2-* runtime id 그대로 — DB 호환): - * - v2-list → 조회 (read) / 통합 목록 - * - v2-table-list → 조회 (read) / 테이블 (legacy, hidden) * - v2-repeater → 입력 (write) / 자식 행 입력 * - * 마이그레이션 단계: - * 현재 = wrapper (cp brumb + 옛 패널 위임) - * 다음 = brumb 클릭 시 componentType 자동 전환 + 본체 점진 cp 이주 + resolver/writer + * Phase F.8 (2026-05-21): + * - 옛 read 카테고리(통합 목록) 는 canonical TableComponent + InvTableConfigPanel 로 통합되어 폐기. + * - 본 패널은 write 전용. 옛 저장 데이터가 v2-list 등으로 들어오더라도 v2-repeater 로 정규화. * * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md, 2026-04-28-invdata-inventory.md */ import React from "react"; -import { Database, MousePointerClick, Table2, Rows3, Columns3 } from "lucide-react"; +import { MousePointerClick, Columns3 } from "lucide-react"; import { CPCrumb, type CPCrumbType, type CPCrumbKind } from "./_shared/cp"; -import { V2ListConfigPanel } from "./V2ListConfigPanel"; -import { V2TableListConfigPanel } from "./V2TableListConfigPanel"; import { InvRepeaterConfigPanel } from "./InvRepeaterConfigPanel"; -type DataComponentType = "v2-list" | "v2-table-list" | "v2-repeater"; -type DataKind = "read" | "write"; +type DataComponentType = "v2-repeater"; +type DataKind = "write"; interface InvDataConfigPanelProps { config: any; onChange: (config: any) => void; /** 컴포넌트 타입 — runtime id (registerV2Components 에서 전달) */ - componentType: DataComponentType; + componentType: DataComponentType | string; /** 화면 메인 테이블명 */ screenTableName?: string; /** 현재 테이블명 */ @@ -43,11 +39,6 @@ interface InvDataConfigPanelProps { } const KINDS: CPCrumbKind[] = [ - { - id: "read", - name: "조회", - icon: , - }, { id: "write", name: "입력", @@ -56,22 +47,6 @@ const KINDS: CPCrumbKind[] = [ ]; const TYPES_BY_KIND: Record = { - read: [ - { - id: "v2-list", - name: "통합 목록", - desc: "테이블/카드/칸반 등 다양한 표시", - icon: , - col: "LIST", - }, - { - id: "v2-table-list", - name: "테이블", - desc: "행/열 그리드 (legacy)", - icon: , - col: "TABLE", - }, - ], write: [ { id: "v2-repeater", @@ -84,11 +59,16 @@ const TYPES_BY_KIND: Record = { }; const KIND_OF_TYPE: Record = { - "v2-list": "read", - "v2-table-list": "read", "v2-repeater": "write", }; +// Phase F.8 — 옛 read 카테고리는 canonical table 로 흡수됨. 옛 저장 데이터가 본 패널에 +// 들어오면 무조건 write/v2-repeater 로 정규화한다. +function _normalizeComponentType(t: string): DataComponentType { + if (t === "v2-repeater") return t; + return "v2-repeater"; +} + export const InvDataConfigPanel: React.FC = ({ config, onChange, @@ -99,10 +79,11 @@ export const InvDataConfigPanel: React.FC = ({ menuObjid, onComponentTypeChange, }) => { - const currentKind: DataKind = KIND_OF_TYPE[componentType] || "read"; + const normalizedType = _normalizeComponentType(componentType as string); + const currentKind: DataKind = KIND_OF_TYPE[normalizedType] || "write"; const handleKindChange = (nextKind: string) => { - // kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 + // kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 (현재 write 만 존재) const firstType = TYPES_BY_KIND[nextKind as DataKind]?.[0]?.id as DataComponentType | undefined; if (!firstType || firstType === componentType) return; if (onComponentTypeChange) { @@ -116,13 +97,14 @@ export const InvDataConfigPanel: React.FC = ({ }; const handleTypeChange = (nextType: string) => { - if (nextType === componentType) return; + const normalizedNextType = _normalizeComponentType(nextType); + if (normalizedNextType === componentType) return; if (onComponentTypeChange) { - onComponentTypeChange(nextType as DataComponentType); + onComponentTypeChange(normalizedNextType); } else { console.warn( "[InvDataConfigPanel] onComponentTypeChange 미구현 — type 변경 적용 안 됨:", - nextType, + normalizedNextType, ); } }; @@ -134,28 +116,11 @@ export const InvDataConfigPanel: React.FC = ({ currentKind={currentKind} onChangeKind={handleKindChange} types={TYPES_BY_KIND[currentKind] || []} - value={componentType} + value={normalizedType} onChange={handleTypeChange} /> - {/* 본체 — 현재는 옛 패널 위임. 다음 단계에서 cp 이주 */} - {componentType === "v2-list" && ( - - )} - {componentType === "v2-table-list" && ( - - )} - {componentType === "v2-repeater" && ( + {normalizedType === "v2-repeater" && (
- {/* 펼친 옵션: 너비 / 정렬 / 정렬 가능 */} + {/* 펼친 옵션 (Phase C.2 풀 옵션) */} {expanded && (
+ {/* 너비 + 정렬 */}
+ + {/* 고정 + 입력 타입 */} +
+ + + onChange({ + fixed: + v === "left" ? "left" : v === "right" ? "right" : false, + }) + } + options={[ + { value: "left", label: "좌" }, + { value: "none", label: "—" }, + { value: "right", label: "우" }, + ]} + /> + + + onChange({ inputType: v || undefined })} + sortable={false} + options={[ + { value: "", label: "(자동)" }, + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "datetime", label: "일시" }, + { value: "select", label: "선택" }, + { value: "entity", label: "엔티티" }, + { value: "checkbox", label: "체크박스" }, + { value: "textarea", label: "텍스트영역" }, + { value: "file", label: "파일" }, + { value: "image", label: "이미지" }, + { value: "code", label: "코드" }, + ]} + /> + +
+ + {/* 동작 — sortable / searchable / editable */} onChange({ sortable: v })} /> + + onChange({ searchable: v })} + /> + + + onChange({ editable: v })} + /> + + + {/* 표시 — visible / hidden */} + + onChange({ visible: v })} + /> + + + onChange({ hidden: v })} + /> + + + {/* 포맷 + 천단위 */} + + onChange({ format: e.target.value || undefined })} + placeholder="예: ###,### / YYYY-MM-DD" + style={inputStyle()} + /> + + {(col.inputType === "number" || + col.format === "number" || + col.format === "currency") && ( + + onChange({ thousandSeparator: v })} + /> + + )}
)} @@ -807,4 +1580,749 @@ function inputStyle({ mono = false }: { mono?: boolean } = {}): React.CSSPropert }; } +// ─────────────────────────────────────────────────────── +// Phase C.3 (2026-05-20) — 필터 sub-editor helpers +// ─────────────────────────────────────────────────────── + +type ColOpt = { value: string; label: string }; + +function SubSectionHeading({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function AddRowButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( +
+ +
+ ); +} + +function RemoveRowButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +const WIDGET_TYPE_OPTIONS: ColOpt[] = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "select", label: "선택" }, + { value: "entity", label: "엔티티" }, + { value: "code", label: "공통코드" }, + { value: "checkbox", label: "체크박스" }, +]; + +// ──── FilterWidgetList (filter.filters[]) ──── +function FilterWidgetList({ + filters, + columnOptions, + onChange, +}: { + filters: NonNullable["filters"]; + columnOptions: ColOpt[]; + onChange: (next: NonNullable["filters"]) => void; +}) { + const update = (idx: number, p: Partial<(typeof filters)[number]>) => + onChange(filters.map((f, i) => (i === idx ? { ...f, ...p } : f))); + const remove = (idx: number) => onChange(filters.filter((_, i) => i !== idx)); + const add = () => + onChange([ + ...filters, + { + columnName: columnOptions[0]?.value || "", + widgetType: "text", + label: "", + gridColumns: 3, + }, + ]); + + return ( + <> + {filters.length === 0 ? ( + 필터 위젯이 없습니다. 아래 [+ 검색 필터 추가] 로 새 row 를 만드세요. + ) : ( +
+ {filters.map((f, idx) => ( +
+
+ update(idx, { columnName: v })} + sortable={false} + options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} + /> + update(idx, { widgetType: v })} + sortable={false} + options={WIDGET_TYPE_OPTIONS} + /> + update(idx, { label: e.target.value })} + placeholder="라벨" + style={inputStyle()} + /> + update(idx, { gridColumns: v ?? 3 })} + min={1} + max={12} + /> + remove(idx)} /> +
+ {f.widgetType === "number" && ( + + + update(idx, { + numberFilterMode: v as "exact" | "range", + }) + } + options={[ + { value: "exact", label: "단일" }, + { value: "range", label: "범위" }, + ]} + /> + + )} + {f.widgetType === "code" && ( + + + update(idx, { codeInfo: e.target.value || undefined }) + } + placeholder="예: ORD_STATUS" + style={inputStyle({ mono: true })} + /> + + )} + {(f.widgetType === "entity" || f.widgetType === "select") && ( + <> + + + update(idx, { + referenceTable: e.target.value || undefined, + }) + } + placeholder="예: tb_customer" + style={inputStyle({ mono: true })} + /> + + + + update(idx, { + referenceColumn: e.target.value || undefined, + }) + } + placeholder="예: customer_code" + style={inputStyle({ mono: true })} + /> + + + + update(idx, { + displayColumn: e.target.value || undefined, + }) + } + placeholder="예: customer_name" + style={inputStyle({ mono: true })} + /> + + + )} +
+ ))} +
+ )} + + + ); +} + +// ──── LinkedFilterList (linkedFilters[]) ──── +function LinkedFilterList({ + items, + columnOptions, + onChange, +}: { + items: NonNullable; + columnOptions: ColOpt[]; + onChange: (next: NonNullable) => void; +}) { + const update = ( + idx: number, + p: Partial<(typeof items)[number]>, + ) => onChange(items.map((f, i) => (i === idx ? { ...f, ...p } : f))); + const remove = (idx: number) => onChange(items.filter((_, i) => i !== idx)); + const add = () => + onChange([ + ...items, + { + sourceComponentId: "", + targetColumn: columnOptions[0]?.value || "", + operator: "equals", + enabled: true, + }, + ]); + + return ( + <> + {items.length === 0 ? ( + 연결된 필터가 없습니다. 아래 [+ 연결 필터 추가] 로 새 row 를 만드세요. + ) : ( +
+ {items.map((f, idx) => ( +
+
+ update(idx, { enabled: v })} + /> + + update(idx, { sourceComponentId: e.target.value }) + } + placeholder="source 컴포넌트 id" + style={inputStyle({ mono: true })} + /> + remove(idx)} /> +
+ + + update(idx, { sourceField: e.target.value || undefined }) + } + placeholder="value" + style={inputStyle({ mono: true })} + /> + + + update(idx, { targetColumn: v })} + sortable={false} + options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} + /> + + + + update(idx, { + operator: v as "equals" | "contains" | "in", + }) + } + options={[ + { value: "equals", label: "=" }, + { value: "contains", label: "포함" }, + { value: "in", label: "IN" }, + ]} + /> + +
+ ))} +
+ )} + + + ); +} + +// ──── DataFilterList (dataFilter.filters[]) ──── +const DATA_FILTER_OPERATOR_OPTIONS: ColOpt[] = [ + { value: "equals", label: "= equals" }, + { value: "not_equals", label: "≠ not_equals" }, + { value: "in", label: "IN" }, + { value: "not_in", label: "NOT IN" }, + { value: "contains", label: "contains" }, + { value: "starts_with", label: "starts_with" }, + { value: "ends_with", label: "ends_with" }, + { value: "is_null", label: "is_null" }, + { value: "is_not_null", label: "is_not_null" }, + { value: "greater_than", label: ">" }, + { value: "less_than", label: "<" }, + { value: "greater_than_or_equal", label: "≥" }, + { value: "less_than_or_equal", label: "≤" }, + { value: "between", label: "between" }, + { value: "date_range_contains", label: "date_range_contains" }, +]; + +function DataFilterList({ + filters, + columnOptions, + onChange, +}: { + filters: NonNullable["filters"]; + columnOptions: ColOpt[]; + onChange: (next: NonNullable["filters"]) => void; +}) { + const update = (idx: number, p: Partial<(typeof filters)[number]>) => + onChange(filters.map((f, i) => (i === idx ? { ...f, ...p } : f))); + const remove = (idx: number) => onChange(filters.filter((_, i) => i !== idx)); + const add = () => + onChange([ + ...filters, + { + id: `filter-${Date.now()}`, + column_name: columnOptions[0]?.value || "", + operator: "equals", + value: "", + value_type: "static", + }, + ]); + + const onValueChange = ( + idx: number, + raw: string, + operator: string, + ) => { + if (operator === "in" || operator === "not_in") { + const arr = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + update(idx, { value: arr }); + } else { + update(idx, { value: raw }); + } + }; + const onOperatorChange = ( + idx: number, + operator: (typeof filters)[number]["operator"], + currentValue: (typeof filters)[number]["value"], + ) => { + const currentText = Array.isArray(currentValue) + ? currentValue.join(", ") + : currentValue ?? ""; + const nextValue = + operator === "in" || operator === "not_in" + ? currentText + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : currentText; + update(idx, { operator, value: nextValue }); + }; + + return ( + <> + {filters.length === 0 ? ( + 데이터 필터 row 가 없습니다. 아래 [+ 컬럼 필터 추가] 로 새 row 를 만드세요. + ) : ( +
+ {filters.map((f, idx) => { + const valueAsText = Array.isArray(f.value) + ? f.value.join(", ") + : f.value ?? ""; + const isNullOp = f.operator === "is_null" || f.operator === "is_not_null"; + const isRangeOp = f.operator === "date_range_contains"; + return ( +
+
+ update(idx, { column_name: v })} + sortable={false} + options={[{ value: "", label: "(컬럼)" }, ...columnOptions]} + /> + + onOperatorChange( + idx, + v as (typeof f)["operator"], + f.value, + ) + } + sortable={false} + options={DATA_FILTER_OPERATOR_OPTIONS} + /> + remove(idx)} /> +
+ + + update(idx, { + value_type: v as + | "static" + | "category" + | "code" + | "dynamic", + }) + } + options={[ + { value: "static", label: "정적" }, + { value: "category", label: "카테고리" }, + { value: "code", label: "공통코드" }, + { value: "dynamic", label: "동적" }, + ]} + /> + + {!isNullOp && ( + + + onValueChange(idx, e.target.value, f.operator) + } + placeholder={ + f.operator === "in" || f.operator === "not_in" + ? "a, b, c" + : "" + } + style={inputStyle()} + /> + + )} + {isRangeOp && ( + <> + + + update(idx, { + range_config: { + start_column: v, + end_column: f.range_config?.end_column || "", + }, + }) + } + sortable={false} + options={[ + { value: "", label: "(컬럼)" }, + ...columnOptions, + ]} + /> + + + + update(idx, { + range_config: { + start_column: f.range_config?.start_column || "", + end_column: v, + }, + }) + } + sortable={false} + options={[ + { value: "", label: "(컬럼)" }, + ...columnOptions, + ]} + /> + + + )} +
+ ); + })} +
+ )} + + + ); +} + +// ──── ActionItemList (Phase C.4 — actions.actions[]) ──── +const ACTION_TYPE_OPTIONS: { value: TableActionType; label: string }[] = [ + { value: "view", label: "보기" }, + { value: "edit", label: "수정" }, + { value: "delete", label: "삭제" }, + { value: "custom", label: "커스텀" }, +]; + +function ActionItemList({ + items, + onChange, +}: { + items: TableActionItemConfig[]; + onChange: (next: TableActionItemConfig[]) => void; +}) { + const update = (idx: number, p: Partial) => + onChange(items.map((it, i) => (i === idx ? { ...it, ...p } : it))); + const remove = (idx: number) => onChange(items.filter((_, i) => i !== idx)); + const add = () => + onChange([ + ...items, + { type: "view", label: "보기" }, + ]); + + return ( + <> + {items.length === 0 ? ( + 행 액션이 없습니다. 아래 [+ 행 액션 추가] 로 새 row 를 만드세요. + ) : ( +
+ {items.map((it, idx) => ( +
+
+ + update(idx, { type: v as TableActionType }) + } + options={ACTION_TYPE_OPTIONS} + /> + update(idx, { label: e.target.value })} + placeholder="라벨" + style={inputStyle()} + /> + remove(idx)} /> +
+ + + update(idx, { icon: e.target.value || undefined }) + } + placeholder="Eye" + style={inputStyle({ mono: true })} + /> + + + + update(idx, { color: e.target.value || undefined }) + } + placeholder="primary" + style={inputStyle()} + /> + + + + update(idx, { + confirmMessage: e.target.value || undefined, + }) + } + placeholder="예: 정말 삭제하시겠습니까?" + style={inputStyle()} + /> + + + + update(idx, { + targetScreen: e.target.value || undefined, + }) + } + placeholder="예: /screen/orderDetail" + style={inputStyle({ mono: true })} + /> + +
+ ))} +
+ )} + + + ); +} + export default InvTableConfigPanel; diff --git a/frontend/lib/registry/components/table/TableComponent.tsx b/frontend/lib/registry/components/table/TableComponent.tsx index b2d69801..53d1d486 100644 --- a/frontend/lib/registry/components/table/TableComponent.tsx +++ b/frontend/lib/registry/components/table/TableComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { @@ -10,77 +10,52 @@ import { TableRowHeight, } from "./types"; import { useTableData } from "./useTableData"; +import type { ExcludeFilterPayload } from "./useTableData"; import { GroupedView } from "./views/GroupedView"; import { CardView } from "./views/CardView"; import { PivotView } from "./views/PivotView"; - -/** - * ───────────────────────────────────────────────────────────────────────── - * Legacy / v2 table-list delegation (2026-05-20 canonical cleanup follow-up) - * - * canonical 새 생성 경로는 그대로 'table' (이 파일의 본체 `TableComponent`). - * 단, old 저장 layout 중 componentType === 'table-list' / 'v2-table-list' 인 - * 컴포넌트가 BlockRenderer / DynamicComponentRenderer 의 alias 라우팅으로 - * 본 컴포넌트에 도달하면 기능 손실 없이 shared legacy/v2 runtime 으로 위임한다. - * ───────────────────────────────────────────────────────────────────────── - */ -import { TableListWrapper as LegacyTableListWrapper } from "./_shared/TableListComponent"; -import { TableListContainerWrapper as V2TableListContainerWrapper } from "./_shared/V2TableListContainerWrapper"; - -/** - * raw original componentType 탐색 — meaningful table type 만 즉시 채택. - * - * BlockRenderer / DynamicComponentRenderer 의 alias 라우팅을 통해 본 컴포넌트에 도달하면 - * `component.type` 이 layout JSON 의 일반 값 ("component", "widget", "container", "row" 등) - * 으로 박혀 들어오는 경우가 있다. 그런 generic 값은 delegation 판단에 도움이 안 되므로 - * skip 하고 더 구체적인 후보를 계속 본다. - * - * 우선순위: - * 1) MEANINGFUL_TABLE_TYPES 에 즉시 일치 → 채택 - * 2) GENERIC_TYPES 에 일치 → skip - * 3) 그 외 첫 non-empty string → fallback 후보로 기억 - * 4) 모든 후보 + url last segment 검사 후 fallback 반환 - */ -const _MEANINGFUL_TABLE_TYPES = new Set([ - "table", - "table-list", - "v2-table-list", - "data-table", - "datatable", -]); -const _GENERIC_COMPONENT_TYPES = new Set([ - "component", "widget", "container", "group", "row", "column", "area", "flow", "tabs", -]); - -function _resolveRawComponentType(component: any, props: any): string | undefined { - const candidates: unknown[] = [ - component?.componentType, - component?.component_type, - component?.componentConfig?.type, - component?.component_config?.type, - props?.componentType, - component?.type, - props?.type, - ]; - let fallback: string | undefined; - const consider = (v: unknown): string | undefined => { - if (typeof v !== "string" || v.length === 0) return undefined; - if (_MEANINGFUL_TABLE_TYPES.has(v)) return v; - if (_GENERIC_COMPONENT_TYPES.has(v)) return undefined; - if (!fallback) fallback = v; - return undefined; - }; - for (const v of candidates) { - const hit = consider(v); - if (hit) return hit; - } - if (typeof component?.url === "string") { - const last = component.url.split("/").pop(); - const hit = consider(last); - if (hit) return hit; - } - return fallback; -} +import { useTableOptionsOptional } from "@/contexts/TableOptionsContext"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { + useSplitPanelContext, + type SplitPanelPosition, +} from "@/contexts/SplitPanelContext"; +import type { + DataProvidable, + DataReceivable, + DataReceiverConfig, + DataReceivableComponentType, + EntityJoinColumnMeta, +} from "@/types/data-transfer"; +import { tableDisplayStore } from "@/stores/tableDisplayStore"; +import type { + TableRegistration, + TableColumn as TableOptionsTableColumn, + ColumnVisibility, + TableFilter, + GroupSumConfig, +} from "@/types/table-options"; +import type { DataFilterConfig } from "@/types/screen-management"; +import { fieldsToCanonicalColumns } from "@/lib/fieldConfig/adapters"; +import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; +import { InlineCellDatePicker } from "@/components/screen/filters/InlineCellDatePicker"; +import { + Eye, + Pencil, + Trash2, + MoreHorizontal, + Copy, + Printer, + Search as SearchIcon, + Filter as FilterIcon, + PenSquare, + RotateCw, + FileSpreadsheet, + Settings, +} from "lucide-react"; +import { renderTableCellValue } from "./cell-renderers"; +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; +import { TableSettingsModal } from "@/components/screen/table-options/TableSettingsModal"; const VALID_MODES: TableDisplayMode[] = ["table", "split", "grouped", "pivot", "card"]; const ROW_HEIGHT_PRESETS: Record = { @@ -90,6 +65,32 @@ const ROW_HEIGHT_PRESETS: Record = { }; const DESIGN_PREVIEW_ROWS = 5; +// Phase D.2 — 검색 값 sanitize. empty / null / __ALL__ / 빈 배열 / 모든 멤버가 비어있는 객체 제거. +function _isMeaningfulSearchValue(v: any): boolean { + if (v === null || v === undefined) return false; + if (typeof v === "string") { + const t = v.trim(); + return t.length > 0 && t !== "__ALL__"; + } + if (Array.isArray(v)) { + return v.some((x) => _isMeaningfulSearchValue(x)); + } + if (typeof v === "object") { + // range objects: { from, to } / { min, max } / { start, end } 등 어느 키든 멤버 1개 이상 의미 있으면 통과 + return Object.values(v).some((x) => _isMeaningfulSearchValue(x)); + } + // number / boolean 등은 의미 있는 값 + return true; +} + +function _sanitizeSearchValues(obj: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (_isMeaningfulSearchValue(v)) out[k] = v; + } + return out; +} + export interface TableComponentProps extends ComponentRendererProps { config?: TableConfig; } @@ -106,50 +107,9 @@ export const TableComponent: React.FC = ({ style, ...props }) => { - // ─── Legacy / v2 delegation (early return) ─── - // canonical 새 생성 경로는 'table'. 그 외 라우팅된 old type 은 shared runtime 으로 위임. - // props 손실 방지: 모든 props 를 ...rest 로 전달 + component/config/isDesignMode 등 명시. - const _rawType = _resolveRawComponentType(component, props); - if (_rawType === "table-list") { - // old table-list layout — 기능 완전 보존: 필터/정렬/선택/체크박스/카드모드/ - // inline edit / toolbar / export / search / linked filter / exclude filter / - // GroupSum / DataProvider / DataReceiver / FieldConfig adapter / tableName / - // selectedTable / dbTable 모두 shared LegacyTableListWrapper 에서 그대로 처리. - return ( - - ); - } - if (_rawType === "v2-table-list") { - // old v2-table-list layout — V2TableListContainerWrapper 가 - // FieldConfig → columns 어댑터 + ResizeObserver + DataProvider/DataReceiver 포함. - return ( - - ); - } - - // ─── 4경로 머지 (canonical 'table' 본체, 기존 코드 그대로) ─── + // ─── 4경로 머지 (canonical 'table' 본체) ─── + // Phase F.1 — canonical body 만 runtime 경로. 옛 layout 호환은 alias 라우팅 / migration + // 레이어에서 처리 (사용자 정책: 솔루션 개발 단계라 옛 데이터 형식 / 마이그 불필요). const fromProps: Partial = {}; const p = props as any; if (typeof p.displayMode === "string" && (VALID_MODES as string[]).includes(p.displayMode)) @@ -171,6 +131,38 @@ export const TableComponent: React.FC = ({ if (typeof p.showToolbar === "boolean") fromProps.showToolbar = p.showToolbar; if (typeof p.showExcel === "boolean") fromProps.showExcel = p.showExcel; if (typeof p.showRefresh === "boolean") fromProps.showRefresh = p.showRefresh; + // ─── 데이터 소스 옵션 (Phase C.1, 2026-05-20) ─── + if (typeof p.useCustomTable === "boolean") fromProps.useCustomTable = p.useCustomTable; + if (typeof p.customTableName === "string") fromProps.customTableName = p.customTableName; + if (typeof p.isReadOnly === "boolean") fromProps.isReadOnly = p.isReadOnly; + if (typeof p.autoLoad === "boolean") fromProps.autoLoad = p.autoLoad; + // ─── 스타일 / 툴바 / 데이터 동작 (Phase C.5, 2026-05-20) ─── + if (p.tableStyle && typeof p.tableStyle === "object") + fromProps.tableStyle = p.tableStyle; + if (p.toolbar && typeof p.toolbar === "object") fromProps.toolbar = p.toolbar; + if (p.defaultSort && typeof p.defaultSort === "object") + fromProps.defaultSort = p.defaultSort; + if (typeof p.refreshInterval === "number") + fromProps.refreshInterval = p.refreshInterval; + // ─── 필터 config (Phase C.3, 2026-05-20) — runtime 적용은 Phase D.2 ─── + if (p.filter && typeof p.filter === "object") fromProps.filter = p.filter; + if (Array.isArray(p.linkedFilters)) fromProps.linkedFilters = p.linkedFilters; + if (p.excludeFilter && typeof p.excludeFilter === "object") + fromProps.excludeFilter = p.excludeFilter; + if (p.dataFilter && typeof p.dataFilter === "object") + fromProps.dataFilter = p.dataFilter; + // ─── 액션 config (Phase C.4, 2026-05-20) — runtime 렌더/실행은 Phase D.4 ─── + if (p.actions && typeof p.actions === "object") fromProps.actions = p.actions; + // ─── 컬럼 시스템 runtime 옵션 (Phase D.1, 2026-05-20) ─── + if (typeof p.autoWidth === "boolean") fromProps.autoWidth = p.autoWidth; + if (typeof p.stickyHeader === "boolean") fromProps.stickyHeader = p.stickyHeader; + if (p.horizontalScroll && typeof p.horizontalScroll === "object") + fromProps.horizontalScroll = p.horizontalScroll; + if ( + typeof p.responsiveCardBreakpoint === "number" || + p.responsiveCardBreakpoint === false + ) + fromProps.responsiveCardBreakpoint = p.responsiveCardBreakpoint; const componentConfig = { ...config, @@ -179,50 +171,485 @@ export const TableComponent: React.FC = ({ ...fromProps, } as TableConfig; - const displayMode: TableDisplayMode = (VALID_MODES as string[]).includes( + const configuredDisplayMode: TableDisplayMode = (VALID_MODES as string[]).includes( componentConfig.displayMode as string, ) ? (componentConfig.displayMode as TableDisplayMode) : "table"; - // ─── 테이블명 결정 ─── + // ─── 테이블명 결정 (Phase C.1 보강, 2026-05-20) ─── + // 1) useCustomTable + customTableName 우선 — 컴포넌트 전용 테이블 + // 2) canonical selectedTable + // 3) legacy alias `tableName` (componentConfig 안) + // 4) component 루트 `tableName` (옛 layout JSON 의 표면 키) + // 5) component.componentConfig.selectedTable / tableName fallback + // 6) 명시 props.tableName (사용자 직접 전달) const tableName = + (componentConfig.useCustomTable && componentConfig.customTableName) || componentConfig.selectedTable || + componentConfig.tableName || (component as any).tableName || (component as any).componentConfig?.selectedTable || (component as any).componentConfig?.tableName || (props as any).tableName; - // ─── columns 결정 ─── - const columns: TableColumn[] = (() => { + // ─── source columns (Phase D.1 — visible/hidden filter 없는 config 원본) ─── + // + // Phase C.2 (2026-05-20) — fields → canonical columns 변환은 `fieldsToCanonicalColumns` + // (camelCase + 풀 옵션) 어댑터 사용. inputType / dataType / searchable / editable / + // thousandSeparator 까지 자동 매핑됨. 동시에 componentConfig.columns 에 사용자가 + // ConfigPanel 로 편집한 풀 옵션이 있으면 같은 key 에 overlay 해서 보존한다. + // + // Phase D.1 — 이전엔 여기서 `visible === false` 를 거르고 single `columns` 로 썼지만 + // ConfigPanel-only metadata (fixed / hidden / fixedOrder / inputType / editable / + // thousandSeparator / entity 조인 메타) 와 TableOptions UI 의 overlay 를 위해 + // **source 보존**. 렌더용은 `renderColumns` 에서 별도 derive. + const sourceColumns: TableColumn[] = useMemo(() => { if (Array.isArray(componentConfig.fields) && componentConfig.fields.length > 0) { - return componentConfig.fields - .filter((f) => f.visible !== false && !f.system) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((f) => ({ - key: f.column, - label: f.label, - width: f.width, - align: f.align ?? (f.type === "number" ? "right" : "left"), - sortable: f.sortable ?? true, - visible: f.visible !== false, - format: f.format, - })); + const fieldColumns = fieldsToCanonicalColumns( + componentConfig.fields, + ) as unknown as TableColumn[]; + if (!Array.isArray(componentConfig.columns) || componentConfig.columns.length === 0) { + return fieldColumns; + } + + const overridesByKey = new Map(); + for (const col of componentConfig.columns) { + const key = (col as any)?.key || (col as any)?.columnName || (col as any)?.field; + if (typeof key === "string" && key.length > 0) { + overridesByKey.set(key, col as TableColumn); + } + } + + const mergedFieldColumns = fieldColumns.map((fieldCol) => ({ + ...fieldCol, + ...(overridesByKey.get(fieldCol.key) ?? {}), + key: fieldCol.key, + label: overridesByKey.get(fieldCol.key)?.label ?? fieldCol.label, + })); + const fieldKeys = new Set(mergedFieldColumns.map((c) => c.key)); + const extraConfigColumns = componentConfig.columns + .filter((col) => { + const key = (col as any)?.key || (col as any)?.columnName || (col as any)?.field; + return typeof key === "string" && key.length > 0 && !fieldKeys.has(key); + }) + .map((col) => col as TableColumn); + return [...mergedFieldColumns, ...extraConfigColumns]; } if (Array.isArray(componentConfig.columns) && componentConfig.columns.length > 0) { - return componentConfig.columns.filter((c) => c.visible !== false); + return componentConfig.columns as TableColumn[]; } return []; - })(); + }, [componentConfig.fields, componentConfig.columns]); + + // ─── TableOptions UI 가 set 하는 runtime overlay (Phase D.1) ─── + // - columnOptionsState: visibility / width / order / fixed(boolean=left only) overlay + // - frozenColumnCount: 앞쪽 N 개를 자동으로 left fixed 처리 (right fixed 는 영향 없음) + const [columnOptionsState, setColumnOptionsState] = useState([]); + const [frozenColumnCount, setFrozenColumnCount] = useState(0); + + // ─── Phase D.7 (2026-05-20) — root ref + ResizeObserver narrow/wide ─── + // configuredDisplayMode === "table" + 컨테이너 width < breakpoint 면 card 자동 fallback. + // breakpoint 기본 600. componentConfig.responsiveCardBreakpoint 가 number 면 그 값, false/0 이면 비활성. + const rootRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + useEffect(() => { + const el = rootRef.current; + if (!el) return; + if (typeof ResizeObserver === "undefined") { + // ResizeObserver 미지원 환경 — 최소 측정 + setContainerWidth(el.getBoundingClientRect().width); + return; + } + const apply = (w: number) => { + setContainerWidth((prev) => (Math.abs(prev - w) < 0.5 ? prev : w)); + }; + apply(el.getBoundingClientRect().width); + const ro = new ResizeObserver((entries) => { + for (const entry of entries) apply(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // ─── Phase D.8 (2026-05-20) — grouping / group-sum / settings modal state ─── + // displayMode useMemo 가 groupSumConfig 를 참조하므로 그 위에서 선언되어야 함. + const [tableOptionsGroups, setTableOptionsGroups] = useState([]); + const [groupSumConfig, setGroupSumConfig] = useState(null); + const [isTableSettingsOpen, setIsTableSettingsOpen] = useState(false); + + // effective displayMode — configured "table" + 좁은 컨테이너 시 "card" 로 fallback. + // Phase D.8 — group-sum enabled + configured "table" 이면 "grouped" 로 강제 (사용자가 subtotal 보기 위함). + // configured 가 card/split/grouped/pivot 이면 영향 없음. + const displayMode: TableDisplayMode = useMemo(() => { + if (configuredDisplayMode !== "table") return configuredDisplayMode; + // group-sum 우선 (좁은 컨테이너 보다 사용자 의도 반영) + if (groupSumConfig?.enabled && groupSumConfig.group_by_column) { + return "grouped"; + } + const bp = componentConfig.responsiveCardBreakpoint; + if (bp === false || bp === 0) return configuredDisplayMode; + const breakpoint = typeof bp === "number" && bp > 0 ? bp : 600; + if (containerWidth > 0 && containerWidth < breakpoint) return "card"; + return configuredDisplayMode; + }, [ + configuredDisplayMode, + componentConfig.responsiveCardBreakpoint, + containerWidth, + groupSumConfig, + ]); + const autoCardActive = configuredDisplayMode === "table" && displayMode === "card"; + + // Phase D.8 — effectiveGroupByColumns: TableOptions groups → groupSumConfig.group_by_column → config.groupBy + const effectiveGroupByColumns: string[] = useMemo(() => { + if (tableOptionsGroups.length > 0) return tableOptionsGroups; + if (groupSumConfig?.enabled && groupSumConfig.group_by_column) { + return [groupSumConfig.group_by_column]; + } + if (componentConfig.groupBy) return [componentConfig.groupBy]; + return []; + }, [tableOptionsGroups, groupSumConfig, componentConfig.groupBy]); + + // 좁은 fallback 시 cardsPerRow clamp (1 또는 2) + const effectiveCardsPerRow = useMemo(() => { + const userValue = componentConfig.cardsPerRow ?? 3; + if (!autoCardActive || containerWidth <= 0) return userValue; + if (containerWidth < 400) return 1; + if (containerWidth < 700) return Math.min(userValue, 2); + return userValue; + }, [componentConfig.cardsPerRow, autoCardActive, containerWidth]); + + // ─── Phase D.2 (2026-05-20) — 필터 runtime state ─── + // TableOptions UI 의 필터 (TableFilter[]) — onFilterChange 콜백에서 set + const [tableOptionsFilters, setTableOptionsFilters] = useState([]); + // linkedFilters 가 source 컴포넌트의 selectedData 에서 추출한 값 (polling) + const [linkedFilterValues, setLinkedFilterValues] = useState>({}); + // AdvancedSearchFilters: 입력 중인 draft + 실제 적용된 applied 분리. + // AdvancedSearchFilters 가 같은 tick 에서 onSearchValueChange → onSearch 호출하는 경우 + // applied 가 stale 안 되도록 searchDraftRef 로 ref-back. + const [searchDraft, setSearchDraft] = useState>({}); + const [searchApplied, setSearchApplied] = useState>({}); + const searchDraftRef = useRef>({}); + + // ScreenContext (optional — provider 없는 화면에서도 안전 mount) + const screenContext = useScreenContextOptional(); + + // Phase B.2/D.9 — SplitPanelContext (optional). provider 가 wrap 안 된 곳도 안전 mount. + const splitPanelContext = useSplitPanelContext(); + const _screenIdPropForSplit = (props as any)?.screenId; + // current split position 결정 — ScreenContext.split_panel_position 우선, + // 없으면 SplitPanelContext.getPositionByScreenId(screenId) fallback. screenId 가 number 아니면 null. + const currentSplitPosition: SplitPanelPosition | null = useMemo(() => { + const ctxPos = screenContext?.split_panel_position; + if (ctxPos === "left" || ctxPos === "right") return ctxPos; + const sid = _screenIdPropForSplit; + const sidNum = typeof sid === "number" ? sid : Number(sid); + if ( + splitPanelContext && + typeof splitPanelContext.getPositionByScreenId === "function" && + Number.isFinite(sidNum) + ) { + const pos = splitPanelContext.getPositionByScreenId(sidNum); + if (pos === "left" || pos === "right") return pos; + } + return null; + }, [screenContext?.split_panel_position, splitPanelContext, _screenIdPropForSplit]); + + // Phase D.5 — 다국어 라벨 (Provider 없으면 fallback 자동, throw X) + const { getTranslatedText } = useScreenMultiLang(); + const getColumnLabel = useCallback( + (col: TableColumn): string => getTranslatedText(col.langKey, col.label), + [getTranslatedText], + ); + const columnLabels = useMemo( + () => + sourceColumns.reduce>((acc, c) => { + acc[c.key] = getColumnLabel(c); + return acc; + }, {}), + [sourceColumns, getColumnLabel], + ); + + // ─── Phase D.6 (2026-05-20) — toolbar / footer / paste UI state ─── + // editModeEnabled: toolbar.showEditMode 가 true 일 때만 의미. 더블클릭 편집 진입 추가 gate. + const [editModeEnabled, setEditModeEnabled] = useState(false); + // 검색 / 필터 패널 표시 (D.2 의 AdvancedSearchFilters 영역) — toolbar 의 showSearch / showFilter 와 묶임 + const [searchPanelOpen, setSearchPanelOpen] = useState(true); + const [filterPanelOpen, setFilterPanelOpen] = useState(true); + // (Phase D.8 grouping / group-sum / settings modal state 는 위쪽 displayMode useMemo 보다 앞에서 선언됨) + const _columnLabelsJoined = useMemo( + () => Object.entries(columnLabels).map(([k, v]) => `${k}:${v}`).join("|"), + [columnLabels], + ); + + // AdvancedSearchFilters 핸들러 — ref-back 으로 같은 tick 의 onChange + onSearch race 방지. + const handleSearchValueChange = useCallback((columnName: string, value: any) => { + const next = { ...searchDraftRef.current, [columnName]: value }; + searchDraftRef.current = next; + setSearchDraft(next); + }, []); + const handleSearch = useCallback(() => { + setSearchApplied({ ...searchDraftRef.current }); + }, []); + const handleClearFilters = useCallback(() => { + searchDraftRef.current = {}; + setSearchDraft({}); + setSearchApplied({}); + }, []); + + // AdvancedSearchFilters 가 사용하는 columns 메타 — sourceColumns 전체 (visible filter 안 함) + // 를 normalize. AdvancedSearchFilters 는 webType / web_type / columnName / column_name 등 + // 다양한 키를 흡수하므로 동시에 채운다. visible / searchable / referenceTable 등도 함께. + const tableColumnsForFilters = useMemo(() => { + return sourceColumns.map((c) => { + const inferredType = + (typeof c.inputType === "string" && c.inputType) || + (typeof c.format === "string" ? c.format : "text"); + return { + column_name: c.key, + columnName: c.key, + column_label: c.label, + columnLabel: c.label, + label: c.label, + displayName: c.label, + visible: c.visible !== false, + isVisible: c.visible !== false, + searchable: c.searchable === true, + web_type: inferredType, + webType: inferredType, + inputType: c.inputType, + format: c.format, + referenceTable: c.entityJoinInfo?.sourceTable, + }; + }); + }, [sourceColumns]); + + // filter.filters[] → AdvancedSearchFilters 의 DataTableFilter shape 으로 정규화. + const filtersForAdvanced = useMemo(() => { + const arr = componentConfig.filter?.filters; + if (!Array.isArray(arr)) return []; + return arr.map((f) => ({ + columnName: f.columnName, + widgetType: f.widgetType, + label: f.label, + gridColumns: f.gridColumns, + numberFilterMode: f.numberFilterMode, + codeInfo: f.codeInfo, + referenceTable: f.referenceTable, + referenceColumn: f.referenceColumn, + displayColumn: f.displayColumn, + })); + }, [componentConfig.filter?.filters]); + + // ─── render columns (visible / hidden / options overlay / fixed grouping / order 적용) ─── + // Ordering policy: TableOptions order → column.order → 원본 index. Fixed grouping: left → middle → right. + // 각 fixed 그룹 내부에서는 fixedOrder 가 있으면 우선. + // hidden=true 는 design 모드에서 dimmed 표시 위해 포함, runtime 에서 제거. + const renderColumns: TableColumn[] = useMemo(() => { + const optionsByKey = new Map(); + for (const o of columnOptionsState) { + if (o && typeof o.column_name === "string") optionsByKey.set(o.column_name, o); + } + + let derived = sourceColumns.map((c) => { + const o = optionsByKey.get(c.key); + if (!o) return c; + return { + ...c, + visible: o.visible, + width: typeof o.width === "number" ? o.width : c.width, + order: typeof o.order === "number" ? o.order : c.order, + fixed: o.fixed === true ? ("left" as const) : c.fixed, + }; + }); + + // visible / hidden 필터 + derived = derived.filter((c) => { + if (c.visible === false) return false; + if (!isDesignMode && c.hidden === true) return false; + return true; + }); + + // frozenColumnCount: 앞쪽 N 개 visible 컬럼을 left fixed 처리. + // 기존 fixed === "right" 는 보존. left/none 중 첫 N 개만 left 로 set. + if (frozenColumnCount > 0) { + let leftCount = 0; + derived = derived.map((c) => { + if (c.fixed === "right") return c; + if (leftCount < frozenColumnCount) { + leftCount += 1; + return { ...c, fixed: "left" as const }; + } + if (c.fixed === "left") return { ...c, fixed: false as const }; + return c; + }); + } + + // order sort (stable). TableOptions order 우선, 없으면 column.order, 없으면 원본 index. + derived = derived + .map((c, idx) => ({ c, idx })) + .sort((a, b) => { + const oa = typeof a.c.order === "number" ? a.c.order : Number.MAX_SAFE_INTEGER; + const ob = typeof b.c.order === "number" ? b.c.order : Number.MAX_SAFE_INTEGER; + if (oa !== ob) return oa - ob; + return a.idx - b.idx; + }) + .map((x) => x.c); + + // fixed grouping. left/right 내부에서 fixedOrder 가 있으면 우선. + const sortByFixedOrder = (arr: TableColumn[]): TableColumn[] => + arr + .map((c, idx) => ({ c, idx })) + .sort((a, b) => { + const oa = typeof a.c.fixedOrder === "number" ? a.c.fixedOrder : Number.MAX_SAFE_INTEGER; + const ob = typeof b.c.fixedOrder === "number" ? b.c.fixedOrder : Number.MAX_SAFE_INTEGER; + if (oa !== ob) return oa - ob; + return a.idx - b.idx; + }) + .map((x) => x.c); + const leftGroup = derived.filter((c) => c.fixed === "left"); + const middleGroup = derived.filter((c) => c.fixed !== "left" && c.fixed !== "right"); + const rightGroup = derived.filter((c) => c.fixed === "right"); + return [ + ...sortByFixedOrder(leftGroup), + ...middleGroup, + ...sortByFixedOrder(rightGroup), + ]; + }, [sourceColumns, columnOptionsState, frozenColumnCount, isDesignMode]); + + // 기존 렌더 코드가 사용하는 변수명 보존 (alias). renderColumns 가 사실상의 source-of-truth. + const columns = renderColumns; + const tableOptionsColumns = useMemo(() => { + const optionsByKey = new Map(); + for (const o of columnOptionsState) { + if (o && typeof o.column_name === "string") optionsByKey.set(o.column_name, o); + } + const renderOrderByKey = new Map(renderColumns.map((c, idx) => [c.key, idx])); + const sourceIndexByKey = new Map(sourceColumns.map((c, idx) => [c.key, idx])); + const orderedSourceColumns = [...sourceColumns].sort((a, b) => { + const ar = renderOrderByKey.get(a.key); + const br = renderOrderByKey.get(b.key); + if (typeof ar === "number" && typeof br === "number") return ar - br; + if (typeof ar === "number") return -1; + if (typeof br === "number") return 1; + return (sourceIndexByKey.get(a.key) ?? 0) - (sourceIndexByKey.get(b.key) ?? 0); + }); + return orderedSourceColumns.map((c) => { + const option = optionsByKey.get(c.key); + const runtimeVisible = + option?.visible ?? (c.visible !== false && c.hidden !== true); + return { + column_name: c.key, + column_label: c.label, + input_type: + (typeof c.inputType === "string" && c.inputType) || + (typeof c.format === "string" ? c.format : "text"), + visible: runtimeVisible, + width: + typeof option?.width === "number" + ? option.width + : typeof c.width === "number" + ? c.width + : 0, + sortable: c.sortable ?? true, + }; + }); + }, [sourceColumns, renderColumns, columnOptionsState]); const showHeader = componentConfig.showHeader !== false; const showFooter = componentConfig.showFooter !== false; const showCheckbox = componentConfig.showCheckbox ?? false; - const striped = componentConfig.striped ?? true; - const hoverable = componentConfig.hoverable ?? true; + const stickyHeaderEnabled = componentConfig.stickyHeader !== false; + const autoWidth = !!componentConfig.autoWidth; + const horizontalScrollEnabled = !!componentConfig.horizontalScroll?.enabled; + const _hMinCol = componentConfig.horizontalScroll?.minColumnWidth ?? 120; + const _hMaxCol = componentConfig.horizontalScroll?.maxColumnWidth; + const _hMaxVisible = componentConfig.horizontalScroll?.maxVisibleColumns; + // Phase D.1 — 컬럼 effective width 계산 (sticky offset / minWidth 양쪽에서 사용). + // autoWidth 가 true 면 명시 width 가 없는 컬럼은 0 으로 취급하지 않고 fallback minColumnWidth 사용 — + // sticky offset 수치 안전성 위해. 렌더 단계에서는 width 미지정 시 width 속성을 빼는 식으로 자동 너비 허용. + const getColumnWidth = useCallback( + (col: TableColumn): number => { + let w = typeof col.width === "number" && col.width > 0 ? col.width : _hMinCol; + if (typeof _hMaxCol === "number" && _hMaxCol > 0) w = Math.min(w, _hMaxCol); + return w; + }, + [_hMinCol, _hMaxCol], + ); + // sticky offset 누적 — 각 fixed 컬럼의 좌/우 px offset. + const stickyOffsets = useMemo(() => { + const result = new Map(); + const left = renderColumns.filter((c) => c.fixed === "left"); + const right = renderColumns.filter((c) => c.fixed === "right"); + let accLeft = showCheckbox ? 32 : 0; + for (const c of left) { + result.set(c.key, { left: accLeft }); + accLeft += getColumnWidth(c); + } + let accRight = 0; + for (let i = right.length - 1; i >= 0; i -= 1) { + const c = right[i]; + result.set(c.key, { right: accRight }); + accRight += getColumnWidth(c); + } + return result; + }, [renderColumns, getColumnWidth, showCheckbox]); + // horizontalScroll.enabled 시 minWidth — 가독성 확보용 임계값. + const tableMinWidthPx = useMemo(() => { + if (!horizontalScrollEnabled) return undefined; + const checkboxW = showCheckbox ? 32 : 0; + const widthSum = renderColumns.reduce((acc, c) => acc + getColumnWidth(c), 0); + if (typeof _hMaxVisible === "number" && _hMaxVisible > 0) { + return Math.max(widthSum + checkboxW, _hMaxVisible * _hMinCol + checkboxW); + } + return widthSum + checkboxW; + }, [ + horizontalScrollEnabled, + renderColumns, + getColumnWidth, + showCheckbox, + _hMaxVisible, + _hMinCol, + ]); + // Phase C.5 — `tableStyle.alternateRows` / `tableStyle.hoverEffect` 가 우선. root `striped` / + // `hoverable` 은 legacy alias (ConfigPanel UI 가 양쪽 키를 동시에 set 하므로 둘 중 어디서 와도 OK). + // Phase D.6 — `tableStyle.theme === "striped"` 도 striping 활성화 source 로 인식. + const striped = + componentConfig.tableStyle?.alternateRows ?? + (componentConfig.tableStyle?.theme === "striped" ? true : undefined) ?? + componentConfig.striped ?? + true; + const hoverable = + componentConfig.tableStyle?.hoverEffect ?? componentConfig.hoverable ?? true; + // Phase D.6 — headerStyle 별 background / text color + const _headerBg = (() => { + const hs = componentConfig.tableStyle?.headerStyle; + if (hs === "dark") return "hsl(var(--foreground))"; + if (hs === "light") return "hsl(var(--card))"; + return "hsl(var(--muted))"; + })(); + const _headerColor = (() => { + const hs = componentConfig.tableStyle?.headerStyle; + if (hs === "dark") return "hsl(var(--background))"; + return "hsl(var(--foreground))"; + })(); const rowHeight = ROW_HEIGHT_PRESETS[componentConfig.rowHeight ?? "normal"]; const showToolbar = componentConfig.showToolbar ?? true; const emptyMessage = componentConfig.emptyMessage ?? "데이터가 없습니다"; + // Phase C.5 — toolbar.showRefresh / toolbar.showExcel 가 우선. root showRefresh / showExcel 은 + // legacy alias. renderToolbar 안에서 이 변수를 사용한다. + const _showRefreshBtn = + componentConfig.toolbar?.showRefresh ?? componentConfig.showRefresh; + const _showExcelBtn = + componentConfig.toolbar?.showExcel ?? componentConfig.showExcel; + // Phase D.6 — 추가 toolbar 버튼 visibility + const _showEditModeBtn = componentConfig.toolbar?.showEditMode === true; + const _showPdfBtn = componentConfig.toolbar?.showPdf === true; + const _showCopyBtn = componentConfig.toolbar?.showCopy === true; + const _showSearchBtn = componentConfig.toolbar?.showSearch === true; + const _showFilterBtn = componentConfig.toolbar?.showFilter === true; + const _showPaginationRefreshBtn = + componentConfig.toolbar?.showPaginationRefresh === true; // ─── 외부 검색 파라미터 (Search 컴포넌트가 onSearch 로 업데이트) ─── const externalSearch = @@ -230,14 +657,139 @@ export const TableComponent: React.FC = ({ ? ((props as any).searchParams as Record) : undefined; + // ─── Phase D.2 (2026-05-20) — effectiveSearch / dataFilter / excludeFilter param ─── + // effectiveSearch 우선순위 (낮 → 높): externalSearch → tableOptions → linkedFilter → applied. + // sanitize 로 empty / __ALL__ / 빈 배열 / 빈 객체 제거. + const tableOptionsFiltersMerged = useMemo>(() => { + const out: Record = {}; + for (const f of tableOptionsFilters) { + if (f && typeof f.column_name === "string" && f.value !== undefined) { + out[f.column_name] = f.value; + } + } + return out; + }, [tableOptionsFilters]); + + // Phase D.9 — 우측 분할 패널에서는 SplitPanelContext.getLinkedFilterValues() 의 값들을 + // ScreenContext linkedFilters 와 동일 layer 로 머지. 빈 값은 _sanitize 가 제거하므로 + // searchApplied 의 의미 있는 값을 덮어쓰지 않는다. + const splitLinkedFilterValues = useMemo>(() => { + if (currentSplitPosition !== "right" || !splitPanelContext) return {}; + try { + const v = splitPanelContext.getLinkedFilterValues?.(); + return v && typeof v === "object" ? v : {}; + } catch { + return {}; + } + }, [currentSplitPosition, splitPanelContext]); + + const effectiveSearch = useMemo>(() => { + const merged: Record = { + ..._sanitizeSearchValues(externalSearch || {}), + ..._sanitizeSearchValues(tableOptionsFiltersMerged), + ..._sanitizeSearchValues(linkedFilterValues), + ..._sanitizeSearchValues(splitLinkedFilterValues), + ..._sanitizeSearchValues(searchApplied), + }; + return _sanitizeSearchValues(merged); + }, [ + externalSearch, + tableOptionsFiltersMerged, + linkedFilterValues, + splitLinkedFilterValues, + searchApplied, + ]); + + // dataFilter (enabled 만 전달) + const dataFilterParam = useMemo(() => { + const df = componentConfig.dataFilter; + if (!df || !df.enabled) return undefined; + return df; + }, [componentConfig.dataFilter]); + + // excludeFilter (filterValue 4-source resolve: props.formData → screenContext.form_data → URL → parent) + const _propsFormData = (props as any)?.formData; + const _splitPanelParentData = (props as any)?.splitPanelParentData; + const _parentData = (props as any)?.parentData; + const _selectedParentData = (props as any)?.selectedParentData; + const excludeFilterParam = useMemo(() => { + const ef = componentConfig.excludeFilter; + if (!ef || !ef.enabled) return undefined; + let filterValue: any = undefined; + if (ef.filterColumn) { + const field = ef.filterValueField || ef.filterColumn; + const fromProps = _propsFormData && typeof _propsFormData === "object" ? _propsFormData[field] : undefined; + const fromCtx = screenContext?.form_data?.[field]; + const parentCandidates = [ + _splitPanelParentData && typeof _splitPanelParentData === "object" ? _splitPanelParentData[field] : undefined, + _parentData && typeof _parentData === "object" ? _parentData[field] : undefined, + _selectedParentData && typeof _selectedParentData === "object" ? _selectedParentData[field] : undefined, + ]; + const fromParent = parentCandidates.find(_isMeaningfulSearchValue); + let fromUrl: any = undefined; + if (typeof window !== "undefined") { + try { + const url = new URLSearchParams(window.location.search); + const v = url.get(field); + if (v !== null) fromUrl = v; + } catch { + /* noop */ + } + } + // priority: props.formData → screenContext.form_data → URL → parent + if (_isMeaningfulSearchValue(fromProps)) { + filterValue = fromProps; + } else if (_isMeaningfulSearchValue(fromCtx)) { + filterValue = fromCtx; + } else if (_isMeaningfulSearchValue(fromUrl)) { + filterValue = fromUrl; + } else if (_isMeaningfulSearchValue(fromParent)) { + filterValue = fromParent; + } + } + if (ef.filterColumn && !_isMeaningfulSearchValue(filterValue)) { + return undefined; + } + return { + enabled: true, + referenceTable: ef.referenceTable, + referenceColumn: ef.referenceColumn, + sourceColumn: ef.sourceColumn, + filterColumn: ef.filterColumn, + filterValue, + }; + }, [ + componentConfig.excludeFilter, + _propsFormData, + screenContext?.form_data, + _splitPanelParentData, + _parentData, + _selectedParentData, + ]); + // ─── 데이터 fetch ─── + // autoLoad === false 면 초기 fetch 를 보류. 디자인 모드에서는 미리보기를 위해 autoLoad 를 무시한다. + // 운영 모드의 refreshTrigger / 수동 refresh wiring 은 Phase D.6 에서 `useTableData.refresh()` 와 연결. + const _autoLoadEnabled = isDesignMode || componentConfig.autoLoad !== false; + // 디자인 모드에서는 검색/필터 모두 skip (미리보기는 원본). + const _activeSearch = isDesignMode + ? undefined + : Object.keys(effectiveSearch).length > 0 + ? effectiveSearch + : undefined; const tableData = useTableData({ tableName, - enabled: !!tableName, + enabled: !!tableName && _autoLoadEnabled, pageSize: isDesignMode ? Math.min(componentConfig.pagination?.pageSize ?? DESIGN_PREVIEW_ROWS, DESIGN_PREVIEW_ROWS) : componentConfig.pagination?.pageSize ?? 20, - search: isDesignMode ? undefined : externalSearch, + search: _activeSearch, + // Phase C.5 — defaultSort 가 있으면 초기 정렬로 적용. 사용자 헤더 클릭이 즉시 덮어쓴다. + sortBy: componentConfig.defaultSort?.columnName, + sortOrder: componentConfig.defaultSort?.direction, + // Phase D.2 — runtime 필터 전달 + dataFilter: isDesignMode ? undefined : dataFilterParam, + excludeFilter: isDesignMode ? undefined : excludeFilterParam, }); // ─── 렌더할 데이터 결정 ─── @@ -247,10 +799,875 @@ export const TableComponent: React.FC = ({ : (columns.length > 0 ? [{}, {}, {}] : [])) : tableData.data; + // ─── TableOptionsContext / tableDisplayStore 연동 (Phase B.1 + B.4, 2026-05-20) ─── + // TableOptions UI 가 기대하는 contract 로 사용자 옵션 UI 에 등록. + // Provider 없는 화면 (대시보드 카드 등) 도 mount 가능해야 하므로 optional hook 사용. + // 본 단계는 placeholder — 풀 옵션 (filter/group/visibility callback) 은 Phase D.1/D.2/D.8 에서 완성. + const _tableOptions = useTableOptionsOptional(); + const _registerTable = _tableOptions?.registerTable; + const _unregisterTable = _tableOptions?.unregisterTable; + const _updateTableDataCount = _tableOptions?.updateTableDataCount; + const _componentId = + typeof (component as any)?.id === "string" || typeof (component as any)?.id === "number" + ? String((component as any).id) + : undefined; + const _canonicalTableId = _componentId ? `table-${_componentId}` : undefined; + // Phase D.1 — source / render 두 갈래로 stable string. source 는 register dep (변동시 재등록), + // render 는 store sync dep (visible_columns 갱신). + const _sourceKeysJoined = sourceColumns.map((c) => c.key).join("|"); + const _renderKeysJoined = renderColumns.map((c) => c.key).join("|"); + const _tableOptionsColumnsJoined = tableOptionsColumns + .map((c) => [ + c.column_name, + c.column_label, + c.input_type, + c.visible ? "1" : "0", + c.width, + c.sortable === false ? "0" : "1", + ].join(":")) + .join("|"); + + // (1) TableOptions register / unregister + useEffect(() => { + if ( + isDesignMode || + !_registerTable || + !_unregisterTable || + !_canonicalTableId || + !tableName || + sourceColumns.length === 0 + ) { + return; + } + const registration: TableRegistration = { + table_id: _canonicalTableId, + label: + (typeof (component as any)?.label === "string" && (component as any).label) || + (tableName as string), + table_name: tableName as string, + frozen_column_count: frozenColumnCount, + // 모든 source columns 를 등록 — TableOptions UI 가 source 전체에 대해 + // visibility/width/order/frozen 토글 가능. visible/width 는 runtime overlay 를 반영해 + // TableOptions UI 를 다시 열어도 직전 상태가 유지되게 한다. + columns: tableOptionsColumns, + // Phase D.2 — TableOptions UI 의 검색 필터 (TableFilter[]) 변경 시 state 갱신. + // effectiveSearch useMemo 가 tableOptionsFilters → column_name=value 매핑 후 머지. + onFilterChange: (filters) => { + setTableOptionsFilters(Array.isArray(filters) ? filters : []); + }, + // Phase D.8 — TableOptions UI 의 grouping 컬럼 list. 빈 배열이면 grouping 해제. + onGroupChange: (groups) => { + setTableOptionsGroups(Array.isArray(groups) ? groups : []); + }, + // Phase D.8 — group-sum 설정. enabled=false 면 null 로 정규화 (GroupedView 가 summary skip). + onGroupSumChange: (cfg) => { + setGroupSumConfig(cfg && cfg.enabled ? cfg : null); + }, + // Phase D.1 — TableOptions UI 의 컬럼 visibility/width/order/fixed 토글 결과를 runtime state 로 + onColumnVisibilityChange: (updated) => { + setColumnOptionsState(Array.isArray(updated) ? updated : []); + }, + // Phase D.1 — TableOptions UI 의 frozen left 컬럼 수 변경 + onFrozenColumnCountChange: (count, updatedColumns) => { + const nextCount = typeof count === "number" && count >= 0 ? Math.floor(count) : 0; + setFrozenColumnCount(nextCount); + if (Array.isArray(updatedColumns) && updatedColumns.length > 0) { + // visibility 도 같이 갱신 (UI 가 frozen 변경 시 visible 묶음을 함께 보낼 수 있음) + setColumnOptionsState((prev) => { + const byKey = new Map(); + for (const o of prev) { + if (o && typeof o.column_name === "string") byKey.set(o.column_name, o); + } + for (const u of updatedColumns) { + const cur = byKey.get(u.column_name); + if (cur) { + byKey.set(u.column_name, { ...cur, visible: !!u.visible }); + } else { + byKey.set(u.column_name, { + column_name: u.column_name, + visible: !!u.visible, + }); + } + } + return Array.from(byKey.values()); + }); + } + }, + }; + _registerTable(registration); + return () => { + _unregisterTable(_canonicalTableId); + }; + // columns / component.label 객체 변동을 안정 deps 로 (key 문자열) 추적 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + _registerTable, + _unregisterTable, + _canonicalTableId, + tableName, + _sourceKeysJoined, + _tableOptionsColumnsJoined, + frozenColumnCount, + isDesignMode, + ]); + + // (2) 데이터 건수 동기화 + useEffect(() => { + if (isDesignMode || !_updateTableDataCount || !_canonicalTableId) return; + _updateTableDataCount(_canonicalTableId, tableData.total); + }, [_updateTableDataCount, _canonicalTableId, tableData.total, isDesignMode]); + + // (3) tableDisplayStore 동기화 — canonical key (`table-${id}`). + // 외부 consumer (Excel/Copy 등) 는 `tableDisplayStore.getTableDataForComponent(id)` 로 + // 안전 조회. + useEffect(() => { + if (isDesignMode || !_componentId) return; + // Phase D.1 — column_order / visible_columns 는 runtime renderColumns 기준 (정렬 / fixed / overlay + // 적용 후). column_labels 는 source 전체에서 가져와 외부 consumer (Excel/Copy) 가 숨겨진 + // 컬럼의 라벨도 필요할 때 쓸 수 있게. + tableDisplayStore.setTableDataForComponent( + _componentId, + rows, + renderColumns.map((c) => c.key), + tableData.sortBy ?? null, + tableData.sortOrder, + { + total_items: tableData.total, + current_page: tableData.page, + page_size: componentConfig.pagination?.pageSize ?? rows.length, + // Phase D.5 — 다국어 라벨 적용. langKey 가 있으면 번역, 없으면 원본 label. + column_labels: columnLabels, + visible_columns: renderColumns.map((c) => c.key), + }, + ); + return () => { + tableDisplayStore.clearTableDataForComponent(_componentId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + _componentId, + rows, + _sourceKeysJoined, + _renderKeysJoined, + _columnLabelsJoined, + tableData.sortBy, + tableData.sortOrder, + tableData.total, + tableData.page, + isDesignMode, + ]); + + // (4) refreshInterval 주기 새로고침 (Phase C.5) + // 디자인 모드에서는 skip. interval 이 양수일 때만 setInterval 등록. + useEffect(() => { + if (isDesignMode) return; + const interval = componentConfig.refreshInterval; + if (typeof interval !== "number" || interval <= 0) return; + const timer = setInterval(() => { + tableData.refresh(); + }, interval * 1000); + return () => clearInterval(timer); + }, [isDesignMode, componentConfig.refreshInterval, tableData.refresh]); + + // (5) linkedFilters polling (Phase D.2) — source 컴포넌트의 selectedData 에서 값 추출. + // event bus 가 아직 없으므로 500ms polling 으로 동기화. + // event bus 가 없으니 가벼운 polling 만. ScreenContext 가 없으면 skip. + useEffect(() => { + if (isDesignMode) return; + const lf = componentConfig.linkedFilters; + if (!Array.isArray(lf) || lf.length === 0) return; + if (!screenContext || typeof screenContext.getDataProvider !== "function") return; + + const compute = () => { + const next: Record = {}; + for (const f of lf) { + if (!f || f.enabled === false) continue; + if (!f.sourceComponentId || !f.targetColumn) continue; + const provider = screenContext.getDataProvider(f.sourceComponentId); + if (!provider || typeof (provider as any).getSelectedData !== "function") continue; + const selectedData = (provider as any).getSelectedData(); + const sel = Array.isArray(selectedData) ? selectedData[0] : selectedData; + if (!sel || typeof sel !== "object") continue; + const field = f.sourceField || "value"; + const v = (sel as any)[field]; + if (v !== undefined && v !== null && v !== "") { + // operator 가 equals 외인 경우에도 legacy 동작 (plain value) 유지. + // 백엔드 search contract 가 operator 다원화 미지원이라 D.2 에선 plain 만. + next[f.targetColumn] = v; + } + } + // 변동 없으면 setState skip — re-render 폭주 방지 + setLinkedFilterValues((prev) => { + const aKeys = Object.keys(prev); + const bKeys = Object.keys(next); + if (aKeys.length !== bKeys.length) return next; + for (const k of aKeys) { + if (prev[k] !== next[k]) return next; + } + return prev; + }); + }; + + compute(); + const timer = setInterval(compute, 500); + return () => clearInterval(timer); + }, [isDesignMode, componentConfig.linkedFilters, screenContext]); + // ─── 행 선택 ─── const [selectedRowIdx, setSelectedRowIdx] = useState(null); const [selectedRows, setSelectedRows] = useState>(new Set()); + // ─── Phase D.3 (2026-05-20) — 인라인 편집 state ─── + // editingCell: 현재 편집 중인 셀 위치 (rowIndex, columnKey) + 원본 값. + // editingValue: 입력 중인 draft 문자열. commit 시 column inputType 따라 정규화. + // editInputRef: input/select/date 의 ref — autoFocus 보강용. + // savingCellKey / editError: 저장 중 / 실패 상태. + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + columnKey: string; + originalValue: any; + } | null>(null); + const [editingValue, setEditingValue] = useState(""); + const [savingCellKey, setSavingCellKey] = useState(null); + const [editError, setEditError] = useState(null); + const editInputRef = useRef(null); + const editingValueRef = useRef(""); + const committingEditRef = useRef(false); + + const setEditingDraft = useCallback((value: string) => { + editingValueRef.current = value; + setEditingValue(value); + }, []); + + // 편집 진입 가능 조건. checkbox / 내부 컬럼 ("__" prefix) 은 제외. + // Phase D.6 — `toolbar.showEditMode === true` 일 때는 `editModeEnabled` 가 ON 인 경우에만 편집 진입. + // 그 외 (옛 layout, showEditMode 미설정) 는 D.3 동작 그대로 — 더블클릭이 바로 편집 진입. + const canEditCell = useCallback( + (col: TableColumn): boolean => { + if (isDesignMode) return false; + if (componentConfig.isReadOnly) return false; + if (col.editable === false) return false; + if (!col.key || col.key.startsWith("__")) return false; + if (col.inputType === "checkbox" || col.inputType === "boolean") return false; + if (!tableName) return false; + if (_showEditModeBtn && !editModeEnabled) return false; + return true; + }, + [isDesignMode, componentConfig.isReadOnly, tableName, _showEditModeBtn, editModeEnabled], + ); + + const cancelEdit = useCallback(() => { + setEditingCell(null); + setEditingDraft(""); + setEditError(null); + setSavingCellKey(null); + committingEditRef.current = false; + }, [setEditingDraft]); + + // 편집 시작 — 더블클릭에서 호출. 원본 값 보존 + draft 초기화. + const startEdit = useCallback( + (rowIndex: number, col: TableColumn, row: Record) => { + if (!canEditCell(col)) return; + if (!row) return; + const original = row[col.key]; + const draft = original === null || original === undefined ? "" : String(original); + setEditingCell({ rowIndex, columnKey: col.key, originalValue: original }); + setEditingDraft(draft); + setEditError(null); + }, + [canEditCell, setEditingDraft], + ); + + // primaryKey 결정: componentConfig.primaryKey → "id" → `${tableName}_id` → fallback "id" + const resolveKeyField = useCallback( + (row: Record): string => { + const pk = (componentConfig as any)?.primaryKey; + if (typeof pk === "string" && pk.length > 0) return pk; + if (row && Object.prototype.hasOwnProperty.call(row, "id")) return "id"; + if (tableName) { + const cand = `${tableName}_id`; + if (row && Object.prototype.hasOwnProperty.call(row, cand)) return cand; + } + return "id"; + }, + [componentConfig, tableName], + ); + + // 편집 commit — Enter / blur 에서 호출. 값 변경 없으면 close-only. + // legacy 본체와 동일 endpoint (PUT /dynamic-form/update-field). + const commitEdit = useCallback(async () => { + if (!editingCell) return; + if (committingEditRef.current) return; + const { rowIndex, columnKey, originalValue } = editingCell; + const row = rows[rowIndex]; + if (!row || !tableName) { + cancelEdit(); + return; + } + + // 값 정규화 + const col = sourceColumns.find((c) => c.key === columnKey); + const isNumber = + !!col && + (col.inputType === "number" || + col.inputType === "decimal" || + col.format === "number"); + const draftValue = editingValueRef.current; + let updateValue: any; + if (draftValue === "" || draftValue === null || draftValue === undefined) { + updateValue = null; + } else if (isNumber) { + const n = Number(draftValue); + updateValue = Number.isFinite(n) ? n : draftValue; + } else { + updateValue = draftValue; + } + + // 변경 없음 → close-only + const origStr = + originalValue === null || originalValue === undefined ? "" : String(originalValue); + const newStr = updateValue === null || updateValue === undefined ? "" : String(updateValue); + if (origStr === newStr) { + cancelEdit(); + return; + } + + const keyField = resolveKeyField(row); + const keyValue = row[keyField]; + if (keyValue === undefined || keyValue === null || keyValue === "") { + console.warn( + "[TableComponent] inline edit skip — keyField has no value", + { keyField, columnKey, row }, + ); + cancelEdit(); + return; + } + + committingEditRef.current = true; + setSavingCellKey(`${rowIndex}-${columnKey}`); + setEditError(null); + try { + const { apiClient } = await import("@/lib/api/client"); + await apiClient.put("/dynamic-form/update-field", { + tableName, + keyField, + keyValue, + updateField: columnKey, + updateValue, + }); + setEditingCell(null); + setEditingDraft(""); + setSavingCellKey(null); + committingEditRef.current = false; + tableData.refresh(); + } catch (err: any) { + // 편집 모드 유지 — 사용자가 재시도하거나 Escape 가능 + console.error("[TableComponent] inline edit save 실패:", err); + setEditError(err?.message || "저장 실패"); + setSavingCellKey(null); + committingEditRef.current = false; + } + }, [ + editingCell, + rows, + tableName, + sourceColumns, + cancelEdit, + resolveKeyField, + setEditingDraft, + tableData, + ]); + + // Enter / Escape 키 처리 + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + commitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + cancelEdit(); + } + }, + [commitEdit, cancelEdit], + ); + + // editingCell 변경 시 input autoFocus 보강 (autoFocus 속성도 사용하지만 ref-based 가 더 신뢰) + useEffect(() => { + if (!editingCell) return; + const el = editInputRef.current; + if (!el) return; + try { + el.focus?.(); + if (typeof el.select === "function") el.select(); + } catch { + /* noop */ + } + }, [editingCell]); + + // ─── Phase D.4 (2026-05-20) — 행/일괄 액션 runtime ─── + // rowActions: showActions === true + actions 배열 ≥1 의 유효한 (type 있는) action 들. + const rowActions = useMemo(() => { + const ac = componentConfig.actions; + if (!ac || ac.showActions !== true) return []; + if (!Array.isArray(ac.actions)) return []; + return ac.actions.filter( + (a) => a && typeof a.type === "string" && a.type.length > 0, + ); + }, [componentConfig.actions]); + + // bulkActionNames: bulkActions === true + bulkActionList ≥1 의 정규화된 (trim + non-empty) 이름. + const bulkActionNames = useMemo(() => { + const ac = componentConfig.actions; + if (!ac || ac.bulkActions !== true) return []; + if (!Array.isArray(ac.bulkActionList)) return []; + return ac.bulkActionList + .map((s) => (typeof s === "string" ? s.trim() : "")) + .filter((s) => s.length > 0); + }, [componentConfig.actions]); + + // 행 액션 실행: confirm → 외부 callbacks → built-in fallback. + // built-in: view/edit → openScreenModal CustomEvent 또는 URL navigate / delete → API + // custom → tableRowAction CustomEvent + const handleRowAction = useCallback( + async ( + action: typeof rowActions[number], + row: Record, + rowIndex: number, + ) => { + if (!action || !row) return; + if (action.confirmMessage) { + const ok = + typeof window !== "undefined" && typeof window.confirm === "function" + ? window.confirm(action.confirmMessage) + : true; + if (!ok) return; + } + + const keyField = resolveKeyField(row); + const keyValue = row[keyField]; + const context = { + tableName, + rowIndex, + keyField, + keyValue, + componentId: _componentId, + }; + + // 외부 callbacks 호출 (props 우선) — built-in 보다 먼저. + const onRowActionCb = (props as any)?.onRowAction; + const onTableActionCb = (props as any)?.onTableAction; + const onActionCb = (props as any)?.onAction; + if (typeof onRowActionCb === "function") { + try { + onRowActionCb(action, row, context); + } catch (e) { + console.error("[TableComponent] onRowAction 실패:", e); + } + } + if (typeof onTableActionCb === "function") { + try { + onTableActionCb(action, row, context); + } catch (e) { + console.error("[TableComponent] onTableAction 실패:", e); + } + } + if (typeof onActionCb === "function") { + try { + onActionCb(action.type, row, context); + } catch (e) { + console.error("[TableComponent] onAction 실패:", e); + } + } + + // built-in fallback + switch (action.type) { + case "view": + case "edit": { + const target = action.targetScreen; + if (typeof target === "string" && target.length > 0) { + if (/^\d+$/.test(target.trim())) { + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: Number(target.trim()), + urlParams: { + mode: action.type, + editId: keyValue, + tableName, + primaryKeyColumn: keyField, + }, + }, + }), + ); + } + } else if (target.startsWith("/")) { + if (typeof window !== "undefined") { + window.location.assign(target); + } + } else { + console.info( + `[TableComponent] action.targetScreen ("${target}") 의 형식이 numeric/URL 모두 아니라 navigation 보류`, + ); + } + } + break; + } + case "delete": { + if ( + keyValue === undefined || + keyValue === null || + keyValue === "" || + !tableName + ) { + console.warn( + "[TableComponent] delete skip — keyValue 또는 tableName 없음", + { keyField, keyValue, tableName }, + ); + break; + } + const confirmMsg = action.confirmMessage || "이 행을 삭제하시겠습니까?"; + // confirmMessage 가 있으면 위에서 이미 confirm 했음. 없으면 default confirm. + if (!action.confirmMessage) { + const ok = + typeof window !== "undefined" && typeof window.confirm === "function" + ? window.confirm(confirmMsg) + : true; + if (!ok) break; + } + try { + const { apiClient } = await import("@/lib/api/client"); + await apiClient.delete( + `/table-management/tables/${tableName}/delete`, + { data: { ids: [String(keyValue)] } }, + ); + // 삭제된 행의 selection 제거 + setSelectedRows((prev) => { + const next = new Set(prev); + next.delete(rowIndex); + return next; + }); + setSelectedRowIdx((prev) => (prev === rowIndex ? null : prev)); + tableData.refresh(); + } catch (e) { + console.error("[TableComponent] delete API 실패:", e); + } + break; + } + case "custom": + default: { + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("tableRowAction", { + detail: { action, row, context }, + }), + ); + } + break; + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [resolveKeyField, tableName, _componentId, tableData, props], + ); + + // 일괄 액션 실행 — delete 만 built-in (모든 선택 행 한 번에 delete API), 나머지는 external + dispatch + const handleBulkAction = useCallback( + async (actionName: string) => { + const selectedIndexes = Array.from(selectedRows).sort((a, b) => a - b); + const selectedRowsData = selectedIndexes + .map((i) => rows[i]) + .filter((r): r is Record => !!r); + if (selectedRowsData.length === 0) return; + + const context = { + tableName, + componentId: _componentId, + selectedIndexes, + }; + + // 외부 callback 우선 + const onBulkActionCb = (props as any)?.onBulkAction; + if (typeof onBulkActionCb === "function") { + try { + onBulkActionCb(actionName, selectedRowsData, context); + } catch (e) { + console.error("[TableComponent] onBulkAction 실패:", e); + } + } + + // built-in fallback + if (actionName === "delete") { + if (!tableName) return; + const ok = + typeof window !== "undefined" && typeof window.confirm === "function" + ? window.confirm( + `선택한 ${selectedRowsData.length}개 행을 삭제하시겠습니까?`, + ) + : true; + if (!ok) return; + const ids: string[] = []; + for (const row of selectedRowsData) { + const kf = resolveKeyField(row); + const kv = row[kf]; + if (kv !== undefined && kv !== null && kv !== "") { + ids.push(String(kv)); + } + } + if (ids.length === 0) { + console.warn("[TableComponent] bulk delete — 유효한 keyValue 0건"); + return; + } + try { + const { apiClient } = await import("@/lib/api/client"); + await apiClient.delete( + `/table-management/tables/${tableName}/delete`, + { data: { ids } }, + ); + setSelectedRows(new Set()); + setSelectedRowIdx(null); + tableData.refresh(); + } catch (e) { + console.error("[TableComponent] bulk delete 실패:", e); + } + return; + } + + // export / copy / unknown → dispatch + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("tableBulkAction", { + detail: { actionName, selectedRowsData, context }, + }), + ); + } + }, + [selectedRows, rows, tableName, _componentId, resolveKeyField, tableData, props], + ); + + // 액션 type 별 lucide 아이콘 매핑 (configured `icon` 문자열이 unknown 이면 무시 + label 표시) + const getActionIcon = (type: string) => { + switch (type) { + case "view": + return Eye; + case "edit": + return Pencil; + case "delete": + return Trash2; + case "custom": + default: + return MoreHorizontal; + } + }; + + // 카드 모드에서 onView/onEdit/onDelete 매핑 — actions 의 첫 번째 매칭 action 을 callback 으로. + const cardOnView = useMemo(() => { + const a = rowActions.find((x) => x.type === "view"); + return a ? (row: any) => handleRowAction(a, row, rows.indexOf(row)) : undefined; + }, [rowActions, handleRowAction, rows]); + const cardOnEdit = useMemo(() => { + const a = rowActions.find((x) => x.type === "edit"); + return a ? (row: any) => handleRowAction(a, row, rows.indexOf(row)) : undefined; + }, [rowActions, handleRowAction, rows]); + const cardOnDelete = useMemo(() => { + const a = rowActions.find((x) => x.type === "delete"); + return a ? (row: any) => handleRowAction(a, row, rows.indexOf(row)) : undefined; + }, [rowActions, handleRowAction, rows]); + + // ─── Phase D.6 (2026-05-20) — Excel / Copy / PDF / Paste / PageSize 핸들러 ─── + + // export 대상 row: selected 있으면 selected (visible 기준), 없으면 전체 visible rows + const getExportTargetRows = useCallback((): Record[] => { + if (selectedRows.size > 0) { + return Array.from(selectedRows) + .sort((a, b) => a - b) + .map((i) => rows[i]) + .filter((r): r is Record => !!r); + } + return rows; + }, [selectedRows, rows]); + + // 셀의 raw text — React node 가 아니라 export 가능한 문자열. + // entity 조인 / 특수 셀은 raw row[col.key] 만. 별도 helper 가 필요하면 D.6 후속 phase. + const getCellRawText = useCallback( + (col: TableColumn, row: Record): string => { + const v = row[col.key]; + if (v === null || v === undefined) return ""; + if (typeof v === "object") { + try { + return JSON.stringify(v); + } catch { + return String(v); + } + } + return String(v); + }, + [], + ); + + // 4-1) Excel 내보내기 (XLSX dynamic import) + const handleExportExcel = useCallback(async () => { + const targetRows = getExportTargetRows(); + if (targetRows.length === 0) { + console.info("[TableComponent] Excel export skip — 대상 row 0건"); + return; + } + try { + const XLSX = await import("xlsx"); + const header = renderColumns.map((c) => getColumnLabel(c)); + const data = targetRows.map((row) => + renderColumns.map((c) => getCellRawText(c, row)), + ); + const aoa: any[][] = [header, ...data]; + const ws = XLSX.utils.aoa_to_sheet(aoa); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, "0"); + const dd = String(today.getDate()).padStart(2, "0"); + const filename = `${tableName || "table"}_${yyyy}-${mm}-${dd}.xlsx`; + XLSX.writeFile(wb, filename); + } catch (e) { + console.error("[TableComponent] Excel export 실패:", e); + } + }, [getExportTargetRows, renderColumns, getColumnLabel, getCellRawText, tableName]); + + // 4-2) 클립보드 복사 (TSV) + const handleCopyTable = useCallback(async () => { + const targetRows = getExportTargetRows(); + if (targetRows.length === 0) { + console.info("[TableComponent] copy skip — 대상 row 0건"); + return; + } + const sanitize = (s: string) => + s.replace(/\t/g, " ").replace(/\r?\n/g, " "); + const header = renderColumns.map((c) => sanitize(getColumnLabel(c))).join("\t"); + const dataLines = targetRows + .map((row) => + renderColumns.map((c) => sanitize(getCellRawText(c, row))).join("\t"), + ) + .join("\n"); + const tsv = `${header}\n${dataLines}`; + try { + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(tsv); + console.info("[TableComponent] 클립보드 복사 완료"); + return; + } + // fallback: hidden textarea + execCommand + if (typeof document !== "undefined") { + const ta = document.createElement("textarea"); + ta.value = tsv; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + console.info("[TableComponent] 클립보드 복사 완료 (fallback)"); + } + } catch (e) { + console.error("[TableComponent] 클립보드 복사 실패:", e); + } + }, [getExportTargetRows, renderColumns, getColumnLabel, getCellRawText]); + + // 4-3) PDF — conservative fallback: 이벤트 dispatch + 외부 핸들러 미존재 시 window.print() + const handleExportPdf = useCallback(() => { + if (typeof window === "undefined") return; + const targetRows = getExportTargetRows(); + const detail = { + componentId: _componentId, + tableName, + rows: targetRows, + columns: renderColumns.map((c) => ({ + key: c.key, + label: getColumnLabel(c), + })), + }; + let handled = false; + try { + // 외부 핸들러가 detail 의 properties 를 수정 (handled=true) 하면 print 건너뛰기 + const evt = new CustomEvent("tablePdfExport", { + detail: { ...detail, markHandled: () => { handled = true; } }, + cancelable: true, + }); + window.dispatchEvent(evt); + if (evt.defaultPrevented) handled = true; + } catch (e) { + console.error("[TableComponent] tablePdfExport dispatch 실패:", e); + } + if (!handled) { + try { + window.print(); + } catch (e) { + console.error("[TableComponent] window.print 실패:", e); + } + } + }, [_componentId, tableName, getExportTargetRows, renderColumns, getColumnLabel]); + + // 4-4) Paste — TSV/CSV-ish 파싱 → tablePaste CustomEvent dispatch (D.6 는 backend write X) + const handleTablePaste = useCallback( + (e: React.ClipboardEvent) => { + // 인라인 편집 중이면 input 이 처리하게 둠 — interference X + if (editingCell !== null) return; + const target = e.target as HTMLElement | null; + if (!target) return; + if ( + target.closest("input, textarea, select") || + target.isContentEditable || + target.closest("[contenteditable]") + ) { + return; + } + const focused = target.closest("td[data-row-idx][data-col-key]"); + if (!focused) return; + const text = e.clipboardData?.getData("text/plain") || ""; + if (!text) return; + // TSV / CSV 파싱 — tab 우선, 없으면 comma + const usesTab = text.includes("\t"); + const lines = text.split(/\r?\n/).filter((l) => l.length > 0); + const data: string[][] = lines.map((line) => + usesTab ? line.split("\t") : line.split(","), + ); + const startRowIndex = focused?.getAttribute("data-row-idx"); + const startColumnKey = focused?.getAttribute("data-col-key"); + e.preventDefault(); + try { + window.dispatchEvent( + new CustomEvent("tablePaste", { + detail: { + componentId: _componentId, + tableName, + startRowIndex: startRowIndex ? Number(startRowIndex) : null, + startColumnKey: startColumnKey || null, + data, + }, + }), + ); + } catch (err) { + console.error("[TableComponent] tablePaste dispatch 실패:", err); + } + }, + [_componentId, tableName, editingCell], + ); + + // 4-5) Footer pageSize 변경 + const handlePageSizeChange = useCallback( + (n: number) => { + if (!Number.isFinite(n) || n <= 0) return; + tableData.setPageSize(n); + }, + [tableData], + ); + const emitSelection = useCallback((nextSelectedRowIdx: number | null, nextSelectedRows: Set) => { const runtimeRows = tableData.data; const onRowSelect = @@ -275,7 +1692,210 @@ export const TableComponent: React.FC = ({ if (onSelectedRowsChange) { onSelectedRowsChange(Array.from(nextSelectedRows), selectedRowsData); } - }, [props, tableData.data]); + // Phase D.9 — 좌측 분할 패널의 선택 변경을 SplitPanel 의 selected_left_data 로 전파. + // disable_auto_data_transfer === true 면 버튼 클릭 시에만 전달 — auto 전달 skip. + if ( + splitPanelContext && + currentSplitPosition === "left" && + !Boolean( + (splitPanelContext as any).disable_auto_data_transfer ?? + (splitPanelContext as any).disableAutoDataTransfer, + ) + ) { + try { + splitPanelContext.setSelectedLeftData(selectedRow ?? null); + } catch (e) { + console.error("[TableComponent] setSelectedLeftData 실패:", e); + } + } + }, [props, tableData.data, splitPanelContext, currentSplitPosition]); + + // ─── Phase B.3 / D.9 (2026-05-20) — DataProvidable / DataReceivable 어댑터 ─── + // ScreenContext / SplitPanel 의 data-transfer 컨트랙트 expose. + // ref 가 매 렌더마다 신규면 register useEffect 가 폭주 → useMemo + 안정 dep. + + const dataProvider = useMemo(() => ({ + component_id: _componentId || "", + component_type: "table", + table_name: tableName, + getSelectedData: () => { + // selectedRows: Set (rows index 기준). 현재 runtime rows 매핑. + const runtime = tableData.data; + const indexes = Array.from(selectedRows).sort((a, b) => a - b); + return indexes + .map((i) => runtime[i]) + .filter((r): r is Record => !!r); + }, + getAllData: () => tableData.data, + clearSelection: () => { + setSelectedRows(new Set()); + setSelectedRowIdx(null); + // 외부 콜백도 일관 — selected 해제 emit + emitSelection(null, new Set()); + }, + getEntityJoinColumns: (): EntityJoinColumnMeta[] => { + const out: EntityJoinColumnMeta[] = []; + for (const c of sourceColumns) { + // additionalJoinInfo 가 정식 메타. 가능한 fields 매핑. + if (c.additionalJoinInfo) { + const aj = c.additionalJoinInfo; + if (aj.sourceColumn && aj.joinAlias && (aj.referenceTable || aj.sourceTable)) { + out.push({ + source_column: aj.sourceColumn, + join_alias: aj.joinAlias, + reference_table: aj.referenceTable || aj.sourceTable!, + source_table: aj.sourceTable, + }); + } + } else if (c.entityJoinInfo) { + // entityJoinInfo 만 있을 때 — reference_table 정보 없으면 sourceTable fallback. + const ej = c.entityJoinInfo; + if (ej.sourceColumn && ej.joinAlias) { + out.push({ + source_column: ej.sourceColumn, + join_alias: ej.joinAlias, + reference_table: ej.sourceTable || ej.joinAlias, + source_table: ej.sourceTable, + }); + } + } + } + return out; + }, + }), [ + _componentId, + tableName, + tableData.data, + selectedRows, + sourceColumns, + emitSelection, + ]); + + // mapping rules 적용 helper — source_field → target_field, default, required 누락 시 skip + const _applyMappingRules = useCallback( + (rows: any[], rules: DataReceiverConfig["mapping_rules"]): any[] => { + if (!Array.isArray(rules) || rules.length === 0) return rows; + return rows.map((row) => { + const out: Record = { ...row }; + for (const rule of rules) { + if (!rule || typeof rule.target_field !== "string") continue; + const src = typeof rule.source_field === "string" ? row?.[rule.source_field] : undefined; + if (src !== undefined && src !== null && src !== "") { + out[rule.target_field] = src; + } else if (rule.default_value !== undefined && rule.default_value !== null) { + out[rule.target_field] = rule.default_value; + } else if (rule.required) { + // 필수 누락 — 그 필드만 skip, row 자체는 유지 + continue; + } + } + return out; + }); + }, + [], + ); + + const dataReceiver = useMemo(() => ({ + component_id: _componentId || "", + component_type: "table" as DataReceivableComponentType, + getData: () => tableData.data, + receiveData: async (incoming: any[], config: DataReceiverConfig) => { + if (!Array.isArray(incoming)) return; + const mapped = _applyMappingRules(incoming, config?.mapping_rules); + const current = tableData.data; + const mode = config?.mode; + let next: any[]; + if (mode === "replace") { + next = mapped; + } else if (mode === "append") { + next = [...current, ...mapped]; + } else if (mode === "merge") { + // id → ${tableName}_id → fallback key 순서로 merge + const keyOf = (r: any): string | null => { + if (!r || typeof r !== "object") return null; + if (r.id !== undefined && r.id !== null) return String(r.id); + if (tableName) { + const k = `${tableName}_id`; + if (r[k] !== undefined && r[k] !== null) return String(r[k]); + } + return null; + }; + const indexByKey = new Map(); + const merged = [...current]; + merged.forEach((r, i) => { + const k = keyOf(r); + if (k !== null) indexByKey.set(k, i); + }); + for (const item of mapped) { + const k = keyOf(item); + if (k !== null && indexByKey.has(k)) { + const idx = indexByKey.get(k)!; + merged[idx] = { ...merged[idx], ...item }; + } else { + merged.push(item); + } + } + next = merged; + } else { + // unknown mode — 안전하게 replace 처리 + console.warn("[TableComponent] DataReceivable.receiveData unknown mode:", mode); + next = mapped.length > 0 ? mapped : current; + } + tableData.setLocalData(next, next.length); + }, + }), [_componentId, tableData.data, tableData.setLocalData, tableName, _applyMappingRules]); + + // ScreenContext register / unregister + useEffect(() => { + if (isDesignMode || !screenContext || !_componentId) return; + screenContext.registerDataProvider(_componentId, dataProvider); + screenContext.registerDataReceiver(_componentId, dataReceiver); + return () => { + screenContext.unregisterDataProvider(_componentId); + screenContext.unregisterDataReceiver(_componentId); + }; + }, [isDesignMode, screenContext, _componentId, dataProvider, dataReceiver]); + + // SplitPanelContext receiver register / unregister + useEffect(() => { + if ( + isDesignMode || + !splitPanelContext || + !_componentId || + !currentSplitPosition + ) { + return; + } + const splitReceiver = { + component_id: _componentId, + component_type: "table", + receiveData: async ( + incoming: any[], + mode: "append" | "replace" | "merge", + ) => { + await dataReceiver.receiveData(incoming, { + target_component_id: _componentId, + target_component_type: "table" as const, + mode, + mapping_rules: [], + }); + }, + }; + splitPanelContext.registerReceiver( + currentSplitPosition, + _componentId, + splitReceiver, + ); + return () => { + splitPanelContext.unregisterReceiver(currentSplitPosition, _componentId); + }; + }, [ + isDesignMode, + splitPanelContext, + _componentId, + currentSplitPosition, + dataReceiver, + ]); const handleRowClick = useCallback((idx: number) => { if (isDesignMode) return; @@ -361,11 +1981,37 @@ export const TableComponent: React.FC = ({ disabled: _72, required: _73, // Search ↔ Table 연동 props — DOM 에 흘리지 않음 onSearch: _74, searchParams: _75, + // 데이터 소스 옵션 (Phase C.1) — DOM 에 흘리지 않음 + useCustomTable: _76, customTableName: _77, isReadOnly: _78, autoLoad: _79, + // 스타일 / 툴바 / 데이터 동작 (Phase C.5) — DOM 에 흘리지 않음 + tableStyle: _80, toolbar: _81, defaultSort: _82, refreshInterval: _83, + // 필터 config (Phase C.3) — runtime 적용은 D.2. DOM 에 흘리지 않음 + filter: _84, linkedFilters: _85, excludeFilter: _86, dataFilter: _87, + // 액션 config (Phase C.4) — runtime 렌더/실행은 D.4. DOM 에 흘리지 않음 + actions: _88, + // 컬럼 시스템 runtime 옵션 (Phase D.1) — DOM 에 흘리지 않음 + autoWidth: _89, stickyHeader: _90, horizontalScroll: _91, + responsiveCardBreakpoint: _92, ...domProps } = props as any; /* eslint-enable @typescript-eslint/no-unused-vars */ // ─── 스타일 ─── + // Phase D.6 — tableStyle.theme / borderStyle 가 컨테이너 외곽에 영향. + // theme === "minimal" → 외곽 border 흐릿 + // theme === "bordered" → 외곽 border 진하게 + // borderStyle === "heavy" → 외곽 border 두꺼움 + // borderStyle === "none" → 외곽 border 제거 + const _ts = componentConfig.tableStyle; + const _containerBorder = (() => { + if (_ts?.borderStyle === "none") return "none"; + if (_ts?.borderStyle === "heavy") return "2px solid hsl(var(--border))"; + if (_ts?.theme === "minimal") + return "1px solid hsl(var(--border) / 0.4)"; + if (_ts?.theme === "bordered") + return "1px solid hsl(var(--foreground) / 0.5)"; + return "1px solid hsl(var(--border))"; + })(); const containerStyle: React.CSSProperties = { width: "100%", height: "100%", @@ -373,7 +2019,7 @@ export const TableComponent: React.FC = ({ flexDirection: "column", background: "hsl(var(--card))", borderRadius: "6px", - border: "1px solid hsl(var(--border))", + border: _containerBorder, overflow: "hidden", ...(component as any).style, ...style, @@ -393,28 +2039,213 @@ export const TableComponent: React.FC = ({ {isDesignMode && tableName && ` · 미리보기 ${Math.min(rows.length, DESIGN_PREVIEW_ROWS)}건`} {isDesignMode && !tableName && columns.length > 0 && ` · 컬럼 ${columns.length}개`} -
+
{tableData.loading && ( 로딩... )} - {componentConfig.showRefresh && ( - )} - {componentConfig.showExcel && ( - + {_showSearchBtn && componentConfig.filter?.enabled && ( + + )} + {_showFilterBtn && componentConfig.filter?.enabled && ( + + )} + {_showCopyBtn && ( + + )} + {_showExcelBtn && ( + + )} + {_showPdfBtn && ( + + )} + {_showRefreshBtn && ( + + )} + {/* Phase D.8 — TableSettingsModal 진입점. _tableOptions Provider 있을 때만. */} + {!isDesignMode && _tableOptions && _canonicalTableId && ( + )}
); + // Phase D.1 — header cell style. sticky/fixed 분기 + autoWidth/width + hidden dim. + const headerCellStyle = (col: TableColumn): React.CSSProperties => { + const off = stickyOffsets.get(col.key); + const isLeft = col.fixed === "left"; + const isRight = col.fixed === "right"; + const isFixed = isLeft || isRight; + const widthPx = !autoWidth + ? typeof col.width === "number" && col.width > 0 + ? `${col.width}px` + : `${getColumnWidth(col)}px` + : col.width + ? `${col.width}px` + : undefined; + return { + ...thStyle, + width: widthPx, + textAlign: col.align ?? "left", + cursor: col.sortable && !isDesignMode ? "pointer" : "default", + opacity: col.hidden && isDesignMode ? 0.4 : 1, + // Phase D.6 — headerStyle 적용 — 모든 (sticky/non-sticky) 헤더 셀에 일관 적용 + background: _headerBg, + color: _headerColor, + ...(isFixed + ? { + position: "sticky" as const, + ...(isLeft ? { left: off?.left ?? 0 } : {}), + ...(isRight ? { right: off?.right ?? 0 } : {}), + zIndex: stickyHeaderEnabled ? 3 : 2, + boxShadow: isLeft + ? "1px 0 0 hsl(var(--border))" + : "-1px 0 0 hsl(var(--border))", + } + : {}), + }; + }; + const renderHeader = () => showHeader && ( -
+ {showCheckbox && ( - )} - {columns.map((col) => ( + {renderColumns.map((col) => ( ))} + {/* Phase D.4 — 우측 actions header (basic table mode only, rowActions ≥1 일 때) */} + {rowActions.length > 0 && ( + + )} ); + // Phase D.1 — body cell. row background 와 동일한 background 를 sticky cell 에 줘서 스크롤 시 + // 투명해지지 않게. selected/striped/transparent 행 색깔 동기. + const rowBackgroundFor = (rowIdx: number): string => { + if (selectedRowIdx === rowIdx) return "hsl(var(--primary) / 0.08)"; + if (striped && rowIdx % 2 === 1) return "hsl(var(--muted) / 0.5)"; + return "hsl(var(--card))"; + }; + const bodyCellStyle = (col: TableColumn, rowIdx: number): React.CSSProperties => { + const off = stickyOffsets.get(col.key); + const isLeft = col.fixed === "left"; + const isRight = col.fixed === "right"; + const isFixed = isLeft || isRight; + return { + ...tdStyle, + textAlign: col.align ?? "left", + opacity: col.hidden && isDesignMode ? 0.4 : 1, + ...(isFixed + ? { + position: "sticky" as const, + ...(isLeft ? { left: off?.left ?? 0 } : {}), + ...(isRight ? { right: off?.right ?? 0 } : {}), + background: rowBackgroundFor(rowIdx), + zIndex: 1, + boxShadow: isLeft + ? "1px 0 0 hsl(var(--border) / 0.3)" + : "-1px 0 0 hsl(var(--border) / 0.3)", + } + : {}), + }; + }; + + // Phase D.3 — 편집 input 렌더 (셀 안). inputType 분기: date/datetime → InlineCellDatePicker, + // category/code/select 면서 column.options 있음 → native select, number/decimal/format=number → number, + // 그 외 → text input. + const renderEditInput = (col: TableColumn): React.ReactNode => { + const inputType = col.inputType; + const isNumber = + inputType === "number" || inputType === "decimal" || col.format === "number"; + const isDate = inputType === "date" || inputType === "datetime"; + const selectOptions: any[] = Array.isArray((col as any).options) + ? ((col as any).options as any[]) + : []; + const isSelect = + (inputType === "category" || inputType === "code" || inputType === "select") && + selectOptions.length > 0; + + const commonStyle: React.CSSProperties = { + width: "100%", + height: "100%", + padding: "0 4px", + fontSize: 12, + border: "1px solid hsl(var(--primary))", + background: "hsl(var(--card))", + color: "hsl(var(--foreground))", + outline: "none", + boxSizing: "border-box", + }; + + if (isDate) { + return ( + + ); + } + + if (isSelect) { + return ( + + ); + } + + return ( + setEditingDraft(e.target.value)} + onKeyDown={handleEditKeyDown} + onClick={(e) => e.stopPropagation()} + onBlur={commitEdit} + style={commonStyle} + autoFocus + /> + ); + }; + const renderRows = () => ( - {rows.length > 0 ? rows.map((row, idx) => ( + {rows.length > 0 ? rows.map((row, idx) => { + const rowBg = + selectedRowIdx === idx + ? "hsl(var(--primary) / 0.08)" + : striped && idx % 2 === 1 + ? "hsl(var(--muted) / 0.5)" + : "transparent"; + return ( = ({ onClick={() => handleRowClick(idx)} > {showCheckbox && ( - )} - {columns.map((col) => ( - + ); + })} + {/* Phase D.4 — 우측 actions cell. row click / inline edit 와 격리. */} + {rowActions.length > 0 && ( + - ))} + )} - )) : ( + ); + }) : ( ); - const renderFooter = () => - showFooter && ( + const renderFooter = () => { + if (!showFooter) return null; + // Phase D.6 — pagination 확장: + // - showPageInfo: "1-20 / 총 N건" 텍스트 + // - showSizeSelector + pageSizeOptions: size 드롭다운 + // - showPaginationRefresh: footer refresh 버튼 + const pag = componentConfig.pagination; + const showPageInfo = !!pag?.showPageInfo; + const showSizeSelector = !!pag?.showSizeSelector; + const pageSizeOptions: number[] = + Array.isArray(pag?.pageSizeOptions) && pag.pageSizeOptions.length > 0 + ? pag!.pageSizeOptions! + : [10, 20, 50, 100]; + const from = + tableData.total > 0 + ? (tableData.page - 1) * tableData.pageSize + 1 + : 0; + const to = Math.min(tableData.page * tableData.pageSize, tableData.total); + return (
- {isDesignMode ? `미리보기 ${rows.length}건` : `총 ${tableData.total}건`} +
+ + {isDesignMode ? `미리보기 ${rows.length}건` : `총 ${tableData.total}건`} + + {!isDesignMode && showPageInfo && tableData.total > 0 && ( + + {from}-{to} 표시 + + )} +
+ {showSizeSelector && !isDesignMode && ( + + )} + {_showPaginationRefreshBtn && ( + + )}
); + }; // ─── 메인 렌더 ─── const renderBasicTable = () => (
- {columns.length > 0 ? ( -
+ c.fixed === "left") + ? { + position: "sticky" as const, + left: 0, + zIndex: stickyHeaderEnabled ? 3 : 2, + } + : {}), + }} + > 0 && selectedRows.size === rows.length} @@ -424,18 +2255,14 @@ export const TableComponent: React.FC = ({ /> col.sortable && !isDesignMode && tableData.toggleSort(col.key)} > - {col.label} + {/* Phase D.5 — langKey 가 있으면 다국어 번역, 없으면 col.label */} + {getColumnLabel(col)} {col.sortable && ( {tableData.sortBy === col.key ? (tableData.sortOrder === "asc" ? "↑" : "↓") : "↕"} @@ -443,23 +2270,148 @@ export const TableComponent: React.FC = ({ )} + 액션 +
+ c.fixed === "left") + ? { + position: "sticky" as const, + left: 0, + background: rowBackgroundFor(idx), + zIndex: 1, + } + : {}), + }} + > = ({ /> - {isDesignMode ? ( - - {row[col.key] != null ? String(row[col.key]) : "..."} - - ) : ( - {row[col.key] != null ? String(row[col.key]) : ""} - )} + {renderColumns.map((col) => { + // Phase D.3 — 편집 중인 셀이면 input 렌더, 아니면 기존 텍스트 표시 + const isEditingThisCell = + editingCell !== null && + editingCell.rowIndex === idx && + editingCell.columnKey === col.key; + const isSavingThisCell = savingCellKey === `${idx}-${col.key}`; + return ( + { + if (canEditCell(col)) { + e.stopPropagation(); + startEdit(idx, col, row); + } + }} + title={ + isSavingThisCell + ? "저장 중..." + : editError && isEditingThisCell + ? editError + : undefined + } + > + {isEditingThisCell ? ( + renderEditInput(col) + ) : ( + // Phase D.5 — canonical cell renderer (image / file / entityDisplay / number / date / boolean) + + {renderTableCellValue({ + value: row[col.key], + column: col, + row, + isDesignMode, + })} + + )} + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {rowActions.map((a, aIdx) => { + const Icon = getActionIcon(a.type); + const colorStyle: React.CSSProperties = a.color + ? a.color === "destructive" || a.color === "danger" + ? { color: "hsl(var(--destructive))" } + : a.color === "primary" + ? { color: "hsl(var(--primary))" } + : a.color === "muted" + ? { color: "hsl(var(--muted-foreground))" } + : /^#|^hsl|^rgb/.test(a.color) + ? { color: a.color } + : {} + : a.type === "delete" + ? { color: "hsl(var(--destructive))" } + : {}; + return ( + + ); + })}
0 ? 1 : 0) || 1 + } style={{ padding: "24px", textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px" }} > {!tableName ? "테이블을 연결하세요" - : columns.length === 0 + : renderColumns.length === 0 ? "컬럼을 자동 로드하세요" : tableData.loading ? "로딩 중..." @@ -508,11 +2573,60 @@ export const TableComponent: React.FC = ({
+ {renderColumns.length > 0 ? ( +
{renderHeader()} {renderRows()}
@@ -600,18 +2737,31 @@ export const TableComponent: React.FC = ({ const idx = tableData.data.indexOf(row); if (idx >= 0) handleRowClick(idx); }} + // Phase D.8 — TableOptions groups / groupSumConfig 전달 + groupByColumns={effectiveGroupByColumns} + groupSumConfig={groupSumConfig} + getColumnLabel={getColumnLabel} /> ); case "card": return ( { const idx = tableData.data.indexOf(row); if (idx >= 0) handleRowClick(idx); }} + // Phase D.4 — actions 의 첫 번째 view/edit/delete 매칭을 CardView 콜백으로 wiring. + // CardView 자체의 cardStyle.showActions / show{View,Edit,Delete}Button 와는 별 layer. + onView={cardOnView} + onEdit={cardOnEdit} + onDelete={cardOnDelete} + // Phase D.7 — canonical columns 메타 + 다국어 라벨 전달. + // CardView 가 mapping inference / displayColumns D.5 셀 렌더 / langKey 번역에 사용. + columns={renderColumns} + getColumnLabel={getColumnLabel} /> ); case "pivot": @@ -634,16 +2784,108 @@ export const TableComponent: React.FC = ({ return (
{ e.stopPropagation(); onClick?.(); }} onDragStart={onDragStart} onDragEnd={onDragEnd} + onPaste={isDesignMode ? undefined : handleTablePaste} {...filterDOMProps(domProps)} > {renderToolbar()} + {/* Phase D.6 — pagination.position === "top" 시 footer 를 body 위에 표시 */} + {componentConfig.pagination?.position === "top" && renderFooter()} + {/* Phase D.2 — AdvancedSearchFilters 검색 위젯 영역. + 운영 모드 + filter.enabled + (정의된 필터 또는 normalize 가능한 컬럼) 일 때만 렌더. + Phase D.6 — toolbar.showSearch / showFilter 가 활성이면 토글 state 도 반영 + (하나라도 켜져 있으면 표시). 두 toolbar 옵션 모두 미설정이면 D.2 동작 그대로. */} + {!isDesignMode && + componentConfig.filter?.enabled && + (filtersForAdvanced.length > 0 || tableColumnsForFilters.length > 0) && + (!(_showSearchBtn || _showFilterBtn) || + searchPanelOpen || + filterPanelOpen) && ( +
+ +
+ )} + {/* Phase D.4 — bulk action bar. 운영 모드 + bulkActionNames 1+ + 선택 행 1+ 일 때. */} + {!isDesignMode && bulkActionNames.length > 0 && selectedRows.size > 0 && ( +
e.stopPropagation()} + > + + {selectedRows.size}개 선택됨 + +
+ {bulkActionNames.map((name) => { + const isDelete = name === "delete"; + return ( + + ); + })} +
+
+ )} {renderBody()} - {renderFooter()} + {/* Phase D.6 — pagination.position === "top" 이면 위에서 이미 렌더했으므로 skip */} + {componentConfig.pagination?.position !== "top" && renderFooter()} + {/* Phase D.8 — TableSettingsModal. Provider 있을 때만 + 운영 모드만. */} + {!isDesignMode && _tableOptions && isTableSettingsOpen && ( + setIsTableSettingsOpen(false)} + onFiltersApplied={(filters) => + setTableOptionsFilters(Array.isArray(filters) ? filters : []) + } + /> + )}
); }; diff --git a/frontend/lib/registry/components/table/_shared/CardModeRenderer.tsx b/frontend/lib/registry/components/table/_shared/CardModeRenderer.tsx deleted file mode 100644 index 7694b96f..00000000 --- a/frontend/lib/registry/components/table/_shared/CardModeRenderer.tsx +++ /dev/null @@ -1,259 +0,0 @@ -"use client"; - -/** - * CardModeRenderer — shared card grid renderer - * - * 2026-05-20 table-list / v2-table-list 의 중복 CardModeRenderer 를 흡수. - * `variant="v2"` prop 으로 v2 전용 이미지 URL 정규화 (objid → getFilePreviewUrl, - * path → getFullImageUrl) 와 이미지 로드 실패 시 fallback DOM 삽입을 분기 처리. - * - * 이 파일은 legacy table-list / v2-table-list local type files 를 import 하지 않는다. - * shared 타입은 `./tableListConfigTypes` 에서 가져온다. - */ - -import React from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; -import type { CardDisplayConfig, ColumnConfig } from "./tableListConfigTypes"; -import { getFullImageUrl } from "@/lib/api/client"; -import { getFilePreviewUrl } from "@/lib/api/file"; - -export type CardModeVariant = "default" | "v2"; - -interface CardModeRendererProps { - /** 시각/동작 분기 — 기본은 table-list 기존 동작, "v2" 는 v2-table-list 흡수 분기 */ - variant?: CardModeVariant; - data: Record[]; - cardConfig: CardDisplayConfig; - visibleColumns: ColumnConfig[]; - onRowClick?: (row: Record, index: number, e: React.MouseEvent) => void; - onRowSelect?: (row: Record, selected: boolean) => void; - selectedRows?: string[]; -} - -/** - * 카드 모드 렌더러 - * 테이블 데이터를 카드 형태로 표시 - */ -export const CardModeRenderer: React.FC = ({ - variant = "default", - data, - cardConfig, - visibleColumns, - onRowClick, - selectedRows = [], -}) => { - const isV2 = variant === "v2"; - // 기본값과 병합 - const config = { - idColumn: cardConfig?.idColumn || "", - titleColumn: cardConfig?.titleColumn || "", - subtitleColumn: cardConfig?.subtitleColumn, - descriptionColumn: cardConfig?.descriptionColumn, - imageColumn: cardConfig?.imageColumn, - cardsPerRow: cardConfig?.cardsPerRow ?? 3, - cardSpacing: cardConfig?.cardSpacing ?? 16, - showActions: cardConfig?.showActions ?? true, - cardHeight: cardConfig?.cardHeight as number | "auto" | undefined, - }; - - // 디버깅: cardConfig 확인 - console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config }); - - // 카드 그리드 스타일 계산 - const gridStyle: React.CSSProperties = { - display: "grid", - gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`, - gap: `${config.cardSpacing}px`, - padding: `${config.cardSpacing}px`, - overflow: "auto", - }; - - // 카드 높이 스타일 - const cardStyle: React.CSSProperties = { - height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`, - cursor: onRowClick ? "pointer" : "default", - }; - - // 컬럼 값 가져오기 함수 - const getColumnValue = (row: Record, columnName?: string): string => { - if (!columnName || !row) return ""; - return String(row[columnName] || ""); - }; - - // 액션 버튼 렌더링 - const renderActions = (_row: Record) => { - if (!config.showActions) return null; - - return ( -
- - - - -
- ); - }; - - // 데이터가 없는 경우 - if (!data || data.length === 0) { - return ( -
-
-
-
-
표시할 데이터가 없습니다
-
조건을 변경하거나 새로운 데이터를 추가해보세요
-
- ); - } - - return ( -
- {data.map((row, index) => { - const idValue = getColumnValue(row, config.idColumn); - const titleValue = getColumnValue(row, config.titleColumn); - const subtitleValue = getColumnValue(row, config.subtitleColumn); - const descriptionValue = getColumnValue(row, config.descriptionColumn); - const imageValue = getColumnValue(row, config.imageColumn); - - const isSelected = selectedRows.includes(idValue); - - return ( - onRowClick?.(row, index, e)} - > - -
-
- {titleValue || "제목 없음"} - {subtitleValue &&
{subtitleValue}
} -
- - {/* ID 뱃지 */} - {idValue && ( - - {idValue} - - )} -
-
- - - {/* 이미지 표시 */} - {imageValue && ( -
- { - // ★ v2 전용: 숫자 objid → getFilePreviewUrl, 그 외 path → getFullImageUrl 정규화 - const strValue = String(imageValue); - const isObjid = /^\d+$/.test(strValue); - return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); - })() - : imageValue - } - alt={titleValue} - className="h-24 w-full rounded-md bg-muted object-cover" - onError={(e) => { - const target = e.target as HTMLImageElement; - // 이미지 로드 실패 시 폴백 표시 - target.style.display = "none"; - // ★ v2 전용: 폴백 DOM 삽입 (data-image-fallback). default 는 단순 hide 만. - if (!isV2) return; - const parent = target.parentElement; - if (parent && !parent.querySelector("[data-image-fallback]")) { - const fallback = document.createElement("div"); - fallback.setAttribute("data-image-fallback", "true"); - fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground"; - fallback.innerHTML = ``; - parent.appendChild(fallback); - } - }} - /> -
- )} - - {/* 설명 표시 */} - {descriptionValue &&
{descriptionValue}
} - - {/* 추가 필드들 표시 (선택적) */} -
- {(visibleColumns || []) - .filter( - (col) => - col.columnName !== config.idColumn && - col.columnName !== config.titleColumn && - col.columnName !== config.subtitleColumn && - col.columnName !== config.descriptionColumn && - col.columnName !== config.imageColumn && - col.columnName !== "__checkbox__" && - col.visible, - ) - .slice(0, 3) // 최대 3개 추가 필드만 표시 - .map((col) => { - const value = getColumnValue(row, col.columnName); - if (!value) return null; - - return ( -
- {col.displayName}: - {value} -
- ); - })} -
- - {/* 액션 버튼들 */} - {renderActions(row)} -
-
- ); - })} -
- ); -}; diff --git a/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx index 5964d76c..4ed5fcf4 100644 --- a/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx @@ -3,16 +3,11 @@ /** * SingleTableWithSticky — shared table renderer * - * 2026-05-19 FlowWidget hard blocker 제거를 위해 legacy - * `lib/registry/components/table-list/SingleTableWithSticky.tsx` 에서 이동. + * 2026-05-19 FlowWidget 의 sticky 테이블 구현을 흡수한 헬퍼. + * 현재는 `frontend/components/screen/widgets/FlowWidget.tsx` 에서만 사용한다. * - * 2026-05-20 v2-table-list 의 중복 sticky 구현을 흡수. `variant="v2"` prop 으로 - * v2 전용 헤더/행/셀 스타일, 인라인 편집(category/code select, date picker fallback, - * number input), mobile scroll/minWidth, null/undefined/"" → "-" 표시(0 은 값) 분기. - * - * 이 파일은 legacy `table-list` / `v2-table-list` / FlowWidget 모두에서 공유한다. - * legacy table-list / v2-table-list local type files 를 import 하지 않는다. 본 파일이 자체 minimal - * `ColumnConfig` 를 export 한다. + * 본 파일은 자체 minimal `ColumnConfig` 를 export 하며 외부 타입을 의존하지 않는다. + * canonical table runtime 은 `frontend/lib/registry/components/table/TableComponent.tsx` 사용. */ import React from "react"; @@ -24,10 +19,6 @@ import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; /** * SingleTableWithSticky 가 실제 사용하는 컬럼 메타 minimal 정의. - * - * legacy table-list / v2-table-list 의 `ColumnConfig` 가 - * 이 type 의 superset 이므로 구조적으로 호환된다. 따라서 legacy 호출부에서는 - * 별도 변환 없이 그대로 전달 가능. */ export interface ColumnConfig { columnName: string; @@ -42,11 +33,7 @@ export interface ColumnConfig { [key: string]: any; } -export type SingleTableVariant = "default" | "v2"; - interface SingleTableWithStickyProps { - /** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */ - variant?: SingleTableVariant; visibleColumns?: TColumn[]; columns?: TColumn[]; data: Record[]; @@ -62,7 +49,6 @@ interface SingleTableWithStickyProps void; renderCheckboxCell?: (row: any, index: number) => React.ReactNode; renderCheckboxHeader?: () => React.ReactNode; - /** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */ formatCellValue: ( value: any, format?: string, @@ -79,19 +65,12 @@ interface SingleTableWithStickyProps void; onEditKeyDown?: (e: React.KeyboardEvent) => void; editInputRef?: React.RefObject; - /** v2 전용: Enter/blur 시 저장 콜백 (date picker fallback 포함 통합 저장 경로) */ - onEditSave?: () => void; - /** v2 전용: 컬럼별 inputType (select/category/code, number, date, datetime) */ - columnMeta?: Record; - /** v2 전용: category/code 컬럼의 옵션 매핑 */ - categoryMappings?: Record>; searchHighlights?: Set; currentSearchIndex?: number; searchTerm?: string; } export function SingleTableWithSticky({ - variant = "default", visibleColumns, columns, data, @@ -118,9 +97,6 @@ export function SingleTableWithSticky {}); const actualData = data || []; - const isV2 = variant === "v2"; - - // ── 컨테이너/스크롤 분기 (v2 만 mobile scroll + minWidth 적용) ── - const scrollContainerStyle: React.CSSProperties = isV2 ? { WebkitOverflowScrolling: "touch" } : {}; - const tableStyle: React.CSSProperties = { - width: "100%", - tableLayout: "auto", - boxSizing: "border-box", - ...(isV2 ? { minWidth: `${Math.max(actualColumns.length * 80, 400)}px` } : {}), - }; - - // ── 헤더 스타일 분기 ── - const headerBaseClass = isV2 ? "border-b border-border/60" : "bg-background border-b"; - const headerStyle: React.CSSProperties = isV2 ? { backgroundColor: "hsl(var(--muted) / 0.4)" } : {}; - const headerRowClass = isV2 ? "border-b border-border/60" : "border-b"; - return (
-
- +
+
- + {actualColumns.map((column, colIndex) => { const leftFixedWidth = actualColumns .slice(0, colIndex) @@ -178,20 +144,10 @@ export function SingleTableWithSticky column.sortable && sortHandler(column.columnName)} > -
+
{isCheckboxCol ? ( checkboxConfig.selectAll && ( ) ) : ( @@ -297,17 +241,10 @@ export function SingleTableWithSticky ) : ( actualData.map((row, index) => { - // ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ── - const rowClass = isV2 - ? cn( - "cursor-pointer border-b border-border/50 transition-[background] duration-75", - index % 2 === 0 ? "bg-background" : "bg-muted/20", - tableConfig?.tableStyle?.hoverEffect !== false && "hover:bg-accent", - ) - : cn( - "bg-background h-10 cursor-pointer border-b transition-colors", - tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50", - ); + const rowClass = cn( + "bg-background h-10 cursor-pointer border-b transition-colors", + tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50", + ); return ( handleRowClick?.(row, index, e)}> @@ -345,31 +282,10 @@ export function SingleTableWithSticky-; - isReactElement = true; - } else { - rawCellValue = formatted; - isReactElement = typeof formatted === "object" && React.isValidElement(formatted); - } - } else { - rawCellValue = - formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; - } + const rawCellValue: React.ReactNode = + formatCellValue(row[column.columnName], column.format, column.columnName, row) || " "; const renderCellContent = () => { - // ReactElement (v2 의 이미지/JSX) 는 그대로 렌더 - if (isReactElement) { - return rawCellValue; - } - if (!isHighlighted || !searchTerm || isCheckboxCol) { return rawCellValue; } @@ -403,37 +319,15 @@ export function SingleTableWithSticky { - const meta = columnMeta?.[column.columnName]; - const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - const isCategoryType = inputType === "category" || inputType === "code"; - const categoryOptions = categoryMappings?.[column.columnName]; - const hasCategoryOptions = - isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0; - - const commonInputClass = - "border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"; - const handleBlurSave = () => { - if (onEditKeyDown) { - const fakeEvent = { - key: "Enter", - preventDefault: () => {}, - } as React.KeyboardEvent; - onEditKeyDown(fakeEvent); - } - onEditSave?.(); - }; - - if (hasCategoryOptions) { - const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({ - value, - label: info.label, - })); - return ( - - ); + onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={() => { + if (onEditKeyDown) { + const fakeEvent = { + key: "Enter", + preventDefault: () => {}, + } as React.KeyboardEvent; + onEditKeyDown(fakeEvent); } - - if (inputType === "date" || inputType === "datetime") { - try { - // 외부 의존 모듈 — runtime require 실패 시 일반 text input 으로 폴백 - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); - return ( - onEditingValueChange?.(v)} - onSave={handleBlurSave} - onKeyDown={onEditKeyDown} - inputRef={editInputRef} - /> - ); - } catch { - return ( - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={handleBlurSave} - className={commonInputClass} - onClick={(e) => e.stopPropagation()} - /> - ); - } - } - - return ( - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={handleBlurSave} - className={commonInputClass} - style={isNumeric ? { textAlign: "right" } : undefined} - onClick={(e) => e.stopPropagation()} - /> - ); - })() - ) : ( - // ── 기본 인라인 편집: 단순 text input (table-list / FlowWidget 기존 동작) ── - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={() => { - if (onEditKeyDown) { - const fakeEvent = { - key: "Enter", - preventDefault: () => {}, - } as React.KeyboardEvent; - onEditKeyDown(fakeEvent); - } - }} - className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm" - onClick={(e) => e.stopPropagation()} - /> - ) + }} + className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm" + onClick={(e) => e.stopPropagation()} + /> ) : ( renderCellContent() )} diff --git a/frontend/lib/registry/components/table/_shared/TableListComponent.tsx b/frontend/lib/registry/components/table/_shared/TableListComponent.tsx deleted file mode 100644 index cd662e27..00000000 --- a/frontend/lib/registry/components/table/_shared/TableListComponent.tsx +++ /dev/null @@ -1,6838 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; -import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes"; -import type { WebType } from "@/types/screen"; -import { tableTypeApi } from "@/lib/api/screen"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { codeCache } from "@/lib/caching/codeCache"; -import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; -import { getFullImageUrl } from "@/lib/api/client"; -import { Button } from "@/components/ui/button"; -import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; -import { filterDOMProps } from "@/lib/utils/domPropsFilter"; - -// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 -declare global { - interface Window { - __relatedButtonsTargetTables?: Set; - } -} -import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - RefreshCw, - ArrowUp, - ArrowDown, - TableIcon, - Settings, - X, - Layers, - ChevronDown, - Filter, - Check, - Download, - FileSpreadsheet, - Copy, - ClipboardCopy, - Edit, - CheckSquare, - Trash2, - Lock, -} from "lucide-react"; -import * as XLSX from "xlsx"; -import { FileText, ChevronRightIcon, Search } from "lucide-react"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/utils/toastUtils"; -import { tableDisplayStore } from "@/stores/tableDisplayStore"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Label } from "@/components/ui/label"; -import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; -import { SingleTableWithSticky } from "./SingleTableWithSticky"; -import { CardModeRenderer } from "./CardModeRenderer"; -import { TableOptionsModal } from "@/components/common/TableOptionsModal"; -import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; -import { useAuth } from "@/hooks/useAuth"; -import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; -import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; - -// ======================================== -// 인터페이스 -// ======================================== - -// 그룹화된 데이터 인터페이스 -interface GroupedData { - groupKey: string; - groupValues: Record; - items: any[]; - count: number; - summary?: Record; // 🆕 그룹별 소계 -} - -// ======================================== -// 캐시 및 유틸리티 -// ======================================== - -const tableColumnCache = new Map(); -const tableInfoCache = new Map(); -const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 - -const cleanupTableCache = () => { - const now = Date.now(); - for (const [key, entry] of tableColumnCache.entries()) { - if (now - entry.timestamp > TABLE_CACHE_TTL) { - tableColumnCache.delete(key); - } - } - for (const [key, entry] of tableInfoCache.entries()) { - if (now - entry.timestamp > TABLE_CACHE_TTL) { - tableInfoCache.delete(key); - } - } -}; - -if (typeof window !== "undefined") { - setInterval(cleanupTableCache, 10 * 60 * 1000); -} - -const debounceTimers = new Map(); -const activeRequests = new Map>(); - -const debouncedApiCall = (key: string, fn: (...args: T) => Promise, delay: number = 300) => { - return (...args: T): Promise => { - const activeRequest = activeRequests.get(key); - if (activeRequest) { - return activeRequest as Promise; - } - - return new Promise((resolve, reject) => { - const existingTimer = debounceTimers.get(key); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - try { - const requestPromise = fn(...args); - activeRequests.set(key, requestPromise); - const result = await requestPromise; - resolve(result); - } catch (error) { - reject(error); - } finally { - debounceTimers.delete(key); - activeRequests.delete(key); - } - }, delay); - - debounceTimers.set(key, timer); - }); - }; -}; - -// ======================================== -// Filter Builder 인터페이스 -// ======================================== - -interface FilterCondition { - id: string; - column: string; - operator: - | "equals" - | "notEquals" - | "contains" - | "notContains" - | "startsWith" - | "endsWith" - | "greaterThan" - | "lessThan" - | "greaterOrEqual" - | "lessOrEqual" - | "isEmpty" - | "isNotEmpty"; - value: string; -} - -interface FilterGroup { - id: string; - logic: "AND" | "OR"; - conditions: FilterCondition[]; -} - -// ======================================== -// Props 인터페이스 -// ======================================== - -export interface TableListComponentProps { - component: any; - isDesignMode?: boolean; - isSelected?: boolean; - isInteractive?: boolean; - onClick?: () => void; - onDragStart?: (e: React.DragEvent) => void; - onDragEnd?: (e: React.DragEvent) => void; - className?: string; - style?: React.CSSProperties; - formData?: Record; - onFormDataChange?: (data: any) => void; - config?: TableListConfig; - size?: { width: number; height: number }; - position?: { x: number; y: number; z?: number }; - componentConfig?: any; - selectedScreen?: any; - onZoneComponentDrop?: any; - onZoneClick?: any; - tableName?: string; - onRefresh?: () => void; - onClose?: () => void; - screenId?: number | string; // 화면 ID (필터 설정 저장용) - userId?: string; // 사용자 ID (컬럼 순서 저장용) - onSelectedRowsChange?: ( - selectedRows: any[], - selectedRowsData: any[], - sortBy?: string, - sortOrder?: "asc" | "desc", - columnOrder?: string[], - tableDisplayData?: any[], - ) => void; - onConfigChange?: (config: any) => void; - refreshKey?: number; - // 탭 관련 정보 (탭 내부의 테이블에서 사용) - parentTabId?: string; // 부모 탭 ID - parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID - // 🆕 프리뷰용 회사 코드 (DynamicComponentRenderer에서 전달, 최고 관리자만 오버라이드 가능) - companyCode?: string; -} - -// ======================================== -// 메인 컴포넌트 -// ======================================== - -export const TableListComponent: React.FC = ({ - component, - isDesignMode = false, - isSelected = false, - onClick, - onDragStart, - onDragEnd, - config, - className, - style, - formData: propFormData, - onFormDataChange, - componentConfig, - onSelectedRowsChange, - onConfigChange, - refreshKey, - tableName, - userId, - screenId, - parentTabId, - parentTabsComponentId, - companyCode, -}) => { - // ======================================== - // 설정 및 스타일 - // ======================================== - - const tableConfig = { - ...config, - ...component.config, - ...componentConfig, - } as TableListConfig; - - // selectedTable 안전하게 추출 (문자열인지 확인) - let finalSelectedTable = - componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName; - - // 디버그 로그 제거 (성능 최적화) - - // 객체인 경우 tableName 속성 추출 시도 - if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { - finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; - } - - tableConfig.selectedTable = finalSelectedTable; - - // 디버그 로그 제거 (성능 최적화) - - const buttonColor = getAdaptiveLabelColor(component.style?.labelColor); - const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; - - const gridColumns = component.gridColumns || 1; - let calculatedWidth: string; - - if (isDesignMode) { - if (gridColumns === 1) { - calculatedWidth = "400px"; - } else if (gridColumns === 2) { - calculatedWidth = "800px"; - } else { - calculatedWidth = "100%"; - } - } else { - calculatedWidth = "100%"; - } - - const componentStyle: React.CSSProperties = { - position: "relative", - display: "flex", - flexDirection: "column", - backgroundColor: "hsl(var(--background))", - overflow: "hidden", - boxSizing: "border-box", - width: "100%", - height: "100%", - minHeight: isDesignMode ? "300px" : "100%", - ...style, // style prop이 위의 기본값들을 덮어씀 - }; - - // ======================================== - // 상태 관리 - // ======================================== - - // 사용자 정보 (props에서 받거나 useAuth에서 가져오기) - const { userId: authUserId } = useAuth(); - const currentUserId = userId || authUserId; - - // 화면 컨텍스트 (데이터 제공자로 등록) - const screenContext = useScreenContextOptional(); - - // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) - const splitPanelContext = useSplitPanelContext(); - // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) - const splitPanelPosition = screenContext?.split_panel_position; - - // 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) - const [linkedFilterValues, setLinkedFilterValues] = useState>({}); - - // 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태 - const [relatedButtonFilter, setRelatedButtonFilter] = useState<{ - filterColumn: string; - filterValue: any; - } | null>(null); - - // 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부 - const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => { - // 초기값: 전역 레지스트리에서 확인 - if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) { - return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || ""); - } - return false; - }); - - // TableOptions Context - const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); - const [filters, setFilters] = useState([]); - const [grouping, setGrouping] = useState([]); - const [columnVisibility, setColumnVisibility] = useState([]); - - // filters가 변경되면 searchValues 업데이트 (실시간 검색) - useEffect(() => { - const newSearchValues: Record = {}; - filters.forEach((filter) => { - if (filter.value) { - // operator 정보도 함께 전달 (백엔드에서 equals/contains 구분) - newSearchValues[filter.column_name] = { - value: filter.value, - operator: filter.operator || "contains", - }; - } - }); - - setSearchValues(newSearchValues); - setCurrentPage(1); // 필터 변경 시 첫 페이지로 - }, [filters]); - - // grouping이 변경되면 groupByColumns 업데이트 - useEffect(() => { - setGroupByColumns(grouping); - }, [grouping]); - - // 초기 로드 시 localStorage에서 저장된 설정 불러오기 - useEffect(() => { - if (tableConfig.selectedTable && currentUserId) { - const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; - const savedSettings = localStorage.getItem(storageKey); - - if (savedSettings) { - try { - const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; - setColumnVisibility(parsed); - } catch (error) { - console.error("저장된 컬럼 설정 불러오기 실패:", error); - } - } - } - }, [tableConfig.selectedTable, currentUserId]); - - // columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용 - useEffect(() => { - if (columnVisibility.length > 0) { - const newOrder = columnVisibility.map((cv) => cv.column_name).filter((name) => name !== "__checkbox__"); // 체크박스 제외 - setColumnOrder(newOrder); - - // 너비 적용 - const newWidths: Record = {}; - columnVisibility.forEach((cv) => { - if (cv.width) { - newWidths[cv.column_name] = cv.width; - } - }); - if (Object.keys(newWidths).length > 0) { - setColumnWidths((prev) => ({ ...prev, ...newWidths })); - - // table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용) - if (tableConfig.selectedTable && userId) { - const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; - try { - const existing = localStorage.getItem(widthsKey); - const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths; - localStorage.setItem(widthsKey, JSON.stringify(merged)); - } catch { /* ignore */ } - } - } - - // localStorage에 저장 (사용자별) - if (tableConfig.selectedTable && currentUserId) { - const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; - localStorage.setItem(storageKey, JSON.stringify(columnVisibility)); - } - } - }, [columnVisibility, tableConfig.selectedTable, currentUserId]); - - // 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용) - const [columnOrder, setColumnOrder] = useState([]); - - // 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해) - const visibleColumns = useMemo(() => { - let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - - // columnVisibility가 있으면 가시성 적용 - if (columnVisibility.length > 0) { - cols = cols.filter((col) => { - const visibilityConfig = columnVisibility.find((cv) => cv.column_name === col.columnName); - return visibilityConfig ? visibilityConfig.visible : true; - }); - } - - // 체크박스 컬럼 (나중에 위치 결정) - // 기본값: enabled가 undefined면 true로 처리 - let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled ?? true) { - checkboxCol = { - columnName: "__checkbox__", - displayName: "", - visible: true, - sortable: false, - searchable: false, - width: 40, - align: "center" as const, - order: -1, - editable: false, // 체크박스는 편집 불가 - }; - } - - // columnOrder가 있으면 해당 순서로 정렬 - if (columnOrder.length > 0) { - const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); - cols = [...cols].sort((a, b) => { - const aIdx = orderMap.get(a.columnName) ?? 9999; - const bIdx = orderMap.get(b.columnName) ?? 9999; - return aIdx - bIdx; - }); - } - - // 체크박스 위치 결정 - if (checkboxCol) { - const checkboxPosition = tableConfig.checkbox?.position || "left"; - if (checkboxPosition === "left") { - return [checkboxCol, ...cols]; - } else { - return [...cols, checkboxCol]; - } - } - - return cols; - }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); - - const [data, setData] = useState[]>([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) - const [headerFilters, setHeaderFilters] = useState>>({}); - const [headerLikeFilters, setHeaderLikeFilters] = useState>({}); // LIKE 검색용 - const [openFilterColumn, setOpenFilterColumn] = useState(null); - - // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 - const [filterGroups, setFilterGroups] = useState([]); - - // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 - const filteredData = useMemo(() => { - let result = data; - - // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 - if (splitPanelPosition === "left" && splitPanelContext?.added_item_ids && splitPanelContext.added_item_ids.size > 0) { - const addedIds = splitPanelContext.added_item_ids; - result = result.filter((row) => { - const rowId = String(row.id || row.po_item_id || row.item_id || ""); - return !addedIds.has(rowId); - }); - } - - // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) - if (Object.keys(headerFilters).length > 0) { - result = result.filter((row) => { - return Object.entries(headerFilters).every(([columnName, values]) => { - if (values.size === 0) return true; - - // 여러 가능한 컬럼명 시도 - const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; - const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - - return values.has(cellStr); - }); - }); - } - - // 2-1. 🆕 LIKE 검색 필터 적용 - if (Object.keys(headerLikeFilters).length > 0) { - result = result.filter((row) => { - return Object.entries(headerLikeFilters).every(([columnName, searchText]) => { - if (!searchText || searchText.trim() === "") return true; - - // 여러 가능한 컬럼명 시도 - const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; - const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue).toLowerCase() : ""; - - // LIKE 검색 (대소문자 무시) - return cellStr.includes(searchText.toLowerCase()); - }); - }); - } - - // 3. 🆕 Filter Builder 적용 - if (filterGroups.length > 0) { - result = result.filter((row) => { - return filterGroups.every((group) => { - const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), - ); - if (validConditions.length === 0) return true; - - const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => { - const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; - const condValue = condition.value.toLowerCase(); - - switch (condition.operator) { - case "equals": - return strValue === condValue; - case "notEquals": - return strValue !== condValue; - case "contains": - return strValue.includes(condValue); - case "notContains": - return !strValue.includes(condValue); - case "startsWith": - return strValue.startsWith(condValue); - case "endsWith": - return strValue.endsWith(condValue); - case "greaterThan": - return parseFloat(strValue) > parseFloat(condValue); - case "lessThan": - return parseFloat(strValue) < parseFloat(condValue); - case "greaterOrEqual": - return parseFloat(strValue) >= parseFloat(condValue); - case "lessOrEqual": - return parseFloat(strValue) <= parseFloat(condValue); - case "isEmpty": - return strValue === "" || value === null || value === undefined; - case "isNotEmpty": - return strValue !== "" && value !== null && value !== undefined; - default: - return true; - } - }; - - if (group.logic === "AND") { - return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); - } else { - return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); - } - }); - }); - } - - return result; - }, [data, splitPanelPosition, splitPanelContext?.added_item_ids, headerFilters, headerLikeFilters, filterGroups]); - - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [totalItems, setTotalItems] = useState(0); - const [searchTerm, setSearchTerm] = useState(""); - const [sortColumn, setSortColumn] = useState(null); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - const hasInitializedSort = useRef(false); - const [columnLabels, setColumnLabels] = useState>({}); - const [tableLabel, setTableLabel] = useState(""); - const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); - const [displayColumns, setDisplayColumns] = useState([]); - const [joinColumnMapping, setJoinColumnMapping] = useState>({}); - const [columnMeta, setColumnMeta] = useState< - Record - >({}); - // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) - const [joinedColumnMeta, setJoinedColumnMeta] = useState< - Record - >({}); - const [categoryMappings, setCategoryMappings] = useState< - Record> - >({}); - const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용 - const [searchValues, setSearchValues] = useState>({}); - const [selectedRows, setSelectedRows] = useState>(new Set()); - const [columnWidths, setColumnWidths] = useState>({}); - const [refreshTrigger, setRefreshTrigger] = useState(0); - // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) - const columnRefs = useRef>({}); - const [isAllSelected, setIsAllSelected] = useState(false); - const hasInitializedWidths = useRef(false); - const isResizing = useRef(false); - - // 필터 설정 관련 상태 - const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); - const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); - - // 🆕 키보드 네비게이션 관련 상태 - const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); - const tableContainerRef = useRef(null); - - // 🆕 인라인 셀 편집 관련 상태 - const [editingCell, setEditingCell] = useState<{ - rowIndex: number; - colIndex: number; - columnName: string; - originalValue: any; - } | null>(null); - const [editingValue, setEditingValue] = useState(""); - const editInputRef = useRef(null); - - // 🆕 배치 편집 관련 상태 - const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 - const [pendingChanges, setPendingChanges] = useState< - Map< - string, - { - rowIndex: number; - columnName: string; - originalValue: any; - newValue: any; - primaryKeyValue: any; - } - > - >(new Map()); // key: `${rowIndex}-${columnName}` - const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 - - // 🆕 유효성 검사 관련 상태 - const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` - - // 🆕 유효성 검사 규칙 타입 - type ValidationRule = { - required?: boolean; - min?: number; - max?: number; - minLength?: number; - maxLength?: number; - pattern?: RegExp; - customMessage?: string; - validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null) - }; - - // 🆕 Cascading Lookups 관련 상태 - const [cascadingOptions, setCascadingOptions] = useState>({}); - const [loadingCascading, setLoadingCascading] = useState>({}); - - // 🆕 Multi-Level Headers (Column Bands) 타입 - type ColumnBand = { - caption: string; - columns: string[]; // 포함될 컬럼명 배열 - }; - - // 그룹 설정 관련 상태 - const [groupByColumns, setGroupByColumns] = useState([]); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - - // 🆕 그룹별 합산 설정 상태 - const [groupSumConfig, setGroupSumConfig] = useState(null); - - // 🆕 Master-Detail 관련 상태 - const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 - const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 - - // 🆕 Drag & Drop 재정렬 관련 상태 - const [draggedRowIndex, setDraggedRowIndex] = useState(null); - const [dropTargetIndex, setDropTargetIndex] = useState(null); - const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); - - // 🆕 Virtual Scrolling 관련 상태 - const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); - const [scrollTop, setScrollTop] = useState(0); - const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀) - const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수 - const scrollContainerRef = useRef(null); - - // 🆕 Column Reordering 관련 상태 - const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); - const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); - const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); - - // 🆕 State Persistence: 통합 상태 키 - const tableStateKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); - - // 🆕 Real-Time Updates 관련 상태 - const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); - const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">( - "disconnected", - ); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - - // 🆕 Context Menu 관련 상태 - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - rowIndex: number; - colIndex: number; - row: any; - } | null>(null); - - // 사용자 옵션 모달 관련 상태 - const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); - const [showGridLines, setShowGridLines] = useState(true); - const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); - // 체크박스 컬럼은 항상 기본 틀고정 - const [frozenColumns, setFrozenColumns] = useState( - (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [], - ); - const [frozenColumnCount, setFrozenColumnCount] = useState(0); - - // 🆕 Search Panel (통합 검색) 관련 상태 - const [globalSearchTerm, setGlobalSearchTerm] = useState(""); - const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); - const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 - - // 🆕 Filter Builder (고급 필터) 관련 상태 추가 - const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); - const [activeFilterCount, setActiveFilterCount] = useState(0); - - // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) - useEffect(() => { - const linkedFilters = tableConfig.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); - - // searchValues에 연결된 필터 값 병합 - setSearchValues((prev) => ({ - ...prev, - ...newFilterValues, - })); - - // 첫 페이지로 이동 - setCurrentPage(1); - } - }; - - // 초기 체크 - checkLinkedFilters(); - - // 주기적으로 체크 (500ms마다) - const intervalId = setInterval(checkLinkedFilters, 500); - - return () => { - clearInterval(intervalId); - }; - }, [screenContext, tableConfig.linkedFilters, linkedFilterValues]); - - // DataProvidable 인터페이스 구현 - const dataProvider: DataProvidable = { - component_id: component.id, - component_type: "table-list", - table_name: tableConfig.selectedTable, - - getSelectedData: () => { - // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) - const selectedData = filteredData.filter((row) => { - const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); - return selectedRows.has(rowId); - }); - return selectedData; - }, - - getAllData: () => { - // 🆕 필터링된 데이터 반환 - return filteredData; - }, - - clearSelection: () => { - setSelectedRows(new Set()); - setIsAllSelected(false); - }, - }; - - // DataReceivable 인터페이스 구현 - const dataReceiver: DataReceivable = { - component_id: component.id, - component_type: "table", - - receiveData: async (receivedData: any[], config: DataReceiverConfig) => { - try { - let newData: any[] = []; - - switch (config.mode) { - case "append": - // 기존 데이터에 추가 - newData = [...data, ...receivedData]; - break; - - case "replace": - // 기존 데이터를 완전히 교체 - newData = receivedData; - break; - - case "merge": - // 기존 데이터와 병합 (ID 기반) - const existingMap = new Map(data.map((item) => [item.id, item])); - receivedData.forEach((item) => { - if (item.id && existingMap.has(item.id)) { - // 기존 데이터 업데이트 - existingMap.set(item.id, { ...existingMap.get(item.id), ...item }); - } else { - // 새 데이터 추가 - existingMap.set(item.id || Date.now() + Math.random(), item); - } - }); - newData = Array.from(existingMap.values()); - break; - } - - // 상태 업데이트 - setData(newData); - - // 총 아이템 수 업데이트 - setTotalItems(newData.length); - } catch (error) { - throw error; - } - }, - - getData: () => { - return data; - }, - }; - - // 화면 컨텍스트에 데이터 제공자/수신자로 등록 - useEffect(() => { - if (screenContext && component.id) { - screenContext.registerDataProvider(component.id, dataProvider); - screenContext.registerDataReceiver(component.id, dataReceiver); - - return () => { - screenContext.unregisterDataProvider(component.id); - screenContext.unregisterDataReceiver(component.id); - }; - } - }, [screenContext, component.id, data, selectedRows]); - - // 분할 패널 컨텍스트에 데이터 수신자로 등록 - // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) - const currentSplitPosition = - splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; - - useEffect(() => { - if (splitPanelContext && component.id && currentSplitPosition) { - const splitPanelReceiver = { - component_id: component.id, - component_type: "table-list", - receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => { - // 분할 패널에서 데이터 수신 처리 - const receiveInfo = { - count: incomingData.length, - mode, - position: currentSplitPosition, - }; - console.log("분할 패널 데이터 수신", receiveInfo); - - await dataReceiver.receiveData(incomingData, { - target_component_id: component.id, - target_component_type: "table" as const, - mode, - mapping_rules: [], - }); - }, - }; - - splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); - - return () => { - splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); - }; - } - }, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]); - - // 테이블 등록 (Context에 등록) - const tableId = `table-list-${component.id}`; - - useEffect(() => { - // tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음) - const columnsToRegister = (tableConfig.columns || []).filter( - (col) => col.visible !== false && col.columnName !== "__checkbox__", - ); - - if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) { - return; - } - - // 컬럼의 고유 값 조회 함수 - const getColumnUniqueValues = async (columnName: string) => { - const { apiClient } = await import("@/lib/api/client"); - - // 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도) - try { - const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); - if (response.data.success && response.data.data && response.data.data.length > 0) { - return response.data.data.map((item: any) => ({ - value: item.value_code, - label: item.value_label, - })); - } - } catch { - // 카테고리 API 실패 시 다음 단계로 - } - - // 2단계: DISTINCT API (백엔드에서 category_values/code_info 라벨 변환 포함) - try { - const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); - if (response.data.success && response.data.data && response.data.data.length > 0) { - let options = response.data.data.map((item: any) => ({ - value: String(item.value), - label: String(item.label), - })); - - // 프론트엔드 카테고리 매핑으로 추가 라벨 변환 - const mapping = categoryMappings[columnName]; - if (mapping && Object.keys(mapping).length > 0) { - options = options.map((opt: { value: string; label: string }) => ({ - value: opt.value, - label: mapping[opt.value]?.label || opt.label, - })); - } - - return options; - } - } catch { - // DISTINCT API 실패 시 다음 단계로 - } - - // 3단계: 현재 로드된 데이터에서 고유 값 추출 (최종 fallback) - const uniqueValuesMap = new Map(); - const mapping = categoryMappings[columnName]; - - data.forEach((row) => { - const value = row[columnName]; - if (value !== null && value !== undefined && value !== "") { - const strValue = String(value); - const nameField = row[`${columnName}_name`]; - const mappedLabel = mapping?.[strValue]?.label; - const label = mappedLabel || nameField || strValue; - uniqueValuesMap.set(strValue, label); - } - }); - - return Array.from(uniqueValuesMap.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label)); - }; - - const registration = { - table_id: tableId, - label: tableLabel || tableConfig.selectedTable, - table_name: tableConfig.selectedTable, - data_count: totalItems || data.length, // 초기 데이터 건수 포함 - columns: columnsToRegister.map((col) => ({ - column_name: col.columnName, - column_label: columnLabels[col.columnName] || col.displayName || col.columnName, - input_type: columnMeta[col.columnName]?.inputType || "text", - visible: col.visible !== false, - width: columnWidths[col.columnName] || col.width || 150, - sortable: col.sortable !== false, - filterable: col.searchable !== false, - })), - onFilterChange: setFilters, - onGroupChange: setGrouping, - onColumnVisibilityChange: setColumnVisibility, - getColumnUniqueValues, // 고유 값 조회 함수 등록 - onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 - // 틀고정 컬럼 관련 - frozen_column_count: frozenColumnCount, // 현재 틀고정 컬럼 수 - onFrozenColumnCountChange: (count: number) => { - setFrozenColumnCount(count); - // 체크박스 컬럼은 항상 틀고정에 포함 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; - // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 - const visibleCols = columnsToRegister - .filter((col) => col.visible !== false) - .map((col) => col.columnName); - const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; - setFrozenColumns(newFrozenColumns); - }, - // 탭 관련 정보 (탭 내부의 테이블인 경우) - parent_tab_id: parentTabId, - parent_tabs_component_id: parentTabsComponentId, - screen_id: screenId ? Number(screenId) : undefined, - }; - - registerTable(registration); - - return () => { - unregisterTable(tableId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - tableId, - tableConfig.selectedTable, - tableConfig.columns, - columnLabels, - columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) - categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용) - columnWidths, - tableLabel, - data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) - totalItems, // 전체 항목 수가 변경되면 재등록 - registerTable, - // unregisterTable은 의존성에서 제외 - 무한 루프 방지 - // unregisterTable 함수는 의존성이 없어 안정적임 - ]); - - // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 - useEffect(() => { - if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; - - const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; - const savedSort = localStorage.getItem(storageKey); - - if (savedSort) { - try { - const { column, direction } = JSON.parse(savedSort); - if (column && direction) { - setSortColumn(column); - setSortDirection(direction); - hasInitializedSort.current = true; - } - } catch { - // 정렬 상태 복원 실패 - 무시 - } - } - }, [tableConfig.selectedTable, userId]); - - // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 - useEffect(() => { - if (!tableConfig.selectedTable || !userId) return; - - const userKey = userId || "guest"; - const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; - const savedOrder = localStorage.getItem(storageKey); - - if (savedOrder) { - try { - const parsedOrder = JSON.parse(savedOrder); - setColumnOrder(parsedOrder); - - // 부모 컴포넌트에 초기 컬럼 순서 전달 - if (onSelectedRowsChange && parsedOrder.length > 0) { - // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) - const initialData = data.map((row: any) => { - const reordered: any = {}; - parsedOrder.forEach((colName: string) => { - if (colName in row) { - reordered[colName] = row[colName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - // 전역 저장소에 데이터 저장 - if (tableConfig.selectedTable) { - // 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - initialData, - parsedOrder.filter((col: string) => col !== "__checkbox__"), - sortColumn, - sortDirection, - { - filter_conditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, - search_term: searchTerm || undefined, - visible_columns: cols.map((col) => col.columnName), - column_labels: labels, - current_page: currentPage, - page_size: localPageSize, - total_items: totalItems, - }, - ); - } - - onSelectedRowsChange([], [], sortColumn ?? undefined, sortDirection, parsedOrder, initialData); - } - } catch { - // 컬럼 순서 파싱 실패 - 무시 - } - } - }, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행) - - const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { - enableBatchLoading: true, - preloadCommonCodes: true, - maxBatchSize: 5, - }); - - // ======================================== - // 컬럼 라벨 가져오기 - // ======================================== - - const fetchColumnLabels = useCallback(async () => { - if (!tableConfig.selectedTable) return; - - try { - // 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원) - const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}"); - const companyCode = currentUser.company_code || "UNKNOWN"; - const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`; - const cached = tableColumnCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { - const labels: Record = {}; - const meta: Record = {}; - - // 캐시된 inputTypes 맵 생성 - const inputTypeMap: Record = {}; - if (cached.inputTypes) { - cached.inputTypes.forEach((col: any) => { - inputTypeMap[col.column_name] = col.input_type; - }); - } - - cached.columns.forEach((col: any) => { - labels[col.column_name] = col.display_name || col.comment || col.column_name; - meta[col.column_name] = { - web_type: col.web_type, - codeInfo: col.code_info, - inputType: inputTypeMap[col.column_name], // 캐시된 inputType 사용! - }; - }); - - setColumnLabels(labels); - setColumnMeta(meta); - return; - } - - const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); - - // 컬럼 입력 타입 정보 가져오기 - const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); - const inputTypeMap: Record = {}; - inputTypes.forEach((col: any) => { - inputTypeMap[col.column_name] = col.input_type; - }); - - tableColumnCache.set(cacheKey, { - columns, - inputTypes, - timestamp: Date.now(), - }); - - const labels: Record = {}; - const meta: Record = {}; - - columns.forEach((col: any) => { - labels[col.column_name] = col.display_name || col.comment || col.column_name; - meta[col.column_name] = { - web_type: col.web_type, - codeInfo: col.code_info, - inputType: inputTypeMap[col.column_name], - }; - }); - - setColumnLabels(labels); - setColumnMeta(meta); - } catch (error) { - console.error("컬럼 라벨 가져오기 실패:", error); - } - }, [tableConfig.selectedTable]); - - // ======================================== - // 테이블 라벨 가져오기 - // ======================================== - - const fetchTableLabel = useCallback(async () => { - if (!tableConfig.selectedTable) return; - - try { - const cacheKey = `table_info_${tableConfig.selectedTable}`; - const cached = tableInfoCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { - const tables = cached.tables || []; - const tableInfo = tables.find((t: any) => t.table_name === tableConfig.selectedTable); - const label = - tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; - setTableLabel(label); - return; - } - - const tables = await tableTypeApi.getTables(); - - tableInfoCache.set(cacheKey, { - tables, - timestamp: Date.now(), - }); - - const tableInfo = tables.find((t: any) => t.table_name === tableConfig.selectedTable); - const label = - tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; - setTableLabel(label); - } catch (error) { - console.error("테이블 라벨 가져오기 실패:", error); - } - }, [tableConfig.selectedTable]); - - // ======================================== - // 카테고리 값 매핑 로드 - // ======================================== - - // 카테고리 컬럼 목록 추출 (useMemo로 최적화) - const categoryColumns = useMemo(() => { - const cols = Object.entries(columnMeta) - .filter(([_, meta]) => meta.inputType === "category") - .map(([columnName, _]) => columnName); - - return cols; - }, [columnMeta]); - - // 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행) - useEffect(() => { - const loadCategoryMappings = async () => { - if (!tableConfig.selectedTable) { - return; - } - - try { - const mappings: Record> = {}; - const apiClient = (await import("@/lib/api/client")).apiClient; - - // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) - const flattenTree = (items: any[], mapping: Record) => { - items.forEach((item: any) => { - if (item.value_code) { - mapping[String(item.value_code)] = { - label: item.value_label, - color: item.color, - }; - } - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.value_label, - color: item.color, - }; - } - if (item.children && Array.isArray(item.children) && item.children.length > 0) { - flattenTree(item.children, mapping); - } - }); - }; - - for (const columnName of categoryColumns) { - try { - let targetTable = tableConfig.selectedTable; - let targetColumn = columnName; - - if (columnName.includes(".")) { - const parts = columnName.split("."); - targetTable = parts[0]; - targetColumn = parts[1]; - } - - // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); - - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { - const mapping: Record = {}; - flattenTree(response.data.data, mapping); - - if (Object.keys(mapping).length > 0) { - mappings[columnName] = mapping; - } - } - } catch (error: any) { - console.error(`[TableList] 카테고리 값 로드 실패 [${columnName}]:`, error.message); - } - } - - // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 - // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 - const joinedColumns = - tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || []; - - // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) - const additionalJoinColumns = - tableConfig.columns - ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) - .map((col: any) => ({ - columnName: col.columnName, // 예: item_code_material - referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info - // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) - actualColumn: - col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") || - col.columnName, - })) || []; - - // 조인 테이블별로 그룹화 - const joinedTableColumns: Record = {}; - - // "테이블명.컬럼명" 형태 처리 - for (const joinedColumn of joinedColumns) { - const parts = joinedColumn.split("."); - if (parts.length !== 2) continue; - - const joinedTable = parts[0]; - const joinedColumnName = parts[1]; - - if (!joinedTableColumns[joinedTable]) { - joinedTableColumns[joinedTable] = []; - } - joinedTableColumns[joinedTable].push({ - columnName: joinedColumn, - actualColumn: joinedColumnName, - }); - } - - // additionalJoinInfo 형태 처리 - for (const col of additionalJoinColumns) { - if (!joinedTableColumns[col.referenceTable]) { - joinedTableColumns[col.referenceTable] = []; - } - joinedTableColumns[col.referenceTable].push({ - columnName: col.columnName, // 예: item_code_material - actualColumn: col.actualColumn, // 예: material - }); - } - - // 조인된 테이블별로 inputType 정보 가져오기 - const newJoinedColumnMeta: Record = {}; - - for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { - try { - // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) - const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); - - for (const col of columns) { - const inputTypeInfo = inputTypes.find((it: any) => it.column_name === col.actualColumn); - - // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) - newJoinedColumnMeta[col.columnName] = { - inputType: inputTypeInfo?.input_type, - }; - - // inputType이 category인 경우 카테고리 매핑 로드 - if (inputTypeInfo?.input_type === "category" && !mappings[col.columnName]) { - try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); - - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { - const mapping: Record = {}; - flattenTree(response.data.data, mapping); - - if (Object.keys(mapping).length > 0) { - mappings[col.columnName] = mapping; - } - } - } catch { - // 조인 테이블 카테고리 없음 - 무시 - } - } - } - } catch (error) { - console.error(`조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error); - } - } - - // 조인 컬럼 메타데이터 상태 업데이트 - if (Object.keys(newJoinedColumnMeta).length > 0) { - setJoinedColumnMeta(newJoinedColumnMeta); - } - - // 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping) - try { - const cascadingResponse = await apiClient.get( - `/category-value-cascading/table/${tableConfig.selectedTable}/mappings`, - ); - if (cascadingResponse.data.success && cascadingResponse.data.data) { - const cascadingMappings = cascadingResponse.data.data; - - // 각 자식 컬럼에 대한 매핑 추가 - for (const [columnName, columnMappings] of Object.entries( - cascadingMappings as Record>, - )) { - if (!mappings[columnName]) { - mappings[columnName] = {}; - } - // 연쇄관계 매핑 추가 - for (const item of columnMappings) { - mappings[columnName][item.code] = { - label: item.label, - color: undefined, // 연쇄관계는 색상 없음 - }; - } - } - } - } catch (cascadingError: any) { - // 연쇄관계 매핑이 없는 경우 무시 (404 등) - } - - setCategoryMappings(mappings); - if (Object.keys(mappings).length > 0) { - setCategoryMappingsKey((prev) => prev + 1); - } - } catch (error) { - console.error("카테고리 매핑 로드 실패:", error); - } - }; - - loadCategoryMappings(); - }, [ - tableConfig.selectedTable, - categoryColumns.length, - JSON.stringify(categoryColumns), - JSON.stringify(tableConfig.columns), - ]); - - // ======================================== - // 데이터 가져오기 - // ======================================== - - const fetchTableDataInternal = useCallback(async () => { - if (!tableConfig.selectedTable || isDesignMode) { - setData([]); - setTotalPages(0); - setTotalItems(0); - return; - } - - setLoading(true); - setError(null); - - try { - const page = tableConfig.pagination?.currentPage || currentPage; - const pageSize = localPageSize; - const sortBy = sortColumn || undefined; - const sortOrder = sortDirection; - const search = searchTerm || undefined; - - // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) - const linkedFilterValues: Record = {}; - let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 - let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - - if (splitPanelContext) { - // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) - const linkedFiltersConfig = splitPanelContext.linked_filters || []; - hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter: any) => - filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || - filter.targetColumn === tableConfig.selectedTable, - ); - - // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = - !!(splitPanelContext.selected_left_data && Object.keys(splitPanelContext.selected_left_data).length > 0); - - const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); - - // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) - // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 - for (const [key, value] of Object.entries(allLinkedFilters)) { - if (key.includes(".")) { - const [tableName, columnName] = key.split("."); - if (tableName === tableConfig.selectedTable) { - // 연결 필터는 코드 값이므로 equals 연산자 사용 - linkedFilterValues[columnName] = { value, operator: "equals" }; - hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 - } - } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) - linkedFilterValues[key] = { value, operator: "equals" }; - } - } - - // 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도 - // 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면 - // 동일한 컬럼명이 있는 경우 자동으로 필터링 적용 - if ( - splitPanelPosition === "right" && - hasSelectedLeftData && - Object.keys(linkedFilterValues).length === 0 && - !hasLinkedFiltersConfigured - ) { - const leftData = splitPanelContext.selected_left_data!; - const tableColumns = (tableConfig.columns || []).map((col) => col.columnName); - - // 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인 - for (const [colName, colValue] of Object.entries(leftData)) { - // null, undefined, 빈 문자열 제외 - if (colValue === null || colValue === undefined || colValue === "") continue; - // id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명) - if (colName === "id" || colName === "objid" || colName === "company_code") continue; - - // 현재 테이블에 동일한 컬럼이 있는지 확인 - if (tableColumns.includes(colName)) { - // 자동 컬럼 매칭도 equals 연산자 사용 - linkedFilterValues[colName] = { value: colValue, operator: "equals" }; - hasLinkedFiltersConfigured = true; - console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`); - } - } - - if (Object.keys(linkedFilterValues).length > 0) { - console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues); - } - } - - if (Object.keys(linkedFilterValues).length > 0) { - console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); - } - } - - // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 - // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) - if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { - console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시"); - setData([]); - setTotalItems(0); - setLoading(false); - return; - } - - // 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우 - // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) - if (isRelatedButtonTarget && !relatedButtonFilter) { - console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시"); - setData([]); - setTotalItems(0); - setLoading(false); - return; - } - - // 🆕 RelatedDataButtons 필터 값 준비 - const relatedButtonFilterValues: Record = {}; - if (relatedButtonFilter) { - relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { - value: relatedButtonFilter.filterValue, - operator: "equals", - }; - console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues); - } - - // 검색 필터, 연결 필터, RelatedDataButtons 필터 병합 - const filters = { - ...(Object.keys(searchValues).length > 0 ? searchValues : {}), - ...linkedFilterValues, - ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 - }; - const hasFilters = Object.keys(filters).length > 0; - - // 🆕 REST API 데이터 소스 처리 - const isRestApiTable = - tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); - - let response: any; - - if (isRestApiTable) { - // REST API 데이터 소스인 경우 - const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/); - const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; - - if (connectionId) { - console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId }); - - // REST API 연결 정보 가져오기 및 데이터 조회 - const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); - const restApiData = await ExternalRestApiConnectionAPI.fetchData( - connectionId, - undefined, // endpoint - 연결 정보에서 가져옴 - "response", // jsonPath - 기본값 response - ); - - response = { - data: restApiData.rows || [], - total: restApiData.total || restApiData.rows?.length || 0, - totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), - }; - - console.log("✅ [TableList] REST API 응답:", { - dataLength: response.data.length, - total: response.total, - }); - } else { - throw new Error("REST API 연결 ID를 찾을 수 없습니다."); - } - } else { - // 일반 DB 테이블인 경우 (기존 로직) - const entityJoinColumns = (tableConfig.columns || []) - .filter((col) => col.additionalJoinInfo) - .map((col) => ({ - sourceTable: col.additionalJoinInfo!.sourceTable, - sourceColumn: col.additionalJoinInfo!.sourceColumn, - joinAlias: col.additionalJoinInfo!.joinAlias, - referenceTable: col.additionalJoinInfo!.referenceTable, - })); - - // 🎯 화면별 엔티티 표시 설정 수집 - const screenEntityConfigs: Record = {}; - (tableConfig.columns || []) - .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0) - .forEach((col) => { - screenEntityConfigs[col.columnName] = { - displayColumns: col.entityDisplayConfig!.displayColumns, - separator: col.entityDisplayConfig!.separator || " - ", - sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable, - joinTable: col.entityDisplayConfig!.joinTable, - }; - }); - - // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) - let excludeFilterParam: any = undefined; - if (tableConfig.excludeFilter?.enabled) { - const excludeConfig = tableConfig.excludeFilter; - let filterValue: any = undefined; - - // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) - if (excludeConfig.filterColumn && excludeConfig.filterValueField) { - const fieldName = excludeConfig.filterValueField; - - // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) - if (propFormData && propFormData[fieldName]) { - filterValue = propFormData[fieldName]; - console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", { - field: fieldName, - value: filterValue, - }); - } - // 2순위: URL 파라미터에서 값 가져오기 - else if (typeof window !== "undefined") { - const urlParams = new URLSearchParams(window.location.search); - filterValue = urlParams.get(fieldName); - if (filterValue) { - console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", { - field: fieldName, - value: filterValue, - }); - } - } - // 3순위: 분할 패널 부모 데이터에서 값 가져오기 - if (!filterValue && splitPanelContext?.selected_left_data) { - filterValue = splitPanelContext.selected_left_data[fieldName]; - if (filterValue) { - console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", { - field: fieldName, - value: filterValue, - }); - } - } - } - - if (filterValue || !excludeConfig.filterColumn) { - excludeFilterParam = { - enabled: true, - referenceTable: excludeConfig.referenceTable, - referenceColumn: excludeConfig.referenceColumn, - sourceColumn: excludeConfig.sourceColumn, - filterColumn: excludeConfig.filterColumn, - filterValue: filterValue, - }; - console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); - } - } - - // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) - response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { - page, - size: pageSize, - sortBy, - sortOrder, - search: hasFilters ? filters : undefined, - enableEntityJoin: true, - additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, - screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 - dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 - excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 - company_code_override: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만) - }); - - // 실제 데이터의 item_number만 추출하여 중복 확인 - const itemNumbers = (response.data || []).map((item: any) => item.item_number); - const uniqueItemNumbers = [...new Set(itemNumbers)]; - - // console.log("✅ [TableList] API 응답 받음"); - // console.log(` - dataLength: ${response.data?.length || 0}`); - // console.log(` - total: ${response.total}`); - // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); - // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); - // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); - - setData(response.data || []); - setTotalPages(response.totalPages || 0); - setTotalItems(response.total || 0); - setError(null); - - // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - response.data || [], - cols.map((col) => col.columnName), - sortBy ?? null, - sortOrder, - { - filter_conditions: filters, - search_term: search, - visible_columns: cols.map((col) => col.columnName), - column_labels: labels, - current_page: page, - page_size: pageSize, - total_items: response.total || 0, - }, - ); - } - } catch (err: any) { - console.error("데이터 가져오기 실패:", err); - setData([]); - setTotalPages(0); - setTotalItems(0); - setError(err.message || "데이터를 불러오지 못했습니다."); - } finally { - setLoading(false); - } - }, [ - tableConfig.selectedTable, - tableConfig.pagination?.currentPage, - tableConfig.columns, - currentPage, - localPageSize, - sortColumn, - sortDirection, - searchTerm, - searchValues, - isDesignMode, - // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요) - splitPanelPosition, - currentSplitPosition, - splitPanelContext?.selected_left_data, - // 🆕 RelatedDataButtons 필터 추가 - relatedButtonFilter, - isRelatedButtonTarget, - // 🆕 프리뷰용 회사 코드 오버라이드 - companyCode, - ]); - - const fetchTableDataDebounced = useCallback( - (...args: Parameters) => { - const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`; - return debouncedApiCall(key, fetchTableDataInternal, 300)(...args); - }, - [fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection], - ); - - // ======================================== - // 이벤트 핸들러 - // ======================================== - - const handlePageChange = (newPage: number) => { - if (newPage < 1 || newPage > totalPages) return; - setCurrentPage(newPage); - if (tableConfig.pagination) { - tableConfig.pagination.currentPage = newPage; - } - if (onConfigChange) { - onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); - } - }; - - const handleSort = (column: string) => { - console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection }); - - let newSortColumn = column; - let newSortDirection: "asc" | "desc" = "asc"; - - if (sortColumn === column) { - newSortDirection = sortDirection === "asc" ? "desc" : "asc"; - setSortDirection(newSortDirection); - } else { - setSortColumn(column); - setSortDirection("asc"); - newSortColumn = column; - newSortDirection = "asc"; - } - - // 🎯 정렬 상태를 localStorage에 저장 (사용자별) - if (tableConfig.selectedTable && userId) { - const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; - try { - localStorage.setItem( - storageKey, - JSON.stringify({ - column: newSortColumn, - direction: newSortDirection, - }), - ); - console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection }); - } catch (error) { - console.error("❌ 정렬 상태 저장 실패:", error); - } - } - - console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection }); - console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange); - - // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 - if (onSelectedRowsChange) { - const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); - - // 1단계: 데이터를 정렬 - const sortedData = [...data].sort((a, b) => { - const aVal = a[newSortColumn]; - const bVal = b[newSortColumn]; - - // null/undefined 처리 - if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; - - // 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교) - const aNum = Number(aVal); - const bNum = Number(bVal); - - // 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우 - if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") { - return newSortDirection === "desc" ? bNum - aNum : aNum - bNum; - } - - // 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬) - const aStr = String(aVal).toLowerCase(); - const bStr = String(bVal).toLowerCase(); - - // 자연스러운 정렬 (숫자 포함 문자열) - const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" }); - return newSortDirection === "desc" ? -comparison : comparison; - }); - - // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const reorderedData = sortedData.map((row: any) => { - const reordered: any = {}; - cols.forEach((col) => { - if (col.columnName in row) { - reordered[col.columnName] = row[col.columnName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - console.log("✅ 정렬 정보 전달:", { - selectedRowsCount: selectedRows.size, - selectedRowsDataCount: selectedRowsData.length, - sortBy: newSortColumn, - sortOrder: newSortDirection, - columnOrder: columnOrder.length > 0 ? columnOrder : undefined, - tableDisplayDataCount: reorderedData.length, - firstRowAfterSort: reorderedData[0]?.[newSortColumn], - lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn], - }); - onSelectedRowsChange( - Array.from(selectedRows), - selectedRowsData, - newSortColumn, - newSortDirection, - columnOrder.length > 0 ? columnOrder : undefined, - reorderedData, - ); - - // 전역 저장소에 정렬된 데이터 저장 - if (tableConfig.selectedTable) { - const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)).filter( - (col) => col !== "__checkbox__", - ); - - // 컬럼 라벨 정보도 함께 저장 - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - reorderedData, - cleanColumnOrder, - newSortColumn, - newSortDirection, - { - filter_conditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, - search_term: searchTerm || undefined, - visible_columns: cols.map((col) => col.columnName), - column_labels: labels, - current_page: currentPage, - page_size: localPageSize, - total_items: totalItems, - }, - ); - } - } else { - console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); - } - }; - - const handleSearchValueChange = (columnName: string, value: any) => { - setSearchValues((prev) => ({ ...prev, [columnName]: value })); - }; - - const handleAdvancedSearch = () => { - setCurrentPage(1); - fetchTableDataDebounced(); - }; - - const handleClearAdvancedFilters = useCallback(() => { - console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues }); - - // 상태를 초기화하고 useEffect로 데이터 새로고침 - setSearchValues({}); - setCurrentPage(1); - - // 강제로 데이터 새로고침 트리거 - setRefreshTrigger((prev) => prev + 1); - }, [searchValues]); - - const handleRefresh = () => { - fetchTableDataDebounced(); - }; - - const getRowKey = (row: any, index: number) => { - return row.id || row.uuid || `row-${index}`; - }; - - const handleRowSelection = (rowKey: string, checked: boolean) => { - const newSelectedRows = new Set(selectedRows); - if (checked) { - newSelectedRows.add(rowKey); - } else { - newSelectedRows.delete(rowKey); - } - setSelectedRows(newSelectedRows); - - const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index))); - if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData, - }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 - if (typeof window !== "undefined") { - const event = new CustomEvent("tableListDataChange", { - detail: { - componentId: component.id, - tableName: tableConfig.selectedTable, - data: selectedRowsData, - selectedRows: Array.from(newSelectedRows), - }, - }); - window.dispatchEvent(event); - } - - // 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId) - if (tableConfig.selectedTable && selectedRowsData.length > 0) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - const modalItems = selectedRowsData.map((row, idx) => ({ - id: getRowKey(row, idx), - original_data: row, - additional_data: {}, - })); - useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); - }); - } else if (tableConfig.selectedTable && selectedRowsData.length === 0) { - // 선택 해제 시 데이터 제거 - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - useModalDataStore.getState().clearData(tableConfig.selectedTable!); - }); - } - - const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); - setIsAllSelected(allRowsSelected && filteredData.length > 0); - }; - - const handleSelectAll = (checked: boolean) => { - if (checked) { - const allKeys = filteredData.map((row, index) => getRowKey(row, index)); - const newSelectedRows = new Set(allKeys); - setSelectedRows(newSelectedRows); - setIsAllSelected(true); - - if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData: filteredData, - }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 - if (typeof window !== "undefined") { - const event = new CustomEvent("tableListDataChange", { - detail: { - componentId: component.id, - tableName: tableConfig.selectedTable, - data: filteredData, - selectedRows: Array.from(newSelectedRows), - }, - }); - window.dispatchEvent(event); - } - - // 🆕 modalDataStore에 전체 데이터 저장 - if (tableConfig.selectedTable && filteredData.length > 0) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - const modalItems = filteredData.map((row, idx) => ({ - id: getRowKey(row, idx), - original_data: row, - additional_data: {}, - })); - - useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); - }); - } - } else { - setSelectedRows(new Set()); - setIsAllSelected(false); - - if (onSelectedRowsChange) { - onSelectedRowsChange([], [], sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ selectedRows: [], selectedRowsData: [] }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 커스텀 이벤트 발생 (선택 해제) - if (typeof window !== "undefined") { - const event = new CustomEvent("tableListDataChange", { - detail: { - componentId: component.id, - tableName: tableConfig.selectedTable, - data: [], - selectedRows: [], - }, - }); - window.dispatchEvent(event); - } - - // 🆕 modalDataStore 데이터 제거 - if (tableConfig.selectedTable) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - useModalDataStore.getState().clearData(tableConfig.selectedTable!); - }); - } - } - }; - - const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { - // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) - const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]')) { - return; - } - - // 행 선택/해제 토글 - const rowKey = getRowKey(row, index); - const isCurrentlySelected = selectedRows.has(rowKey); - - handleRowSelection(rowKey, !isCurrentlySelected); - - // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - - console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", { - splitPanelPosition, - currentSplitPosition, - effectiveSplitPosition, - hasSplitPanelContext: !!splitPanelContext, - disableAutoDataTransfer: splitPanelContext?.disable_auto_data_transfer, - }); - - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disable_auto_data_transfer) { - if (!isCurrentlySelected) { - // 선택된 경우: 데이터 저장 - splitPanelContext.setSelectedLeftData(row); - console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", { - row, - parentDataMapping: splitPanelContext.parent_data_mapping, - }); - } else { - // 선택 해제된 경우: 데이터 초기화 - splitPanelContext.setSelectedLeftData(null); - console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화"); - } - } - - console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); - }; - - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) - const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { - e.stopPropagation(); - setFocusedCell({ rowIndex, colIndex }); - // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) - tableContainerRef.current?.focus(); - - // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 - // filteredData에서 해당 행의 데이터 가져오기 - const row = filteredData[rowIndex]; - if (!row) return; - - const rowKey = getRowKey(row, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - - // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disable_auto_data_transfer) { - // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 - if (!isCurrentlySelected) { - // 기존 선택 해제하고 새 행 선택 - setSelectedRows(new Set([rowKey])); - setIsAllSelected(false); - - // 분할 패널 컨텍스트에 데이터 저장 - splitPanelContext.setSelectedLeftData(row); - - // onSelectedRowsChange 콜백 호출 - if (onSelectedRowsChange) { - onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); - } - } - } - }; - - // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 - const handleCellDoubleClick = useCallback( - (rowIndex: number, colIndex: number, columnName: string, value: any) => { - // 체크박스 컬럼은 편집 불가 - if (columnName === "__checkbox__") return; - - // 🆕 편집 불가 컬럼 체크 - const column = visibleColumns.find((col) => col.columnName === columnName); - if (column?.editable === false) { - toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); - return; - } - - setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); - setEditingValue(value !== null && value !== undefined ? String(value) : ""); - setFocusedCell({ rowIndex, colIndex }); - }, - [visibleColumns], - ); - - // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) - const startEditingRef = useRef<() => void>(() => {}); - - // 🆕 각 컬럼의 고유값 목록 계산 - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - - if (data.length === 0) return result; - - (tableConfig.columns || []).forEach((column: { columnName: string }) => { - if (column.columnName === "__checkbox__") return; - - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const values = new Set(); - - data.forEach((row) => { - const val = row[mappedColumnName]; - if (val !== null && val !== undefined && val !== "") { - values.add(String(val)); - } - }); - - result[column.columnName] = Array.from(values).sort(); - }); - - return result; - }, [data, tableConfig.columns, joinColumnMapping]); - - // 🆕 헤더 필터 토글 - const toggleHeaderFilter = useCallback((columnName: string, value: string) => { - setHeaderFilters((prev) => { - const current = prev[columnName] || new Set(); - const newSet = new Set(current); - - if (newSet.has(value)) { - newSet.delete(value); - } else { - newSet.add(value); - } - - return { ...prev, [columnName]: newSet }; - }); - }, []); - - // 🆕 헤더 필터 초기화 - const clearHeaderFilter = useCallback((columnName: string) => { - setHeaderFilters((prev) => { - const newFilters = { ...prev }; - delete newFilters[columnName]; - return newFilters; - }); - setOpenFilterColumn(null); - }, []); - - // 🆕 모든 헤더 필터 초기화 - const clearAllHeaderFilters = useCallback(() => { - setHeaderFilters({}); - setOpenFilterColumn(null); - }, []); - - // 🆕 데이터 요약 (Total Summaries) 설정 - // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } - const summaryConfig = useMemo(() => { - const config: Record = {}; - - // tableConfig에서 summary 설정 읽기 - if (tableConfig.summaries) { - tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { - config[summary.columnName] = { type: summary.type, label: summary.label }; - }); - } - - return config; - }, [tableConfig.summaries]); - - // 🆕 요약 데이터 계산 - const summaryData = useMemo(() => { - if (Object.keys(summaryConfig).length === 0 || data.length === 0) { - return null; - } - - const result: Record = {}; - - Object.entries(summaryConfig).forEach(([columnName, config]) => { - const values = data - .map((row) => { - const mappedColumnName = joinColumnMapping[columnName] || columnName; - const val = row[mappedColumnName]; - return typeof val === "number" ? val : parseFloat(val); - }) - .filter((v) => !isNaN(v)); - - let value: number | string = 0; - let label = config.label || ""; - - switch (config.type) { - case "sum": - value = values.reduce((acc, v) => acc + v, 0); - label = label || "합계"; - break; - case "avg": - value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; - label = label || "평균"; - break; - case "count": - value = data.length; - label = label || "개수"; - break; - case "min": - value = values.length > 0 ? Math.min(...values) : 0; - label = label || "최소"; - break; - case "max": - value = values.length > 0 ? Math.max(...values) : 0; - label = label || "최대"; - break; - default: - value = 0; - } - - result[columnName] = { value, label }; - }); - - return result; - }, [data, summaryConfig, joinColumnMapping]); - - // 🆕 편집 취소 - const cancelEditing = useCallback(() => { - setEditingCell(null); - setEditingValue(""); - tableContainerRef.current?.focus(); - }, []); - - // 🆕 편집 저장 (즉시 저장 또는 배치 저장) - const saveEditing = useCallback(async () => { - if (!editingCell) return; - - const { rowIndex, columnName, originalValue } = editingCell; - const newValue = editingValue; - - // 값이 변경되지 않았으면 그냥 닫기 - if (String(originalValue ?? "") === newValue) { - setCellValidationError(rowIndex, columnName, null); // 에러 초기화 - cancelEditing(); - return; - } - - // 현재 행 데이터 가져오기 - const row = data[rowIndex]; - if (!row || !tableConfig.selectedTable) { - cancelEditing(); - return; - } - - // 🆕 유효성 검사 실행 - const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); - if (validationError) { - setCellValidationError(rowIndex, columnName, validationError); - toast.error(validationError); - // 편집 상태 유지 (에러 수정 가능하도록) - return; - } - // 유효성 통과 시 에러 초기화 - setCellValidationError(rowIndex, columnName, null); - - // 기본 키 필드 찾기 (id 또는 첫 번째 컬럼) - const primaryKeyField = tableConfig.primaryKey || "id"; - const primaryKeyValue = row[primaryKeyField]; - - if (primaryKeyValue === undefined || primaryKeyValue === null) { - console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField); - cancelEditing(); - return; - } - - // 🆕 배치 모드: 변경사항을 pending에 저장 - if (editMode === "batch") { - const changeKey = `${rowIndex}-${columnName}`; - setPendingChanges((prev) => { - const newMap = new Map(prev); - newMap.set(changeKey, { - rowIndex, - columnName, - originalValue, - newValue: newValue === "" ? null : newValue, - primaryKeyValue, - }); - return newMap; - }); - - // 로컬 수정 데이터 업데이트 (화면 표시용) - setLocalEditedData((prev) => ({ - ...prev, - [rowIndex]: { - ...(prev[rowIndex] || {}), - [columnName]: newValue === "" ? null : newValue, - }, - })); - - console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 }); - cancelEditing(); - return; - } - - // 🆕 즉시 모드: 바로 저장 - try { - const { apiClient } = await import("@/lib/api/client"); - - await apiClient.put("/dynamic-form/update-field", { - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: primaryKeyValue, - updateField: columnName, - updateValue: newValue === "" ? null : newValue, - }); - - // 데이터 새로고침 트리거 - setRefreshTrigger((prev) => prev + 1); - - console.log("✅ 셀 편집 저장 완료:", { columnName, newValue }); - } catch (error) { - console.error("❌ 셀 편집 저장 실패:", error); - } - - cancelEditing(); - }, [ - editingCell, - editingValue, - data, - tableConfig.selectedTable, - tableConfig.primaryKey, - cancelEditing, - editMode, - pendingChanges.size, - ]); - - // 🆕 배치 저장: 모든 변경사항 한번에 저장 - const saveBatchChanges = useCallback(async () => { - if (pendingChanges.size === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 모든 변경사항 저장 - const savePromises = Array.from(pendingChanges.values()).map((change) => - apiClient.put("/dynamic-form/update-field", { - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: change.primaryKeyValue, - updateField: change.columnName, - updateValue: change.newValue, - }), - ); - - await Promise.all(savePromises); - - // 상태 초기화 - setPendingChanges(new Map()); - setLocalEditedData({}); - setRefreshTrigger((prev) => prev + 1); - - toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); - console.log("✅ 배치 저장 완료:", pendingChanges.size, "개"); - } catch (error) { - console.error("❌ 배치 저장 실패:", error); - showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); - } - }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); - - // 🆕 배치 취소: 모든 변경사항 롤백 - const cancelBatchChanges = useCallback(() => { - if (pendingChanges.size === 0) return; - - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("변경사항이 취소되었습니다."); - console.log("🔄 배치 편집 취소"); - }, [pendingChanges.size]); - - // 🆕 특정 셀이 수정되었는지 확인 - const isCellModified = useCallback( - (rowIndex: number, columnName: string) => { - return pendingChanges.has(`${rowIndex}-${columnName}`); - }, - [pendingChanges], - ); - - // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) - const getDisplayValue = useCallback( - (row: any, rowIndex: number, columnName: string) => { - const localValue = localEditedData[rowIndex]?.[columnName]; - if (localValue !== undefined) { - return localValue; - } - return row[columnName]; - }, - [localEditedData], - ); - - // 🆕 유효성 검사 함수 - const validateValue = useCallback( - (value: any, columnName: string, row: any): string | null => { - // tableConfig.validation에서 컬럼별 규칙 가져오기 - const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; - if (!rules) return null; - - const strValue = value !== null && value !== undefined ? String(value) : ""; - const numValue = parseFloat(strValue); - - // 필수 검사 - if (rules.required && (!strValue || strValue.trim() === "")) { - return rules.customMessage || "필수 입력 항목입니다."; - } - - // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) - if (!strValue || strValue.trim() === "") return null; - - // 최소값 검사 - if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { - return rules.customMessage || `최소값은 ${rules.min}입니다.`; - } - - // 최대값 검사 - if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { - return rules.customMessage || `최대값은 ${rules.max}입니다.`; - } - - // 최소 길이 검사 - if (rules.minLength !== undefined && strValue.length < rules.minLength) { - return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; - } - - // 최대 길이 검사 - if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { - return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; - } - - // 패턴 검사 - if (rules.pattern && !rules.pattern.test(strValue)) { - return rules.customMessage || "입력 형식이 올바르지 않습니다."; - } - - // 커스텀 검증 - if (rules.validate) { - const customError = rules.validate(value, row); - if (customError) return customError; - } - - return null; - }, - [tableConfig], - ); - - // 🆕 셀 유효성 에러 여부 확인 - const getCellValidationError = useCallback( - (rowIndex: number, columnName: string): string | null => { - return validationErrors.get(`${rowIndex}-${columnName}`) || null; - }, - [validationErrors], - ); - - // 🆕 유효성 검사 에러 설정 - const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { - setValidationErrors((prev) => { - const newMap = new Map(prev); - const key = `${rowIndex}-${columnName}`; - if (error) { - newMap.set(key, error); - } else { - newMap.delete(key); - } - return newMap; - }); - }, []); - - // 🆕 모든 유효성 에러 초기화 - const clearAllValidationErrors = useCallback(() => { - setValidationErrors(new Map()); - }, []); - - // 🆕 Excel 내보내기 함수 - const exportToExcel = useCallback( - (exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 (선택된 행만 또는 전체) - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - // 선택된 행만 내보내기 - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } - - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 헤더 행 생성 - const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); - - // 데이터 행 생성 - const rows = exportData.map((row) => { - return exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - const value = row[mappedColumnName]; - - // 카테고리 매핑된 값 처리 - if (value !== null && value !== undefined) { - const valueStr = String(value); - - // 디버그 로그 (카테고리 값인 경우만) - if (valueStr.startsWith("CATEGORY_")) { - console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", { - columnName: col.columnName, - value: valueStr, - hasMappings: !!categoryMappings[col.columnName], - mappingsKeys: categoryMappings[col.columnName] - ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) - : [], - }); - } - - if (categoryMappings[col.columnName]) { - // 쉼표로 구분된 중복 값 처리 - if (valueStr.includes(",")) { - const values = valueStr - .split(",") - .map((v) => v.trim()) - .filter((v) => v); - const labels = values.map((v) => { - const mapping = categoryMappings[col.columnName][v]; - return mapping ? mapping.label : v; - }); - return labels.join(", "); - } - // 단일 값 처리 - const mapping = categoryMappings[col.columnName][valueStr]; - if (mapping) { - return mapping.label; - } - } - - return value; - } - - // null/undefined 처리 - return ""; - }); - }); - - // 워크시트 생성 - const wsData = [headers, ...rows]; - const ws = XLSX.utils.aoa_to_sheet(wsData); - - // 컬럼 너비 자동 조정 - const colWidths = exportColumns.map((col, idx) => { - const headerLength = headers[idx]?.length || 10; - const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length)); - return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; - }); - ws["!cols"] = colWidths; - - // 워크북 생성 - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); - - // 파일명 생성 - const _en = new Date(); - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`; - - // 파일 다운로드 - XLSX.writeFile(wb, fileName); - - toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); - console.log("✅ Excel 내보내기 완료:", fileName); - } catch (error) { - console.error("❌ Excel 내보내기 실패:", error); - showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); - } - }, - [ - filteredData, - selectedRows, - visibleColumns, - columnLabels, - joinColumnMapping, - categoryMappings, - tableLabel, - tableConfig.selectedTable, - getRowKey, - ], - ); - - // 🆕 행 확장/축소 토글 - const toggleRowExpand = useCallback( - async (rowKey: string, row: any) => { - setExpandedRows((prev) => { - const newSet = new Set(prev); - if (newSet.has(rowKey)) { - newSet.delete(rowKey); - } else { - newSet.add(rowKey); - // 상세 데이터 로딩 (아직 없는 경우) - if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { - loadDetailData(rowKey, row); - } - } - return newSet; - }); - }, - [detailData, tableConfig], - ); - - // 🆕 상세 데이터 로딩 - const loadDetailData = useCallback( - async (rowKey: string, row: any) => { - const masterDetailConfig = (tableConfig as any).masterDetail; - if (!masterDetailConfig?.detailTable) return; - - try { - const { apiClient } = await import("@/lib/api/client"); - - // masterKey 값 가져오기 - const masterKeyField = masterDetailConfig.masterKey || "id"; - const masterKeyValue = row[masterKeyField]; - - // 상세 테이블에서 데이터 조회 - const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { - page: 1, - size: 100, - search: { - [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, - }, - autoFilter: true, - }); - - const details = response.data?.data?.data || []; - - setDetailData((prev) => ({ - ...prev, - [rowKey]: details, - })); - - console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); - } catch (error) { - console.error("❌ 상세 데이터 로딩 실패:", error); - setDetailData((prev) => ({ - ...prev, - [rowKey]: [], - })); - } - }, - [tableConfig], - ); - - // 🆕 모든 행 확장/축소 - const expandAllRows = useCallback(() => { - if (expandedRows.size === filteredData.length) { - // 모두 축소 - setExpandedRows(new Set()); - } else { - // 모두 확장 - const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); - setExpandedRows(allKeys); - } - }, [expandedRows.size, filteredData, getRowKey]); - - // 🆕 Multi-Level Headers: Band 정보 계산 - const columnBandsInfo = useMemo(() => { - const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; - if (!bands || bands.length === 0) return null; - - // 각 band의 시작 인덱스와 colspan 계산 - const bandInfo = bands - .map((band) => { - const visibleBandColumns = band.columns.filter((colName) => - visibleColumns.some((vc) => vc.columnName === colName), - ); - - const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName)); - - return { - caption: band.caption, - columns: visibleBandColumns, - colSpan: visibleBandColumns.length, - startIndex, - }; - }) - .filter((b) => b.colSpan > 0); - - // Band에 포함되지 않은 컬럼 찾기 - const bandedColumns = new Set(bands.flatMap((b) => b.columns)); - const unbandedColumns = visibleColumns - .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) - .filter((c) => !bandedColumns.has(c.columnName)); - - return { - bands: bandInfo, - unbandedColumns, - hasBands: bandInfo.length > 0, - }; - }, [tableConfig, visibleColumns]); - - // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 - const loadCascadingOptions = useCallback( - async (columnName: string, parentColumnName: string, parentValue: any) => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return; - - const cacheKey = `${columnName}_${parentValue}`; - - // 이미 로딩 중이면 스킵 - if (loadingCascading[cacheKey]) return; - - // 이미 캐시된 데이터가 있으면 스킵 - if (cascadingOptions[cacheKey]) return; - - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); - - try { - const { apiClient } = await import("@/lib/api/client"); - - // API에서 연계 옵션 로딩 - const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { - page: 1, - size: 1000, - search: { - [cascadingConfig.parentKeyField || parentColumnName]: parentValue, - }, - autoFilter: true, - }); - - const items = response.data?.data?.data || []; - const options = items.map((item: any) => ({ - value: item[cascadingConfig.valueField || "id"], - label: item[cascadingConfig.labelField || "name"], - })); - - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: options, - })); - - console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); - } catch (error) { - console.error("❌ Cascading options 로딩 실패:", error); - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: [], - })); - } finally { - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); - } - }, - [tableConfig, cascadingOptions, loadingCascading], - ); - - // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 - const getCascadingOptions = useCallback( - (columnName: string, row: any): { value: string; label: string }[] => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return []; - - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue === undefined || parentValue === null) return []; - - const cacheKey = `${columnName}_${parentValue}`; - return cascadingOptions[cacheKey] || []; - }, - [tableConfig, cascadingOptions], - ); - - // 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조) - - // 🆕 Virtual Scrolling: 스크롤 핸들러 - const handleVirtualScroll = useCallback( - (e: React.UIEvent) => { - if (!isVirtualScrollEnabled) return; - setScrollTop(e.currentTarget.scrollTop); - }, - [isVirtualScrollEnabled], - ); - - // 🆕 State Persistence: 통합 상태 저장 - const saveTableState = useCallback(() => { - if (!tableStateKey) return; - - const state = { - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - frozenColumnCount, // 틀고정 컬럼 수 저장 - showGridLines, - headerFilters: Object.fromEntries( - Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), - ), - headerLikeFilters, // LIKE 검색 필터 저장 - pageSize: localPageSize, - timestamp: Date.now(), - }; - - try { - localStorage.setItem(tableStateKey, JSON.stringify(state)); - } catch (error) { - console.error("❌ 테이블 상태 저장 실패:", error); - } - }, [ - tableStateKey, - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - frozenColumnCount, - showGridLines, - headerFilters, - headerLikeFilters, - localPageSize, - ]); - - // 🆕 State Persistence: 통합 상태 복원 - const loadTableState = useCallback(() => { - if (!tableStateKey) return; - - try { - const saved = localStorage.getItem(tableStateKey); - if (!saved) return; - - const state = JSON.parse(saved); - - if (state.columnWidths) setColumnWidths(state.columnWidths); - if (state.columnOrder) setColumnOrder(state.columnOrder); - if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); - if (state.sortDirection) setSortDirection(state.sortDirection); - if (state.groupByColumns) setGroupByColumns(state.groupByColumns); - if (state.frozenColumns) { - // 체크박스 컬럼이 항상 포함되도록 보장 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; - const restoredFrozenColumns = - checkboxColumn && !state.frozenColumns.includes(checkboxColumn) - ? [checkboxColumn, ...state.frozenColumns] - : state.frozenColumns; - setFrozenColumns(restoredFrozenColumns); - } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 - if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); - if (state.headerFilters) { - const filters: Record> = {}; - Object.entries(state.headerFilters).forEach(([key, values]) => { - filters[key] = new Set(values as string[]); - }); - setHeaderFilters(filters); - } - if (state.headerLikeFilters) { - setHeaderLikeFilters(state.headerLikeFilters); - } - } catch (error) { - console.error("❌ 테이블 상태 복원 실패:", error); - } - }, [tableStateKey]); - - // 🆕 State Persistence: 상태 초기화 - const resetTableState = useCallback(() => { - if (!tableStateKey) return; - - try { - localStorage.removeItem(tableStateKey); - setColumnWidths({}); - setColumnOrder([]); - setSortColumn(null); - setSortDirection("asc"); - setGroupByColumns([]); - setFrozenColumns([]); - setShowGridLines(true); - setHeaderFilters({}); - toast.success("테이블 설정이 초기화되었습니다."); - } catch (error) { - console.error("❌ 테이블 상태 초기화 실패:", error); - } - }, [tableStateKey]); - - // 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원 - useEffect(() => { - loadTableState(); - }, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지) - - // 🆕 Real-Time Updates: WebSocket 연결 - const connectWebSocket = useCallback(() => { - if (!isRealTimeEnabled || !tableConfig.selectedTable) return; - - const wsUrl = - (tableConfig as any).wsUrl || - `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; - - try { - setWsConnectionStatus("connecting"); - wsRef.current = new WebSocket(wsUrl); - - wsRef.current.onopen = () => { - setWsConnectionStatus("connected"); - console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable); - }; - - wsRef.current.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - console.log("📨 WebSocket 메시지 수신:", message); - - switch (message.type) { - case "insert": - // 새 데이터 추가 - setRefreshTrigger((prev) => prev + 1); - toast.info("새 데이터가 추가되었습니다."); - break; - case "update": - // 데이터 업데이트 - setRefreshTrigger((prev) => prev + 1); - toast.info("데이터가 업데이트되었습니다."); - break; - case "delete": - // 데이터 삭제 - setRefreshTrigger((prev) => prev + 1); - toast.info("데이터가 삭제되었습니다."); - break; - case "refresh": - // 전체 새로고침 - setRefreshTrigger((prev) => prev + 1); - break; - default: - console.log("알 수 없는 메시지 타입:", message.type); - } - } catch (error) { - console.error("WebSocket 메시지 파싱 오류:", error); - } - }; - - wsRef.current.onclose = () => { - setWsConnectionStatus("disconnected"); - console.log("🔌 WebSocket 연결 종료"); - - // 자동 재연결 (5초 후) - if (isRealTimeEnabled) { - reconnectTimeoutRef.current = setTimeout(() => { - console.log("🔄 WebSocket 재연결 시도..."); - connectWebSocket(); - }, 5000); - } - }; - - wsRef.current.onerror = (error) => { - console.error("❌ WebSocket 오류:", error); - setWsConnectionStatus("disconnected"); - }; - } catch (error) { - console.error("WebSocket 연결 실패:", error); - setWsConnectionStatus("disconnected"); - } - }, [isRealTimeEnabled, tableConfig.selectedTable]); - - // 🆕 Real-Time Updates: 연결 관리 - useEffect(() => { - if (isRealTimeEnabled) { - connectWebSocket(); - } - - return () => { - // 정리 - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [isRealTimeEnabled, tableConfig.selectedTable]); - - // 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스) - useEffect(() => { - const timeoutId = setTimeout(() => { - saveTableState(); - }, 1000); // 1초 후 저장 (디바운스) - - return () => clearTimeout(timeoutId); - }, [ - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - showGridLines, - headerFilters, - ]); - - // 🆕 Clipboard: 선택된 데이터 복사 - const handleCopy = useCallback(async () => { - try { - // 선택된 행 데이터 가져오기 - let copyData: any[]; - - if (selectedRows.size > 0) { - // 선택된 행만 - copyData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } else if (focusedCell) { - // 포커스된 셀만 - const row = filteredData[focusedCell.rowIndex]; - if (row) { - const column = visibleColumns[focusedCell.colIndex]; - const value = row[column?.columnName]; - await navigator.clipboard.writeText(String(value ?? "")); - toast.success("셀 복사됨"); - return; - } - return; - } else { - toast.info("복사할 데이터를 선택해주세요."); - return; - } - - // TSV 형식으로 변환 (탭으로 구분) - const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); - const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); - const rows = copyData.map((row) => - exportColumns - .map((c) => { - const value = row[c.columnName]; - return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; - }) - .join("\t"), - ); - - const tsvContent = [headers.join("\t"), ...rows].join("\n"); - await navigator.clipboard.writeText(tsvContent); - - toast.success(`${copyData.length}행 복사됨`); - console.log("✅ 클립보드 복사:", copyData.length, "행"); - } catch (error) { - console.error("❌ 클립보드 복사 실패:", error); - toast.error("복사 실패"); - } - }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); - - // 🆕 전체 행 선택 - const handleSelectAllRows = useCallback(() => { - if (selectedRows.size === filteredData.length) { - // 전체 해제 - setSelectedRows(new Set()); - setIsAllSelected(false); - } else { - // 전체 선택 - const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); - setSelectedRows(allKeys); - setIsAllSelected(true); - } - }, [selectedRows.size, filteredData, getRowKey]); - - // 🆕 Context Menu: 열기 - const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { - e.preventDefault(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - rowIndex, - colIndex, - row, - }); - }, []); - - // 🆕 Context Menu: 닫기 - const closeContextMenu = useCallback(() => { - setContextMenu(null); - }, []); - - // 🆕 Context Menu: 외부 클릭 시 닫기 - useEffect(() => { - if (contextMenu) { - const handleClick = () => closeContextMenu(); - document.addEventListener("click", handleClick); - return () => document.removeEventListener("click", handleClick); - } - }, [contextMenu, closeContextMenu]); - - // 🆕 Search Panel: 통합 검색 실행 - const executeGlobalSearch = useCallback( - (term: string) => { - if (!term.trim()) { - setSearchHighlights(new Set()); - return; - } - - const lowerTerm = term.toLowerCase(); - const highlights = new Set(); - - filteredData.forEach((row, rowIndex) => { - visibleColumns.forEach((col, colIndex) => { - const value = row[col.columnName]; - if (value !== null && value !== undefined) { - const strValue = String(value).toLowerCase(); - if (strValue.includes(lowerTerm)) { - highlights.add(`${rowIndex}-${colIndex}`); - } - } - }); - }); - - setSearchHighlights(highlights); - - // 첫 번째 검색 결과로 포커스 이동 - if (highlights.size > 0) { - const firstHighlight = Array.from(highlights)[0]; - const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - toast.success(`${highlights.size}개 검색 결과`); - } else { - toast.info("검색 결과가 없습니다"); - } - }, - [filteredData, visibleColumns], - ); - - // 🆕 Search Panel: 다음 검색 결과로 이동 - const goToNextSearchResult = useCallback(() => { - if (searchHighlights.size === 0) return; - - const highlightArray = Array.from(searchHighlights).sort((a, b) => { - const [aRow, aCol] = a.split("-").map(Number); - const [bRow, bCol] = b.split("-").map(Number); - if (aRow !== bRow) return aRow - bRow; - return aCol - bCol; - }); - - if (!focusedCell) { - const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - return; - } - - const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; - const currentIndex = highlightArray.indexOf(currentKey); - const nextIndex = (currentIndex + 1) % highlightArray.length; - const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - }, [searchHighlights, focusedCell]); - - // 🆕 Search Panel: 이전 검색 결과로 이동 - const goToPrevSearchResult = useCallback(() => { - if (searchHighlights.size === 0) return; - - const highlightArray = Array.from(searchHighlights).sort((a, b) => { - const [aRow, aCol] = a.split("-").map(Number); - const [bRow, bCol] = b.split("-").map(Number); - if (aRow !== bRow) return aRow - bRow; - return aCol - bCol; - }); - - if (!focusedCell) { - const lastIdx = highlightArray.length - 1; - const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - return; - } - - const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; - const currentIndex = highlightArray.indexOf(currentKey); - const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; - const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - }, [searchHighlights, focusedCell]); - - // 🆕 Search Panel: 검색 초기화 - const clearGlobalSearch = useCallback(() => { - setGlobalSearchTerm(""); - setSearchHighlights(new Set()); - setIsSearchPanelOpen(false); - }, []); - - // 🆕 Filter Builder: 조건 추가 - const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: [ - ...group.conditions, - { - id: `cond-${Date.now()}`, - column: defaultColumn || "", - operator: "contains" as const, - value: "", - }, - ], - } - : group, - ), - ); - }, []); - - // 🆕 Filter Builder: 조건 삭제 - const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: group.conditions.filter((c) => c.id !== conditionId), - } - : group, - ), - ); - }, []); - - // 🆕 Filter Builder: 조건 업데이트 - const updateFilterCondition = useCallback( - (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)), - } - : group, - ), - ); - }, - [], - ); - - // 🆕 Filter Builder: 그룹 추가 - const addFilterGroup = useCallback((defaultColumn?: string) => { - setFilterGroups((prev) => [ - ...prev, - { - id: `group-${Date.now()}`, - logic: "AND" as const, - conditions: [ - { - id: `cond-${Date.now()}`, - column: defaultColumn || "", - operator: "contains" as const, - value: "", - }, - ], - }, - ]); - }, []); - - // 🆕 Filter Builder: 그룹 삭제 - const removeFilterGroup = useCallback((groupId: string) => { - setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); - }, []); - - // 🆕 Filter Builder: 그룹 로직 변경 - const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { - setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group))); - }, []); - - // 🆕 Filter Builder: 필터 적용 - const applyFilterBuilder = useCallback(() => { - // 유효한 조건 개수 계산 - let validConditions = 0; - filterGroups.forEach((group) => { - group.conditions.forEach((cond) => { - if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { - validConditions++; - } - }); - }); - setActiveFilterCount(validConditions); - setIsFilterBuilderOpen(false); - toast.success(`${validConditions}개 필터 조건 적용됨`); - }, [filterGroups]); - - // 🆕 Filter Builder: 필터 초기화 - const clearFilterBuilder = useCallback(() => { - setFilterGroups([]); - setActiveFilterCount(0); - toast.info("필터 초기화됨"); - }, []); - - // 🆕 Filter Builder: 조건 평가 함수 - const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { - const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; - const condValue = condition.value.toLowerCase(); - - switch (condition.operator) { - case "equals": - return strValue === condValue; - case "notEquals": - return strValue !== condValue; - case "contains": - return strValue.includes(condValue); - case "notContains": - return !strValue.includes(condValue); - case "startsWith": - return strValue.startsWith(condValue); - case "endsWith": - return strValue.endsWith(condValue); - case "greaterThan": - return parseFloat(strValue) > parseFloat(condValue); - case "lessThan": - return parseFloat(strValue) < parseFloat(condValue); - case "greaterOrEqual": - return parseFloat(strValue) >= parseFloat(condValue); - case "lessOrEqual": - return parseFloat(strValue) <= parseFloat(condValue); - case "isEmpty": - return strValue === "" || value === null || value === undefined; - case "isNotEmpty": - return strValue !== "" && value !== null && value !== undefined; - default: - return true; - } - }, []); - - // 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인 - const rowPassesFilterBuilder = useCallback( - (row: any): boolean => { - if (filterGroups.length === 0) return true; - - // 모든 그룹이 AND로 연결됨 (그룹 간) - return filterGroups.every((group) => { - const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), - ); - if (validConditions.length === 0) return true; - - if (group.logic === "AND") { - return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); - } else { - return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); - } - }); - }, - [filterGroups, evaluateCondition], - ); - - // 🆕 컬럼 드래그 시작 - const handleColumnDragStart = useCallback( - (e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled) return; - - setDraggedColumnIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", `col-${index}`); - }, - [isColumnDragEnabled], - ); - - // 🆕 컬럼 드래그 오버 - const handleColumnDragOver = useCallback( - (e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled || draggedColumnIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedColumnIndex) { - setDropTargetColumnIndex(index); - } - }, - [isColumnDragEnabled, draggedColumnIndex], - ); - - // 🆕 컬럼 드래그 종료 - const handleColumnDragEnd = useCallback(() => { - setDraggedColumnIndex(null); - setDropTargetColumnIndex(null); - }, []); - - // 🆕 컬럼 드롭 - const handleColumnDrop = useCallback( - (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { - handleColumnDragEnd(); - return; - } - - // 컬럼 순서 변경 - const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; - const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); - newOrder.splice(targetIndex, 0, movedColumn); - - setColumnOrder(newOrder); - toast.info("컬럼 순서가 변경되었습니다."); - console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); - - handleColumnDragEnd(); - }, - [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd], - ); - - // 🆕 행 드래그 시작 - const handleRowDragStart = useCallback( - (e: React.DragEvent, index: number) => { - if (!isDragEnabled) return; - - setDraggedRowIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(index)); - - // 드래그 이미지 설정 (반투명) - const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; - dragImage.style.opacity = "0.5"; - dragImage.style.position = "absolute"; - dragImage.style.top = "-1000px"; - document.body.appendChild(dragImage); - e.dataTransfer.setDragImage(dragImage, 0, 0); - setTimeout(() => document.body.removeChild(dragImage), 0); - }, - [isDragEnabled], - ); - - // 🆕 행 드래그 오버 - const handleRowDragOver = useCallback( - (e: React.DragEvent, index: number) => { - if (!isDragEnabled || draggedRowIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedRowIndex) { - setDropTargetIndex(index); - } - }, - [isDragEnabled, draggedRowIndex], - ); - - // 🆕 행 드래그 종료 - const handleRowDragEnd = useCallback(() => { - setDraggedRowIndex(null); - setDropTargetIndex(null); - }, []); - - // 🆕 행 드롭 - const handleRowDrop = useCallback( - async (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { - handleRowDragEnd(); - return; - } - - try { - // 로컬 데이터 재정렬 - const newData = [...filteredData]; - const [movedRow] = newData.splice(draggedRowIndex, 1); - newData.splice(targetIndex, 0, movedRow); - - // 서버에 순서 저장 (order_index 필드가 있는 경우) - const orderField = (tableConfig as any).orderField || "order_index"; - const hasOrderField = newData[0] && orderField in newData[0]; - - if (hasOrderField && tableConfig.selectedTable) { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 영향받는 행들의 순서 업데이트 - const updates = newData.map((row, idx) => ({ - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: row[primaryKeyField], - updateField: orderField, - updateValue: idx + 1, - })); - - // 배치 업데이트 - await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update))); - - toast.success("순서가 변경되었습니다."); - setRefreshTrigger((prev) => prev + 1); - } else { - // 로컬에서만 순서 변경 (저장 안함) - toast.info("순서가 변경되었습니다. (로컬만)"); - } - - console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); - } catch (error) { - console.error("❌ 행 순서 변경 실패:", error); - showErrorToast("행 순서 변경에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); - } - - handleRowDragEnd(); - }, - [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd], - ); - - // 🆕 PDF 내보내기 (인쇄용 HTML 생성) - const exportToPdf = useCallback( - (exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } - - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 인쇄용 HTML 생성 - const printContent = ` - - - - - ${tableLabel || tableConfig.selectedTable || "데이터"} - - - -

${tableLabel || tableConfig.selectedTable || "데이터 목록"}

-
- 출력일: ${new Date().toLocaleDateString("ko-KR")} | - 총 ${exportData.length}건 -
-
- - - ${exportColumns.map((col) => ``).join("")} - - - - ${exportData - .map( - (row) => ` - - ${exportColumns - .map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - let value = row[mappedColumnName]; - - // 카테고리 매핑 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) value = mapping.label; - } - - const meta = columnMeta[col.columnName]; - const inputType = meta?.inputType || (col as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return ``; - }) - .join("")} - - `, - ) - .join("")} - -
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
- - - `; - - // 새 창에서 인쇄 - const printWindow = window.open("", "_blank"); - if (printWindow) { - printWindow.document.write(printContent); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; - toast.success("인쇄 창이 열렸습니다."); - } else { - toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); - } - } catch (error) { - console.error("❌ PDF 내보내기 실패:", error); - showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); - } - }, - [ - filteredData, - selectedRows, - visibleColumns, - columnLabels, - joinColumnMapping, - categoryMappings, - columnMeta, - tableLabel, - tableConfig.selectedTable, - getRowKey, - ], - ); - - // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) - const handleEditKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case "Enter": - e.preventDefault(); - saveEditing(); - break; - case "Escape": - e.preventDefault(); - cancelEditing(); - break; - case "Tab": - e.preventDefault(); - saveEditing(); - // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 - break; - } - }, - [saveEditing, cancelEditing], - ); - - // 🆕 편집 입력 필드가 나타나면 자동 포커스 - useEffect(() => { - if (editingCell && editInputRef.current) { - editInputRef.current.focus(); - // select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가) - if (typeof editInputRef.current.select === "function") { - editInputRef.current.select(); - } - } - }, [editingCell]); - - // 🆕 포커스된 셀로 스크롤 - useEffect(() => { - if (focusedCell && tableContainerRef.current) { - const focusedCellElement = tableContainerRef.current.querySelector( - `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`, - ) as HTMLElement; - - if (focusedCellElement) { - focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); - } - } - }, [focusedCell]); - - // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(); - }; - - // ======================================== - // 컬럼 관련 (visibleColumns는 상단에서 정의됨) - // ======================================== - - // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 - const lastColumnOrderRef = useRef(""); - - useEffect(() => { - // console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { - // hasCallback: !!onSelectedRowsChange, - // visibleColumnsLength: visibleColumns.length, - // visibleColumnsNames: visibleColumns.map((c) => c.columnName), - // }); - - if (!onSelectedRowsChange) { - // console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); - return; - } - - if (visibleColumns.length === 0) { - // console.warn("⚠️ visibleColumns가 비어있습니다!"); - return; - } - - const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외 - - // console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); - - // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) - const columnOrderString = currentColumnOrder.join(","); - // console.log("🔍 [컬럼 순서] 비교:", { - // current: columnOrderString, - // last: lastColumnOrderRef.current, - // isDifferent: columnOrderString !== lastColumnOrderRef.current, - // }); - - if (columnOrderString === lastColumnOrderRef.current) { - // console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵"); - return; - } - - lastColumnOrderRef.current = columnOrderString; - // console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder); - - // 선택된 행 데이터 가져오기 - const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); - - // 화면에 표시된 데이터를 컬럼 순서대로 재정렬 - const reorderedData = data.map((row: any) => { - const reordered: any = {}; - visibleColumns.forEach((col) => { - if (col.columnName in row) { - reordered[col.columnName] = row[col.columnName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - onSelectedRowsChange( - Array.from(selectedRows), - selectedRowsData, - sortColumn ?? undefined, - sortDirection, - currentColumnOrder, - reorderedData, - ); - }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 - - // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) - const handleTableKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) - if (editingCell) return; - - if (!focusedCell || data.length === 0) return; - - const { rowIndex, colIndex } = focusedCell; - const maxRowIndex = data.length - 1; - const maxColIndex = visibleColumns.length - 1; - - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); - } - break; - case "ArrowDown": - e.preventDefault(); - if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } - break; - case "ArrowRight": - e.preventDefault(); - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } - break; - case "Enter": - e.preventDefault(); - // 현재 행 선택/해제 - const enterRow = data[rowIndex]; - if (enterRow) { - const rowKey = getRowKey(enterRow, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); - } - break; - case " ": // Space - e.preventDefault(); - // 체크박스 토글 - const spaceRow = data[rowIndex]; - if (spaceRow) { - const currentRowKey = getRowKey(spaceRow, rowIndex); - const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked); - } - break; - case "F2": - // 🆕 F2: 편집 모드 진입 - e.preventDefault(); - { - const col = visibleColumns[colIndex]; - if (col && col.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (col.editable === false) { - toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - const row = data[rowIndex]; - const mappedCol = joinColumnMapping[col.columnName] || col.columnName; - const val = row?.[mappedCol]; - setEditingCell({ - rowIndex, - colIndex, - columnName: col.columnName, - originalValue: val, - }); - setEditingValue(val !== null && val !== undefined ? String(val) : ""); - } - } - break; - case "b": - case "B": - // 🆕 Ctrl+B: 배치 편집 모드 토글 - if (e.ctrlKey) { - e.preventDefault(); - setEditMode((prev) => { - const newMode = prev === "immediate" ? "batch" : "immediate"; - if (newMode === "immediate" && pendingChanges.size > 0) { - // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 - const confirmDiscard = window.confirm( - `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`, - ); - if (confirmDiscard) { - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("배치 편집 모드 종료"); - return "immediate"; - } - return "batch"; - } - toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); - return newMode; - }); - } - break; - case "s": - case "S": - // 🆕 Ctrl+S: 배치 저장 - if (e.ctrlKey && editMode === "batch") { - e.preventDefault(); - saveBatchChanges(); - } - break; - case "c": - case "C": - // 🆕 Ctrl+C: 선택된 행/셀 복사 - if (e.ctrlKey) { - e.preventDefault(); - handleCopy(); - } - break; - case "v": - case "V": - // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) - if (e.ctrlKey && editingCell) { - // 기본 동작 허용 (input에서 처리) - } - break; - case "a": - case "A": - // 🆕 Ctrl+A: 전체 선택 - if (e.ctrlKey) { - e.preventDefault(); - handleSelectAllRows(); - } - break; - case "f": - case "F": - // 🆕 Ctrl+F: 통합 검색 패널 열기 - if (e.ctrlKey) { - e.preventDefault(); - setIsSearchPanelOpen(true); - } - break; - case "F3": - // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - break; - case "Home": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+Home: 첫 번째 셀로 - setFocusedCell({ rowIndex: 0, colIndex: 0 }); - } else { - // Home: 현재 행의 첫 번째 셀로 - setFocusedCell({ rowIndex, colIndex: 0 }); - } - break; - case "End": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+End: 마지막 셀로 - setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); - } else { - // End: 현재 행의 마지막 셀로 - setFocusedCell({ rowIndex, colIndex: maxColIndex }); - } - break; - case "PageUp": - e.preventDefault(); - // 10행 위로 - setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); - break; - case "PageDown": - e.preventDefault(); - // 10행 아래로 - setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); - break; - case "Escape": - e.preventDefault(); - // 포커스 해제 - setFocusedCell(null); - break; - case "Tab": - e.preventDefault(); - if (e.shiftKey) { - // Shift+Tab: 이전 셀 - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } else if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); - } - } else { - // Tab: 다음 셀 - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } else if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); - } - } - break; - default: - // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) - if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - const column = visibleColumns[colIndex]; - if (column && column.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (column.editable === false) { - toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - e.preventDefault(); - // 편집 시작 (현재 키를 초기값으로) - const row = data[rowIndex]; - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const value = row?.[mappedColumnName]; - - setEditingCell({ - rowIndex, - colIndex, - columnName: column.columnName, - originalValue: value, - }); - setEditingValue(e.key); // 입력한 키로 시작 - } - } - break; - } - }, - [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection], - ); - - const getColumnWidth = (column: ColumnConfig) => { - if (column.columnName === "__checkbox__") return 50; - if (column.width) return column.width; - - switch (column.format) { - case "date": - return 120; - case "number": - case "currency": - return 100; - case "boolean": - return 80; - default: - return 150; - } - }; - - const renderCheckboxHeader = () => { - if (!tableConfig.checkbox?.selectAll) return null; - - return ; - }; - - const renderCheckboxCell = (row: any, index: number) => { - const rowKey = getRowKey(row, index); - const isChecked = selectedRows.has(rowKey); - - return ( - handleRowSelection(rowKey, checked as boolean)} - aria-label={`행 ${index + 1} 선택`} - /> - ); - }; - - const formatCellValue = useCallback( - (value: any, column: ColumnConfig, rowData?: Record) => { - // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 - // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) - if (column.entityDisplayConfig && rowData) { - const displayColumns = - column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; - const separator = column.entityDisplayConfig.separator; - - if (displayColumns && displayColumns.length > 0) { - // 선택된 컬럼들의 값을 구분자로 조합 - const values = displayColumns - .map((colName: string) => { - // 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn} - // 예: manager 컬럼에서 user_name 선택 시 → manager_user_name - const joinedKey = `${column.columnName}_${colName}`; - let cellValue = rowData[joinedKey]; - - // fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) - if (cellValue === null || cellValue === undefined) { - cellValue = rowData[colName]; - } - - if (cellValue === null || cellValue === undefined) return ""; - return String(cellValue); - }) - .filter((v: string) => v !== ""); // 빈 값 제외 - - const result = values.join(separator || " - "); - if (result) { - return result; // 결과가 있으면 반환 - } - // 결과가 비어있으면 아래로 계속 진행 (원래 값 사용) - } - } - - // value가 null/undefined면 "-" 반환 - if (value === null || value === undefined) return "-"; - - // 🎯 writer 컬럼 자동 변환: user_id -> user_name - if (column.columnName === "writer" && rowData && rowData.writer_name) { - return rowData.writer_name; - } - - // 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기 - const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName]; - - // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) - const inputType = meta?.inputType || (column as any).inputType; - - // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만) - if (inputType === "image" && value && typeof value === "string") { - const firstImage = value.includes(",") ? value.split(",")[0].trim() : value; - const imageUrl = getFullImageUrl(firstImage); - return ( - 이미지 { - e.currentTarget.src = - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E"; - }} - /> - ); - } - - // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 - // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 - const isAttachmentColumn = - inputType === "file" || - inputType === "attachment" || - column.columnName === "attachments" || - column.columnName?.toLowerCase().includes("attachment") || - column.columnName?.toLowerCase().includes("file"); - - if (isAttachmentColumn) { - // JSONB 배열 또는 JSON 문자열 파싱 - let files: any[] = []; - try { - if (typeof value === "string" && value.trim()) { - const parsed = JSON.parse(value); - files = Array.isArray(parsed) ? parsed : []; - } else if (Array.isArray(value)) { - files = value; - } else if (value && typeof value === "object") { - // 단일 객체인 경우 배열로 변환 - files = [value]; - } - } catch (e) { - // 파싱 실패 시 빈 배열 - console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e }); - } - - if (!files || files.length === 0) { - return -; - } - - // 파일 이름 표시 (여러 개면 쉼표로 구분) - const { Paperclip } = require("lucide-react"); - const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); - - return ( -
- - - {fileNames} - - {files.length > 1 && ({files.length})} -
- ); - } - - // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) - if (inputType === "category") { - if (!value) return ""; - - // 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기 - // 1. 원래 컬럼명 (item_info.material) - // 2. 점(.) 뒤의 컬럼명만 (material) - let mapping = categoryMappings[column.columnName]; - - if (!mapping && column.columnName.includes(".")) { - const simpleColumnName = column.columnName.split(".").pop(); - if (simpleColumnName) { - mapping = categoryMappings[simpleColumnName]; - } - } - - const { Badge } = require("@/components/ui/badge"); - - // 다중 값 처리: 콤마로 구분된 값들을 분리 - const valueStr = String(value); - const values = valueStr.includes(",") - ? valueStr - .split(",") - .map((v) => v.trim()) - .filter((v) => v) - : [valueStr]; - - // 단일 값인 경우 (기존 로직) - if (values.length === 1) { - const categoryData = mapping?.[values[0]]; - const displayLabel = categoryData?.label || values[0]; - const displayColor = categoryData?.color; - - // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 - if (!displayColor || displayColor === "none" || !categoryData) { - return {displayLabel}; - } - - return ( - - {displayLabel} - - ); - } - - // 다중 값인 경우: 여러 배지 렌더링 - return ( -
- {values.map((val, idx) => { - const categoryData = mapping?.[val]; - const displayLabel = categoryData?.label || val; - const displayColor = categoryData?.color; - - // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 - if (!displayColor || displayColor === "none" || !categoryData) { - return ( - - {displayLabel} - {idx < values.length - 1 && ", "} - - ); - } - - return ( - - {displayLabel} - - ); - })} -
- ); - } - - // 코드 타입: 코드 값 → 코드명 변환 - if (inputType === "code" && meta?.codeInfo && value) { - try { - // optimizedConvertCode(categoryCode, codeValue) 순서 주의! - const convertedValue = optimizedConvertCode(meta.codeInfo, value); - // 변환에 성공했으면 변환된 코드명 반환 - if (convertedValue && convertedValue !== value) { - return convertedValue; - } - } catch (error) { - console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeInfo}, 값: ${value}`, error); - } - // 변환 실패 시 원본 코드 값 반환 - return String(value); - } - - // 날짜 타입 포맷팅 (yyyy-mm-dd) - if (inputType === "date" || inputType === "datetime") { - if (value) { - try { - const date = new Date(value); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - } catch { - return String(value); - } - } - return "-"; - } - - // 숫자 타입 포맷팅 (천단위 구분자 설정 확인) - if (inputType === "number" || inputType === "decimal") { - if (value !== null && value !== undefined && value !== "") { - const numValue = typeof value === "string" ? parseFloat(value) : value; - if (!isNaN(numValue)) { - // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 - if (column.thousandSeparator !== false) { - return numValue.toLocaleString("ko-KR"); - } - return String(numValue); - } - } - return String(value); - } - - switch (column.format) { - case "number": - if (value !== null && value !== undefined && value !== "") { - const numValue = typeof value === "string" ? parseFloat(value) : value; - if (!isNaN(numValue)) { - // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 - if (column.thousandSeparator !== false) { - return numValue.toLocaleString("ko-KR"); - } - return String(numValue); - } - } - return String(value); - case "date": - if (value) { - try { - const date = new Date(value); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - } catch { - return value; - } - } - return "-"; - case "currency": - if (typeof value === "number") { - // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 - if (column.thousandSeparator !== false) { - return `₩${value.toLocaleString()}`; - } - return `₩${value}`; - } - return value; - case "boolean": - return value ? "예" : "아니오"; - default: - return String(value); - } - }, - [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], - ); - - // ======================================== - // useEffect 훅 - // ======================================== - - // 필터 설정 localStorage 키 생성 (화면별로 독립적) - const filterSettingKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_filterSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); - - // 그룹 설정 localStorage 키 생성 (화면별로 독립적) - const groupSettingKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_groupSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); - - // 저장된 필터 설정 불러오기 - useEffect(() => { - if (!filterSettingKey || visibleColumns.length === 0) return; - - try { - const saved = localStorage.getItem(filterSettingKey); - if (saved) { - const savedFilters = JSON.parse(saved); - setVisibleFilterColumns(new Set(savedFilters)); - } else { - // 초기값: 빈 Set (아무것도 선택 안 함) - setVisibleFilterColumns(new Set()); - } - } catch (error) { - console.error("필터 설정 불러오기 실패:", error); - setVisibleFilterColumns(new Set()); - } - }, [filterSettingKey, visibleColumns]); - - // 필터 설정 저장 - const saveFilterSettings = useCallback(() => { - if (!filterSettingKey) return; - - try { - localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); - setIsFilterSettingOpen(false); - toast.success("검색 필터 설정이 저장되었습니다"); - - // 검색 값 초기화 - setSearchValues({}); - } catch (error) { - console.error("필터 설정 저장 실패:", error); - toast.error("설정 저장에 실패했습니다"); - } - }, [filterSettingKey, visibleFilterColumns]); - - // 필터 컬럼 토글 - const toggleFilterVisibility = useCallback((columnName: string) => { - setVisibleFilterColumns((prev) => { - const newSet = new Set(prev); - if (newSet.has(columnName)) { - newSet.delete(columnName); - } else { - newSet.add(columnName); - } - return newSet; - }); - }, []); - - // 전체 선택/해제 - const toggleAllFilters = useCallback(() => { - const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - const columnNames = filterableColumns.map((col) => col.columnName); - - if (visibleFilterColumns.size === columnNames.length) { - // 전체 해제 - setVisibleFilterColumns(new Set()); - } else { - // 전체 선택 - setVisibleFilterColumns(new Set(columnNames)); - } - }, [visibleFilterColumns, visibleColumns]); - - // 표시할 필터 목록 (선택된 컬럼만) - const activeFilters = useMemo(() => { - return visibleColumns - .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) - .map((col) => ({ - columnName: col.columnName, - label: columnLabels[col.columnName] || col.displayName || col.columnName, - type: col.format || "text", - })); - }, [visibleColumns, visibleFilterColumns, columnLabels]); - - // 그룹 설정 자동 저장 (localStorage) - useEffect(() => { - if (!groupSettingKey) return; - - try { - localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); - } catch (error) { - console.error("그룹 설정 저장 실패:", error); - } - }, [groupSettingKey, groupByColumns]); - - // 그룹 컬럼 토글 - const toggleGroupColumn = useCallback((columnName: string) => { - setGroupByColumns((prev) => { - if (prev.includes(columnName)) { - return prev.filter((col) => col !== columnName); - } else { - return [...prev, columnName]; - } - }); - }, []); - - // 사용자 옵션 저장 핸들러 - const handleTableOptionsSave = useCallback( - (config: { - columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>; - showGridLines: boolean; - viewMode: "table" | "card" | "grouped-card"; - }) => { - // 컬럼 순서 업데이트 - const newColumnOrder = config.columns.map((col) => col.columnName); - setColumnOrder(newColumnOrder); - - // 컬럼 너비 업데이트 - const newWidths: Record = {}; - config.columns.forEach((col) => { - if (col.width) { - newWidths[col.columnName] = col.width; - } - }); - setColumnWidths(newWidths); - - // 틀고정 컬럼 업데이트 - const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); - setFrozenColumns(newFrozenColumns); - - // 그리드선 표시 업데이트 - setShowGridLines(config.showGridLines); - - // 보기 모드 업데이트 - setViewMode(config.viewMode); - - // 컬럼 표시/숨기기 업데이트 - const newDisplayColumns = displayColumns.map((col) => { - const configCol = config.columns.find((c) => c.columnName === col.columnName); - if (configCol) { - return { ...col, visible: configCol.visible }; - } - return col; - }); - setDisplayColumns(newDisplayColumns); - - toast.success("테이블 옵션이 저장되었습니다"); - }, - [displayColumns], - ); - - // 그룹 펼치기/접기 토글 - const toggleGroupCollapse = useCallback((groupKey: string) => { - setCollapsedGroups((prev) => { - const newSet = new Set(prev); - if (newSet.has(groupKey)) { - newSet.delete(groupKey); - } else { - newSet.add(groupKey); - } - return newSet; - }); - }, []); - - // 그룹 해제 - const clearGrouping = useCallback(() => { - setGroupByColumns([]); - setCollapsedGroups(new Set()); - if (groupSettingKey) { - localStorage.removeItem(groupSettingKey); - } - toast.success("그룹이 해제되었습니다"); - }, [groupSettingKey]); - - // 데이터 그룹화 - const groupedData = useMemo((): GroupedData[] => { - if (groupByColumns.length === 0 || filteredData.length === 0) return []; - - const grouped = new Map(); - - filteredData.forEach((item) => { - // 그룹 키 생성: "통화:KRW > 단위:EA" - const keyParts = groupByColumns.map((col) => { - // 카테고리/엔티티 타입인 경우 _name 필드 사용 - const inputType = columnMeta?.[col]?.inputType; - let displayValue = item[col]; - - if (inputType === "category" || inputType === "entity" || inputType === "code") { - // _name 필드가 있으면 사용 (예: division_name, writer_name) - const nameField = `${col}_name`; - if (item[nameField] !== undefined && item[nameField] !== null) { - displayValue = item[nameField]; - } - } - - const label = columnLabels[col] || col; - return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`; - }); - const groupKey = keyParts.join(" > "); - - if (!grouped.has(groupKey)) { - grouped.set(groupKey, []); - } - grouped.get(groupKey)!.push(item); - }); - - return Array.from(grouped.entries()).map(([groupKey, items]) => { - const groupValues: Record = {}; - groupByColumns.forEach((col) => { - groupValues[col] = items[0]?.[col]; - }); - - // 🆕 그룹별 소계 계산 - const groupSummary: Record = {}; - - // 숫자형 컬럼에 대해 소계 계산 - (tableConfig.columns || []).forEach((col: { columnName: string }) => { - if (col.columnName === "__checkbox__") return; - - const colMeta = columnMeta?.[col.columnName]; - const inputType = colMeta?.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - if (isNumeric) { - const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v)); - - if (values.length > 0) { - const sum = values.reduce((a, b) => a + b, 0); - groupSummary[col.columnName] = { - sum, - avg: sum / values.length, - count: values.length, - }; - } - } - }); - - return { - groupKey, - groupValues, - items, - count: items.length, - summary: groupSummary, // 🆕 그룹별 소계 - }; - }); - }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); - - // 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우) - const summedData = useMemo(() => { - // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 - if (!groupSumConfig?.enabled || !groupSumConfig?.group_by_column) { - return filteredData; - } - - console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig); - - const groupByColumn = groupSumConfig.group_by_column; - const groupMap = new Map(); - - // 조인 컬럼인지 확인하고 실제 키 추론 - const getActualKey = (columnName: string, item: any): string => { - if (columnName.includes(".")) { - const [refTable, fieldName] = columnName.split("."); - const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); - const exactKey = `${inferredSourceColumn}_${fieldName}`; - if (item[exactKey] !== undefined) return exactKey; - if (fieldName === "item_name" || fieldName === "name") { - const aliasKey = `${inferredSourceColumn}_name`; - if (item[aliasKey] !== undefined) return aliasKey; - } - } - return columnName; - }; - - // 숫자 타입인지 확인하는 함수 - const isNumericValue = (value: any): boolean => { - if (value === null || value === undefined || value === "") return false; - const num = parseFloat(String(value)); - return !isNaN(num) && isFinite(num); - }; - - // 그룹핑 수행 - filteredData.forEach((item) => { - const actualKey = getActualKey(groupByColumn, item); - const groupValue = String(item[actualKey] || item[groupByColumn] || ""); - - if (!groupMap.has(groupValue)) { - // 첫 번째 항목을 기준으로 초기화 - groupMap.set(groupValue, { ...item, _groupCount: 1 }); - } else { - const existing = groupMap.get(groupValue); - existing._groupCount += 1; - - // 모든 키에 대해 숫자면 합산 - Object.keys(item).forEach((key) => { - const value = item[key]; - if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { - const numValue = parseFloat(String(value)); - const existingValue = parseFloat(String(existing[key] || 0)); - existing[key] = existingValue + numValue; - } - }); - - groupMap.set(groupValue, existing); - } - }); - - const result = Array.from(groupMap.values()); - console.log("🔗 [테이블리스트] 그룹별 합산 결과:", { - 원본개수: filteredData.length, - 그룹개수: result.length, - 그룹기준: groupByColumn, - }); - - return result; - }, [filteredData, groupSumConfig]); - - // 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData - const displayData = useMemo(() => { - return groupSumConfig?.enabled ? summedData : filteredData; - }, [groupSumConfig?.enabled, summedData, filteredData]); - - // 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치) - const virtualScrollInfo = useMemo(() => { - const dataSource = displayData; - if (!isVirtualScrollEnabled || dataSource.length === 0) { - return { - startIndex: 0, - endIndex: dataSource.length, - visibleData: dataSource, - topSpacerHeight: 0, - bottomSpacerHeight: 0, - totalHeight: dataSource.length * ROW_HEIGHT, - }; - } - - const containerHeight = scrollContainerRef.current?.clientHeight || 600; - const totalRows = dataSource.length; - const totalHeight = totalRows * ROW_HEIGHT; - - // 현재 보이는 행 범위 계산 - const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); - const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; - const endIndex = Math.min(totalRows, startIndex + visibleRowCount); - - return { - startIndex, - endIndex, - visibleData: dataSource.slice(startIndex, endIndex), - topSpacerHeight: startIndex * ROW_HEIGHT, - bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, - totalHeight, - }; - }, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]); - - // 저장된 그룹 설정 불러오기 - useEffect(() => { - if (!groupSettingKey || visibleColumns.length === 0) return; - - try { - const saved = localStorage.getItem(groupSettingKey); - if (saved) { - const savedGroups = JSON.parse(saved); - setGroupByColumns(savedGroups); - } - } catch (error) { - console.error("그룹 설정 불러오기 실패:", error); - } - }, [groupSettingKey, visibleColumns]); - - useEffect(() => { - fetchColumnLabels(); - fetchTableLabel(); - }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); - - // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성 - const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right"; - const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selected_left_data : null; - - useEffect(() => { - // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { - // isDesignMode, - // tableName: tableConfig.selectedTable, - // currentPage, - // sortColumn, - // sortDirection, - // }); - - if (!isDesignMode && tableConfig.selectedTable) { - fetchTableDataDebounced(); - } - }, [ - tableConfig.selectedTable, - currentPage, - localPageSize, - sortColumn, - sortDirection, - searchTerm, - searchValues, // 필터 값 변경 시에도 데이터 새로고침 - refreshKey, - refreshTrigger, // 강제 새로고침 트리거 - isDesignMode, - selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침 - // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 - ]); - - useEffect(() => { - if (tableConfig.refreshInterval && !isDesignMode) { - const interval = setInterval(() => { - fetchTableDataDebounced(); - }, tableConfig.refreshInterval * 1000); - - return () => clearInterval(interval); - } - }, [tableConfig.refreshInterval, isDesignMode]); - - // 🆕 전역 테이블 새로고침 이벤트 리스너 - useEffect(() => { - const handleRefreshTable = () => { - if (tableConfig.selectedTable && !isDesignMode) { - console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침"); - setRefreshTrigger((prev) => prev + 1); - } - }; - - window.addEventListener("refreshTable", handleRefreshTable); - - return () => { - window.removeEventListener("refreshTable", handleRefreshTable); - }; - }, [tableConfig.selectedTable, isDesignMode]); - - // 🆕 테이블명 변경 시 전역 레지스트리에서 확인 - useEffect(() => { - if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) { - const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable); - if (isTarget) { - console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable); - setIsRelatedButtonTarget(true); - } - } - }, [tableConfig.selectedTable]); - - // 🆕 RelatedDataButtons 등록/해제 이벤트 리스너 - useEffect(() => { - const handleRelatedButtonRegister = (event: CustomEvent) => { - const { targetTable } = event.detail || {}; - if (targetTable === tableConfig.selectedTable) { - console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable); - setIsRelatedButtonTarget(true); - } - }; - - const handleRelatedButtonUnregister = (event: CustomEvent) => { - const { targetTable } = event.detail || {}; - if (targetTable === tableConfig.selectedTable) { - console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable); - setIsRelatedButtonTarget(false); - setRelatedButtonFilter(null); - } - }; - - window.addEventListener("related-button-register" as any, handleRelatedButtonRegister); - window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister); - - return () => { - window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister); - window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister); - }; - }, [tableConfig.selectedTable]); - - // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) - useEffect(() => { - const handleRelatedButtonSelect = (event: CustomEvent) => { - const { targetTable, filterColumn, filterValue } = event.detail || {}; - - // 이 테이블이 대상 테이블인지 확인 - if (targetTable === tableConfig.selectedTable) { - // filterValue가 null이면 선택 해제 (빈 상태) - if (filterValue === null || filterValue === undefined) { - console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable); - setRelatedButtonFilter(null); - setIsRelatedButtonTarget(true); // 대상으로 등록은 유지 - } else { - console.log("📌 [TableList] RelatedDataButtons 필터 적용:", { - tableName: tableConfig.selectedTable, - filterColumn, - filterValue, - }); - setRelatedButtonFilter({ filterColumn, filterValue }); - setIsRelatedButtonTarget(true); - } - } - }; - - window.addEventListener("related-button-select" as any, handleRelatedButtonSelect); - - return () => { - window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect); - }; - }, [tableConfig.selectedTable]); - - // 🆕 relatedButtonFilter 변경 시 데이터 다시 로드 - useEffect(() => { - if (!isDesignMode) { - // relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거) - console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", { - relatedButtonFilter, - isRelatedButtonTarget, - }); - setRefreshTrigger((prev) => prev + 1); - } - }, [relatedButtonFilter, isDesignMode]); - - // 🎯 컬럼 너비 자동 계산 (내용 기반) - const calculateOptimalColumnWidth = useCallback( - (columnName: string, displayName: string): number => { - // 기본 너비 설정 - const MIN_WIDTH = 100; - const MAX_WIDTH = 400; - const PADDING = 48; // 좌우 패딩 + 여유 공간 - const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) - - // 헤더 텍스트 너비 계산 (대략 8px per character) - const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; - - // 데이터 셀 너비 계산 (상위 50개 샘플링) - const sampleSize = Math.min(50, data.length); - let maxDataWidth = headerWidth; - - for (let i = 0; i < sampleSize; i++) { - const cellValue = data[i]?.[columnName]; - if (cellValue !== null && cellValue !== undefined) { - const cellText = String(cellValue); - // 숫자는 좁게, 텍스트는 넓게 계산 - const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; - const charWidth = isNumber ? 8 : 9; - const cellWidth = cellText.length * charWidth + PADDING; - maxDataWidth = Math.max(maxDataWidth, cellWidth); - } - } - - // 최소/최대 범위 내로 제한 - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); - }, - [data], - ); - - // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 - useEffect(() => { - if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { - const timer = setTimeout(() => { - const storageKey = - tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null; - - // 1. localStorage에서 저장된 너비 불러오기 - let savedWidths: Record = {}; - if (storageKey) { - try { - const saved = localStorage.getItem(storageKey); - if (saved) { - savedWidths = JSON.parse(saved); - } - } catch (error) { - console.error("컬럼 너비 불러오기 실패:", error); - } - } - - // 2. 자동 계산 또는 저장된 너비 적용 - const newWidths: Record = {}; - let hasAnyWidth = false; - - visibleColumns.forEach((column) => { - // 체크박스 컬럼은 제외 (고정 48px) - if (column.columnName === "__checkbox__") return; - - // 저장된 너비가 있으면 우선 사용 - if (savedWidths[column.columnName]) { - newWidths[column.columnName] = savedWidths[column.columnName]; - hasAnyWidth = true; - } else { - // 저장된 너비가 없으면 자동 계산 - const optimalWidth = calculateOptimalColumnWidth( - column.columnName, - columnLabels[column.columnName] || column.displayName, - ); - newWidths[column.columnName] = optimalWidth; - hasAnyWidth = true; - } - }); - - if (hasAnyWidth) { - setColumnWidths(newWidths); - hasInitializedWidths.current = true; - } - }, 150); // DOM 렌더링 대기 - - return () => clearTimeout(timer); - } - }, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]); - - // ======================================== - // 페이지네이션 JSX - // ======================================== - - const paginationJSX = useMemo(() => { - if (!tableConfig.pagination?.enabled || isDesignMode) return null; - - // 페이지 크기 변경 핸들러 - const handlePageSizeChange = (newSize: number) => { - setLocalPageSize(newSize); - setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동 - if (onConfigChange) { - onConfigChange({ - ...tableConfig, - pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, - }); - } - }; - - const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100]; - - return ( -
- {/* 좌측: 페이지 크기 입력 */} -
- 표시: - { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); - handlePageSizeChange(value); - }} - onBlur={(e) => { - // 포커스 잃을 때 유효 범위로 조정 - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); - handlePageSizeChange(value); - }} - className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" - /> - -
- - {/* 중앙 페이지네이션 컨트롤 */} -
- - - - - {currentPage} / {totalPages || 1} - - - - -
- - {/* 우측 버튼 그룹 */} -
- {/* 🆕 내보내기 버튼 (Excel/PDF) */} - - - - - -
-
Excel
- - -
-
PDF/인쇄
- - -
- - - - {/* 새로고침 버튼 (하단 페이지네이션) */} - {(tableConfig.toolbar?.showPaginationRefresh ?? true) && ( - - )} -
-
- ); - }, [ - tableConfig.pagination, - tableConfig.toolbar?.showPaginationRefresh, - isDesignMode, - currentPage, - totalPages, - totalItems, - loading, - selectedRows.size, - exportToExcel, - exportToPdf, - localPageSize, - onConfigChange, - tableConfig, - ]); - - // ======================================== - // 렌더링 - // ======================================== - - const domProps = { - onClick: handleClick, - onDragStart: isDesignMode ? onDragStart : undefined, - onDragEnd: isDesignMode ? onDragEnd : undefined, - draggable: isDesignMode, - className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일 - style: componentStyle, - }; - - // 카드 모드 - if (tableConfig.displayMode === "card" && !isDesignMode) { - return ( -
- {loading ? ( -
- 로딩 중... -
- ) : error ? ( -
- {error} -
- ) : ( - - )} - {paginationJSX} -
- ); - } - - // SingleTableWithSticky 모드 - if (tableConfig.stickyHeader && !isDesignMode) { - return ( -
- {/* 필터 헤더는 TableSearchWidget으로 이동 */} - - {/* 그룹 표시 배지 */} - {groupByColumns.length > 0 && ( -
-
- 그룹: -
- {groupByColumns.map((col, idx) => ( - - {idx > 0 && } - - {columnLabels[col] || col} - - - ))} -
- -
-
- )} - -
- ) => { - const column = visibleColumns.find((c) => c.columnName === columnName); - return column ? formatCellValue(value, column, rowData) : String(value); - }} - getColumnWidth={getColumnWidth} - containerWidth={calculatedWidth} - /> -
- - {paginationJSX} -
- ); - } - - // 일반 테이블 모드 (네이티브 HTML 테이블) - return ( - <> -
- {/* 필터 헤더는 TableSearchWidget으로 이동 */} - - {/* 🆕 DevExpress 스타일 기능 툴바 */} -
- {/* 편집 모드 토글 */} - {(tableConfig.toolbar?.showEditMode ?? true) && ( -
- -
- )} - - {/* 내보내기 버튼들 */} - {((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && ( -
- {(tableConfig.toolbar?.showExcel ?? true) && ( - - )} - {(tableConfig.toolbar?.showPdf ?? true) && ( - - )} -
- )} - - {/* 복사 버튼 */} - {(tableConfig.toolbar?.showCopy ?? true) && ( -
- -
- )} - - {/* 선택 정보 */} - {selectedRows.size > 0 && ( -
- - {selectedRows.size}개 선택됨 - - -
- )} - - {/* 🆕 통합 검색 패널 */} - {(tableConfig.toolbar?.showSearch ?? true) && ( -
- {isSearchPanelOpen ? ( -
- setGlobalSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - executeGlobalSearch(globalSearchTerm); - } else if (e.key === "Escape") { - clearGlobalSearch(); - } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - } - }} - placeholder="검색어 입력... (Enter)" - className="border-input bg-background focus:ring-primary h-7 w-32 rounded border px-2 text-xs focus:ring-1 focus:outline-none sm:w-48" - autoFocus - /> - {searchHighlights.size > 0 && ( - {searchHighlights.size}개 - )} - - - -
- ) : ( - - )} -
- )} - - {/* 🆕 Filter Builder (고급 필터) 버튼 */} - {(tableConfig.toolbar?.showFilter ?? true) && ( -
- - {activeFilterCount > 0 && ( - - )} -
- )} - - {/* 새로고침 */} - {(tableConfig.toolbar?.showRefresh ?? true) && ( -
- -
- )} -
- - {/* 🆕 배치 편집 툴바 */} - {(editMode === "batch" || pendingChanges.size > 0) && ( -
-
- - 배치 편집 모드 - - {pendingChanges.size > 0 && ( - {pendingChanges.size}개 변경사항 - )} -
-
- - -
-
- )} - - {/* 그룹 표시 배지 */} - {groupByColumns.length > 0 && ( -
-
- 그룹: -
- {groupByColumns.map((col, idx) => ( - - {idx > 0 && } - - {columnLabels[col] || col} - - - ))} -
- -
-
- )} - - {/* 테이블 컨테이너 - 키보드 네비게이션 지원 */} -
- {/* 스크롤 영역 */} -
- {/* 테이블 */} - - {/* 헤더 (sticky) */} - - {/* 🆕 Multi-Level Headers (Column Bands) */} - {columnBandsInfo?.hasBands && ( - - {visibleColumns.map((column, colIdx) => { - // 이 컬럼이 속한 band 찾기 - const band = columnBandsInfo.bands.find( - (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx, - ); - - // band의 첫 번째 컬럼인 경우에만 렌더링 - if (band) { - return ( - - ); - } - - // band에 속하지 않은 컬럼 (개별 표시) - const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName)); - if (!isInAnyBand) { - return ( - - ); - } - - // band의 중간 컬럼은 렌더링하지 않음 - return null; - })} - - )} - - {visibleColumns.map((column, columnIndex) => { - const columnWidth = columnWidths[column.columnName]; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - // 🆕 Column Reordering 상태 - const isColumnDragging = draggedColumnIndex === columnIndex; - const isColumnDropTarget = dropTargetColumnIndex === columnIndex; - - return ( - - ); - })} - - - - {/* 바디 (스크롤) */} - - {loading ? ( - - - - ) : error ? ( - - - - ) : data.length === 0 ? ( - - - - ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( - // 그룹화된 렌더링 - groupedData.map((group) => { - const isCollapsed = collapsedGroups.has(group.groupKey); - return ( - - {/* 그룹 헤더 */} - - - - {/* 그룹 데이터 */} - {!isCollapsed && - group.items.map((row, index) => ( - handleRowClick(row, index, e)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const cellValue = row[mappedColumnName]; - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = - frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - return ( - - ); - })} - - ))} - {/* 🆕 그룹별 소계 행 */} - {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( - - {visibleColumns.map((column, colIndex) => { - const summary = group.summary?.[column.columnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - if (colIndex === 0 && column.columnName === "__checkbox__") { - return ( - - ); - } - - if (colIndex === 0 && column.columnName !== "__checkbox__") { - return ( - - ); - } - - if (summary) { - return ( - - ); - } - - return - )} - - ); - }) - ) : ( - // 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원 - <> - {/* 🆕 Virtual Scrolling: Top Spacer */} - {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( - - - )} - {/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */} - {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => { - // Virtual Scrolling에서는 실제 인덱스 계산 - const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; - const rowKey = getRowKey(row, index); - const isRowSelected = selectedRows.has(rowKey); - const isRowFocused = focusedCell?.rowIndex === index; - - // 🆕 Drag & Drop 상태 - const isDragging = draggedRowIndex === index; - const isDropTarget = dropTargetIndex === index; - - return ( - handleRowClick(row, index, e)} - role="row" - aria-selected={isRowSelected} - // 🆕 Drag & Drop 이벤트 - draggable={isDragEnabled} - onDragStart={(e) => handleRowDragStart(e, index)} - onDragOver={(e) => handleRowDragOver(e, index)} - onDragEnd={handleRowDragEnd} - onDrop={(e) => handleRowDrop(e, index)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 - const cellValue = - editMode === "batch" - ? getDisplayValue(row, index, mappedColumnName) - : row[mappedColumnName]; - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 셀 포커스 상태 - const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; - - // 🆕 배치 편집: 수정된 셀 여부 - const isModified = isCellModified(index, mappedColumnName); - - // 🆕 유효성 검사 에러 - const cellValidationError = getCellValidationError(index, mappedColumnName); - - // 🆕 검색 하이라이트 여부 - const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = - frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - return ( - - ); - })} - - ); - })} - {/* 🆕 Virtual Scrolling: Bottom Spacer */} - {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( - - - )} - - )} - - - {/* 🆕 데이터 요약 (Total Summaries) */} - {summaryData && Object.keys(summaryData).length > 0 && ( - - - {visibleColumns.map((column, colIndex) => { - const summary = summaryData[column.columnName]; - const columnWidth = columnWidths[column.columnName]; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return ( - - ); - })} - - - )} -
- {band.caption} - - {columnLabels[column.columnName] || column.columnName} -
{ columnRefs.current[column.columnName] = el; }} - className={cn( - "text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", - column.sortable !== false && - column.columnName !== "__checkbox__" && - "hover:bg-muted/70 cursor-pointer transition-colors", - isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", - // 🆕 Column Reordering 스타일 - isColumnDragEnabled && - column.columnName !== "__checkbox__" && - "cursor-grab active:cursor-grabbing", - isColumnDragging && "bg-primary/20 opacity-50", - isColumnDropTarget && "border-l-primary border-l-4", - )} - style={{ - textAlign: column.columnName === "__checkbox__" ? "center" : "center", - width: - column.columnName === "__checkbox__" - ? "48px" - : columnWidth - ? `${columnWidth}px` - : undefined, - minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - userSelect: "none", - backgroundColor: "hsl(var(--muted))", - ...(isFrozen && { left: `${leftPosition}px` }), - }} - // 🆕 Column Reordering 이벤트 - draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} - onDragStart={(e) => handleColumnDragStart(e, columnIndex)} - onDragOver={(e) => handleColumnDragOver(e, columnIndex)} - onDragEnd={handleColumnDragEnd} - onDrop={(e) => handleColumnDrop(e, columnIndex)} - onClick={() => { - if (isResizing.current) return; - if (column.sortable !== false && column.columnName !== "__checkbox__") { - handleSort(column.columnName); - } - }} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxHeader() - ) : ( -
- {/* 🆕 편집 불가 컬럼 표시 */} - {column.editable === false && ( - - - - )} - {columnLabels[column.columnName] || column.displayName} - {column.sortable !== false && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} - )} - {/* 🆕 헤더 필터 버튼 */} - {tableConfig.headerFilter !== false && - columnUniqueValues[column.columnName]?.length > 0 && ( - setOpenFilterColumn(open ? column.columnName : null)} - > - - - - e.stopPropagation()} - > -
-
- - 필터: {columnLabels[column.columnName] || column.displayName} - - {(headerFilters[column.columnName]?.size > 0 || - headerLikeFilters[column.columnName]) && ( - - )} -
- {/* LIKE 검색 입력 필드 */} -
- - { - setHeaderLikeFilters((prev) => ({ - ...prev, - [column.columnName]: e.target.value, - })); - }} - className="border-input bg-background placeholder:text-muted-foreground focus:ring-primary h-7 w-full rounded-md border pr-2 pl-7 text-xs focus:ring-1 focus:outline-none" - onClick={(e) => e.stopPropagation()} - /> -
- {/* 구분선 */} -
- 또는 값 선택: -
-
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { - const isSelected = headerFilters[column.columnName]?.has(val); - return ( -
toggleHeaderFilter(column.columnName, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
- ); - })} - {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( -
- ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 -
- )} -
-
-
-
- )} -
- )} - {/* 리사이즈 핸들 (체크박스 제외) */} - {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && ( -
e.stopPropagation()} // 정렬 클릭 방지 - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - - const thElement = columnRefs.current[column.columnName]; - if (!thElement) return; - - isResizing.current = true; - - const startX = e.clientX; - const startWidth = columnWidth || thElement.offsetWidth; - - // 드래그 중 텍스트 선택 방지 - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - - const handleMouseMove = (moveEvent: MouseEvent) => { - moveEvent.preventDefault(); - - const diff = moveEvent.clientX - startX; - const newWidth = Math.max(80, startWidth + diff); - - // 직접 DOM 스타일 변경 (리렌더링 없음) - if (thElement) { - thElement.style.width = `${newWidth}px`; - } - }; - - const handleMouseUp = () => { - // 최종 너비를 state에 저장 - if (thElement) { - const finalWidth = Math.max(80, thElement.offsetWidth); - setColumnWidths((prev) => { - const newWidths = { ...prev, [column.columnName]: finalWidth }; - - // 🎯 localStorage에 컬럼 너비 저장 (사용자별) - if (tableConfig.selectedTable && userId) { - const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; - try { - localStorage.setItem(storageKey, JSON.stringify(newWidths)); - } catch (error) { - console.error("컬럼 너비 저장 실패:", error); - } - } - - return newWidths; - }); - } - - // 텍스트 선택 복원 - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - - // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) - setTimeout(() => { - isResizing.current = false; - }, 100); - - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - /> - )} -
-
- -
로딩 중...
-
-
-
-
오류 발생
-
{error}
-
-
-
- -
데이터가 없습니다
-
- 조건을 변경하거나 새로운 데이터를 추가해보세요 -
-
-
-
toggleGroupCollapse(group.groupKey)} - > - {isCollapsed ? ( - - ) : ( - - )} - {group.groupKey} - ({group.count}건) -
-
- {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
- 소계 - - - 소계 ({group.count}건) - - - {summary.sum.toLocaleString()} - ; - })} -
-
handleCellClick(index, colIndex, e)} - onDoubleClick={() => - handleCellDoubleClick(index, colIndex, column.columnName, cellValue) - } - onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} - role="gridcell" - tabIndex={isCellFocused ? 0 : -1} - > - {/* 🆕 인라인 편집 모드 */} - {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex - ? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 - (() => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[ - column.columnName - ]; - const options = cascadingConfig - ? getCascadingOptions(column.columnName, row) - : []; - - // 부모 값이 변경되면 옵션 로딩 - if (cascadingConfig && options.length === 0) { - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue !== undefined && parentValue !== null) { - loadCascadingOptions( - column.columnName, - cascadingConfig.parentColumn, - parentValue, - ); - } - } - - // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 - const colMeta = columnMeta[column.columnName]; - const isCategoryType = - colMeta?.inputType === "category" || colMeta?.inputType === "code"; - const hasCategoryOptions = - categoryMappings[column.columnName] && - Object.keys(categoryMappings[column.columnName]).length > 0; - - if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { - const selectOptions = cascadingConfig - ? options - : Object.entries(categoryMappings[column.columnName] || {}).map( - ([value, info]) => ({ - value, - label: info.label, - }), - ); - - return ( - - ); - } - - // 일반 입력 필드 - return ( - setEditingValue(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={saveEditing} - className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" - style={{ - textAlign: isNumeric ? "right" : column.align || "left", - }} - /> - ); - })() - : column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
-
- {summary ? ( -
- {summary.label} - - {typeof summary.value === "number" - ? summary.value.toLocaleString("ko-KR", { - maximumFractionDigits: 2, - }) - : summary.value} - -
- ) : colIndex === 0 ? ( - 요약 - ) : null} -
-
-
- - {/* 페이지네이션 */} - {paginationJSX} -
- - {/* 필터 설정 다이얼로그 */} - - - - 검색 필터 설정 - - 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. - - - -
- {/* 전체 선택/해제 */} -
- col.columnName !== "__checkbox__").length && - visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 - } - onCheckedChange={toggleAllFilters} - /> - - - {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} - 개 - -
- - {/* 컬럼 목록 */} -
- {visibleColumns - .filter((col) => col.columnName !== "__checkbox__") - .map((col) => ( -
- toggleFilterVisibility(col.columnName)} - /> - -
- ))} -
- - {/* 선택된 컬럼 개수 안내 */} -
- {visibleFilterColumns.size === 0 ? ( - 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 - ) : ( - - 총 {visibleFilterColumns.size}개의 검색 필터가 - 표시됩니다 - - )} -
-
- - - - - -
-
- - {/* 🆕 Context Menu (우클릭 메뉴) */} - {contextMenu && ( -
e.stopPropagation()} - > -
- {/* 셀 복사 */} - - - {/* 행 복사 */} - - -
- - {/* 셀 편집 */} - {(() => { - const col = visibleColumns[contextMenu.colIndex]; - const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; - return ( - - ); - })()} - - {/* 행 선택/해제 */} - - -
- - {/* 행 삭제 */} - -
-
- )} - - {/* 🆕 Filter Builder 모달 */} - - - - 고급 필터 - - 여러 조건을 조합하여 데이터를 필터링합니다. - - - -
- {filterGroups.length === 0 ? ( -
- 필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요. -
- ) : ( - filterGroups.map((group, groupIndex) => ( -
-
-
- 조건 그룹 {groupIndex + 1} - -
- -
- -
- {group.conditions.map((condition) => ( -
- {/* 컬럼 선택 */} - - - {/* 연산자 선택 */} - - - {/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */} - {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( - updateFilterCondition(group.id, condition.id, "value", e.target.value)} - placeholder="값 입력" - className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" - /> - )} - - {/* 조건 삭제 */} - -
- ))} -
- - {/* 조건 추가 버튼 */} - -
- )) - )} - - {/* 그룹 추가 버튼 */} - -
- - - - - - -
-
- - {/* 테이블 옵션 모달 */} - setIsTableOptionsOpen(false)} - columns={visibleColumns.map((col) => ({ - columnName: col.columnName, - label: columnLabels[col.columnName] || col.displayName || col.columnName, - visible: col.visible !== false, - width: columnWidths[col.columnName], - frozen: frozenColumns.includes(col.columnName), - }))} - onSave={handleTableOptionsSave} - tableName={tableConfig.selectedTable || "table"} - userId={userId} - /> - - ); -}; - -export const TableListWrapper: React.FC = (props) => { - return ; -}; diff --git a/frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx b/frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx deleted file mode 100644 index 35bfdf86..00000000 --- a/frontend/lib/registry/components/table/_shared/TableListConfigPanel.tsx +++ /dev/null @@ -1,1361 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { tableTypeApi } from "@/lib/api/screen"; -import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react"; -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 { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; -import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; - -/** - * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) - */ -function SortableColumnRow({ - id, - col, - index, - isEntityJoin, - onLabelChange, - onWidthChange, - onRemove, -}: { - id: string; - col: ColumnConfig; - index: number; - isEntityJoin?: boolean; - onLabelChange: (value: string) => void; - onWidthChange: (value: number) => void; - onRemove: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); - const style = { transform: CSS.Transform.toString(transform), transition }; - - return ( -
-
- -
- {isEntityJoin ? ( - - ) : ( - #{index + 1} - )} - onLabelChange(e.target.value)} - placeholder="표시명" - className="h-6 min-w-0 flex-1 text-xs" - /> - onWidthChange(parseInt(e.target.value) || 100)} - placeholder="너비" - className="h-6 w-14 shrink-0 text-xs" - /> - -
- ); -} - -export interface TableListConfigPanelProps { - config: TableListConfig; - onChange: (config: Partial) => void; - screenTableName?: string; // 화면에 연결된 테이블명 - tableColumns?: any[]; // 테이블 컬럼 정보 -} - -/** - * TableList 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 - */ -export const TableListConfigPanel: React.FC = ({ - config, - onChange, - screenTableName, - tableColumns, -}) => { - // console.log("🔍 TableListConfigPanel props:", { - // config, - // configType: typeof config, - // configSelectedTable: config?.selectedTable, - // configPagination: config?.pagination, - // paginationEnabled: config?.pagination?.enabled, - // paginationPageSize: config?.pagination?.pageSize, - // configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object', - // screenTableName, - // tableColumns: tableColumns?.length, - // tableColumnsSample: tableColumns?.[0], - // }); - - const [availableTables, setAvailableTables] = useState>([]); - const [loadingTables, setLoadingTables] = useState(false); - const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태 - const [availableColumns, setAvailableColumns] = useState< - Array<{ columnName: string; dataType: string; label?: string; input_type?: string }> - >([]); - const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: Array<{ - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; - suggestedLabel: string; - }>; - joinTables: Array<{ - tableName: string; - currentDisplayColumn: string; - availableColumns: Array<{ - columnName: string; - columnLabel: string; - dataType: string; - description?: string; - }>; - }>; - }>({ availableColumns: [], joinTables: [] }); - - const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); - - // 🆕 제외 필터용 참조 테이블 컬럼 목록 - const [referenceTableColumns, setReferenceTableColumns] = useState< - Array<{ columnName: string; dataType: string; label?: string }> - >([]); - const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false); - - // 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지) - useEffect(() => { - // console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", { - // configPagination: config?.pagination, - // configPageSize: config?.pagination?.pageSize, - // }); - // 현재는 별도 내부 상태가 없어서 자동으로 UI가 업데이트됨 - // 만약 내부 상태가 있다면 여기서 동기화 처리 - }, [config]); - - // 🎯 엔티티 컬럼 표시 설정을 위한 상태 - const [entityDisplayConfigs, setEntityDisplayConfigs] = useState< - Record< - string, - { - sourceColumns: Array<{ column_name: string; display_name: string; data_type: string }>; - joinColumns: Array<{ column_name: string; display_name: string; data_type: string }>; - selectedColumns: string[]; - separator: string; - } - > - >({}); - - // 화면 테이블명이 있으면 자동으로 설정 (초기 한 번만) - useEffect(() => { - if (screenTableName && !config.selectedTable) { - // 기존 config의 모든 속성을 유지하면서 selectedTable만 추가/업데이트 - const updatedConfig = { - ...config, - selectedTable: screenTableName, - // 컬럼이 있으면 유지, 없으면 빈 배열 - columns: config.columns || [], - }; - onChange(updatedConfig); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [screenTableName]); // config.selectedTable이 없을 때만 실행되도록 의존성 최소화 - - // 테이블 목록 가져오기 - useEffect(() => { - const fetchTables = async () => { - setLoadingTables(true); - try { - // API 클라이언트를 사용하여 올바른 포트로 호출 - const response = await tableTypeApi.getTables(); - setAvailableTables( - response.map((table: any) => ({ - tableName: table.table_name, - displayName: table.display_name || table.table_name, - })), - ); - } catch (error) { - console.error("테이블 목록 가져오기 실패:", error); - } finally { - setLoadingTables(false); - } - }; - - fetchTables(); - }, []); - - // 🆕 실제 사용할 테이블 이름 계산 (customTableName 우선) - const targetTableName = React.useMemo(() => { - if (config.useCustomTable && config.customTableName) { - return config.customTableName; - } - return config.selectedTable || screenTableName; - }, [config.useCustomTable, config.customTableName, config.selectedTable, screenTableName]); - - // 선택된 테이블의 컬럼 목록 설정 - useEffect(() => { - console.log( - "🔍 useEffect 실행됨 - targetTableName:", - targetTableName, - "config.useCustomTable:", - config.useCustomTable, - "config.customTableName:", - config.customTableName, - "config.selectedTable:", - config.selectedTable, - "screenTableName:", - screenTableName, - ); - - if (!targetTableName) { - console.log("🔧 컬럼 목록 숨김 - 테이블이 선택되지 않음"); - setAvailableColumns([]); - return; - } - - // 🆕 customTableName이 설정된 경우 반드시 API에서 가져오기 - // tableColumns prop은 화면의 기본 테이블 컬럼이므로, customTableName 사용 시 무시 - const shouldUseTableColumnsProp = !config.useCustomTable && tableColumns && tableColumns.length > 0; - - if (shouldUseTableColumnsProp) { - const mappedColumns = tableColumns.map((column: any) => ({ - columnName: column.column_name || column.name, - dataType: column.data_type || column.type || "text", - label: column.label || column.display_name || column.column_label || column.column_name || column.name, - input_type: column.input_type, - })); - setAvailableColumns(mappedColumns); - - // selectedTable이 없으면 screenTableName으로 설정 - if (!config.selectedTable && screenTableName) { - onChange({ - ...config, - selectedTable: screenTableName, - columns: config.columns || [], - }); - } - } else { - // API에서 컬럼 정보 가져오기 - tableManagementApi 사용 - const fetchColumns = async () => { - console.log("🔧 API에서 컬럼 정보 가져오기 (tableManagementApi):", targetTableName); - try { - const result = await tableManagementApi.getColumnList(targetTableName); - console.log("🔧 tableManagementApi 응답:", result); - - if (result.success && result.data) { - // API 응답 구조: { columns: [...], total, page, ... } - const columns = Array.isArray(result.data) ? result.data : result.data.columns; - console.log("🔧 컬럼 배열:", columns); - - if (columns && Array.isArray(columns)) { - setAvailableColumns( - columns.map((col: any) => ({ - columnName: col.column_name, - dataType: col.data_type, - label: col.display_name || col.column_label || col.column_name, - input_type: col.input_type, - })), - ); - } else { - console.error("🔧 컬럼 배열을 찾을 수 없음:", result.data); - setAvailableColumns([]); - } - } else { - console.error("🔧 컬럼 조회 실패:", result.message); - setAvailableColumns([]); - } - } catch (error) { - console.error("컬럼 목록 가져오기 실패:", error); - setAvailableColumns([]); - } - }; - - fetchColumns(); - } - }, [targetTableName, config.useCustomTable, tableColumns]); - - // Entity 조인 컬럼 정보 가져오기 - targetTableName 사용 - useEffect(() => { - const fetchEntityJoinColumns = async () => { - if (!targetTableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } - - setLoadingEntityJoins(true); - try { - console.log("🔗 Entity 조인 컬럼 정보 가져오기:", targetTableName); - const result = await entityJoinApi.getEntityJoinColumns(targetTableName); - console.log("✅ Entity 조인 컬럼 응답:", result); - - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], - }); - } catch (error) { - console.error("❌ Entity 조인 컬럼 조회 오류:", error); - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); - } - }; - - fetchEntityJoinColumns(); - }, [targetTableName]); - - // 🆕 제외 필터용 참조 테이블 컬럼 가져오기 - useEffect(() => { - const fetchReferenceColumns = async () => { - const refTable = config.excludeFilter?.referenceTable; - if (!refTable) { - setReferenceTableColumns([]); - return; - } - - setLoadingReferenceColumns(true); - try { - console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable); - const result = await tableManagementApi.getColumnList(refTable); - if (result.success && result.data) { - // result.data는 { columns: [], total, page, size, totalPages } 형태 - const columns = result.data.columns || []; - setReferenceTableColumns( - columns.map((col: any) => ({ - columnName: col.column_name, - dataType: col.data_type || "text", - label: col.display_name || col.column_label || col.column_name, - })), - ); - console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개"); - } - } catch (error) { - console.error("❌ 참조 테이블 컬럼 조회 오류:", error); - setReferenceTableColumns([]); - } finally { - setLoadingReferenceColumns(false); - } - }; - - fetchReferenceColumns(); - }, [config.excludeFilter?.referenceTable]); - - // 🎯 엔티티 컬럼 자동 로드 - useEffect(() => { - const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig); - - if (!entityColumns || entityColumns.length === 0) return; - - // 각 엔티티 컬럼에 대해 자동으로 loadEntityDisplayConfig 호출 - entityColumns.forEach((column) => { - // 이미 로드된 경우 스킵 - if (entityDisplayConfigs[column.columnName]) { - return; - } - - loadEntityDisplayConfig(column); - }); - }, [config.columns]); - - const handleChange = (key: keyof TableListConfig, value: any) => { - // 기존 config와 병합하여 전달 (다른 속성 손실 방지) - onChange({ ...config, [key]: value }); - }; - - const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => { - // console.log("🔧 TableListConfigPanel handleNestedChange:", { - // parentKey, - // childKey, - // value, - // parentValue: config[parentKey], - // hasOnChange: !!onChange, - // onChangeType: typeof onChange, - // }); - - const parentValue = config[parentKey] as any; - // 전체 config와 병합하여 다른 속성 유지 - const newConfig = { - ...config, - [parentKey]: { - ...parentValue, - [childKey]: value, - }, - }; - - // console.log("📤 TableListConfigPanel onChange 호출:", newConfig); - onChange(newConfig); - }; - - // 컬럼 추가 - const addColumn = (columnName: string) => { - const existingColumn = config.columns?.find((col) => col.columnName === columnName); - if (existingColumn) return; - - // tableColumns → availableColumns 순서로 한국어 라벨 찾기 - const columnInfo = tableColumns?.find((col: any) => (col.column_name || col.name) === columnName); - const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - - const displayName = columnInfo?.label || columnInfo?.display_name || availableColumnInfo?.label || columnName; - - const newColumn: ColumnConfig = { - columnName, - displayName, - visible: true, - sortable: true, - searchable: true, - align: "left", - format: "text", - order: config.columns?.length || 0, - }; - - handleChange("columns", [...(config.columns || []), newColumn]); - }; - - // 🎯 조인 컬럼 추가 (조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리) - const addEntityColumn = (joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => { - console.log("🔗 조인 컬럼 추가 요청:", { - joinColumn, - joinAlias: joinColumn.joinAlias, - columnLabel: joinColumn.columnLabel, - tableName: joinColumn.tableName, - columnName: joinColumn.columnName, - }); - - const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias); - if (existingColumn) { - console.warn("⚠️ 이미 존재하는 컬럼:", joinColumn.joinAlias); - return; - } - - // 🎯 joinTables에서 sourceColumn 찾기 - const joinTableInfo = entityJoinColumns.joinTables?.find((jt: any) => jt.tableName === joinColumn.tableName); - const sourceColumn = (joinTableInfo as any)?.joinConfig?.sourceColumn || ""; - - console.log("🔍 조인 정보 추출:", { - tableName: joinColumn.tableName, - foundJoinTable: !!joinTableInfo, - sourceColumn, - joinConfig: (joinTableInfo as any)?.joinConfig, - }); - - // 조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리 (isEntityJoin: false) - const newColumn: ColumnConfig = { - columnName: joinColumn.joinAlias, - displayName: joinColumn.columnLabel, - visible: true, - sortable: true, - searchable: true, - align: "left", - format: "text", - order: config.columns?.length || 0, - isEntityJoin: false, // 조인 탭에서 추가하는 컬럼은 엔티티 타입이 아님 - // 🎯 추가 조인 정보 저장 - additionalJoinInfo: { - sourceTable: config.selectedTable || screenTableName || "", // 기준 테이블 (예: user_info) - sourceColumn: sourceColumn, // 기준 컬럼 (예: dept_code) - joinTables에서 추출 - referenceTable: joinColumn.tableName, // 참조 테이블 (예: dept_info) - joinAlias: joinColumn.joinAlias, // 조인 별칭 (예: dept_code_company_name) - }, - }; - - handleChange("columns", [...(config.columns || []), newColumn]); - console.log("✅ 조인 컬럼 추가 완료:", { - columnName: newColumn.columnName, - displayName: newColumn.displayName, - totalColumns: (config.columns?.length || 0) + 1, - }); - }; - - // 컬럼 제거 - const removeColumn = (columnName: string) => { - const updatedColumns = config.columns?.filter((col) => col.columnName !== columnName) || []; - handleChange("columns", updatedColumns); - }; - - // 컬럼 업데이트 - const updateColumn = (columnName: string, updates: Partial) => { - const updatedColumns = - config.columns?.map((col) => (col.columnName === columnName ? { ...col, ...updates } : col)) || []; - handleChange("columns", updatedColumns); - }; - - // 🎯 기존 컬럼들을 체크하여 엔티티 타입인 경우 isEntityJoin 플래그 설정 - // useRef로 이전 컬럼 개수를 추적하여 새 컬럼 추가 시에만 실행 - const prevColumnsLengthRef = React.useRef(0); - - useEffect(() => { - const currentLength = config.columns?.length || 0; - const prevLength = prevColumnsLengthRef.current; - - console.log("🔍 엔티티 컬럼 감지 useEffect 실행:", { - hasColumns: !!config.columns, - columnsCount: currentLength, - prevColumnsCount: prevLength, - hasTableColumns: !!tableColumns, - tableColumnsCount: tableColumns?.length || 0, - selectedTable: config.selectedTable, - }); - - if (!config.columns || !tableColumns || config.columns.length === 0) { - console.log("⚠️ 컬럼 또는 테이블 컬럼 정보가 없어서 엔티티 감지 스킵"); - prevColumnsLengthRef.current = currentLength; - return; - } - - // 컬럼 개수가 변경되지 않았고, 이미 체크한 적이 있으면 스킵 - if (currentLength === prevLength && prevLength > 0) { - console.log("ℹ️ 컬럼 개수 변경 없음, 엔티티 감지 스킵"); - return; - } - - const updatedColumns = config.columns.map((column) => { - // 이미 isEntityJoin이 설정된 경우 스킵 - if (column.isEntityJoin) { - console.log("✅ 이미 엔티티 플래그 설정됨:", column.columnName); - return column; - } - - // 테이블 컬럼 정보에서 해당 컬럼 찾기 - const tableColumn = tableColumns.find((tc) => tc.column_name === column.columnName); - console.log("🔍 컬럼 검색:", { - columnName: column.columnName, - found: !!tableColumn, - inputType: tableColumn?.input_type, - web_type: tableColumn?.web_type, - }); - - // 엔티티 타입인 경우 isEntityJoin 플래그 설정 (input_type 또는 web_type 확인) - if (tableColumn && (tableColumn.input_type === "entity" || tableColumn.web_type === "entity")) { - console.log("🎯 엔티티 컬럼 감지 및 플래그 설정:", { - columnName: column.columnName, - referenceTable: tableColumn.reference_table, - referenceTableAlt: tableColumn.referenceTable, - allTableColumnKeys: Object.keys(tableColumn), - }); - - return { - ...column, - isEntityJoin: true, - entityJoinInfo: { - sourceTable: config.selectedTable || screenTableName || "", - sourceColumn: column.columnName, - joinAlias: column.columnName, - }, - entityDisplayConfig: { - displayColumns: [], // 빈 배열로 초기화 - separator: " - ", - sourceTable: config.selectedTable || screenTableName || "", - joinTable: tableColumn.reference_table || tableColumn.referenceTable || "", - }, - }; - } - - return column; - }); - - // 변경사항이 있는 경우에만 업데이트 - const hasChanges = updatedColumns.some((col, index) => col.isEntityJoin !== config.columns![index].isEntityJoin); - - if (hasChanges) { - console.log("🎯 엔티티 컬럼 플래그 업데이트:", updatedColumns); - handleChange("columns", updatedColumns); - } else { - console.log("ℹ️ 엔티티 컬럼 변경사항 없음"); - } - - // 현재 컬럼 개수를 저장 - prevColumnsLengthRef.current = currentLength; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.columns?.length, tableColumns, config.selectedTable]); // 컬럼 개수 변경 시에만 실행 - - // 🎯 엔티티 컬럼의 표시 컬럼 정보 로드 - const loadEntityDisplayConfig = async (column: ColumnConfig) => { - const configKey = `${column.columnName}`; - - // 이미 로드된 경우 스킵 - if (entityDisplayConfigs[configKey]) return; - - if (!column.isEntityJoin) { - // 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제 - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - sourceColumns: [], - joinColumns: [], - selectedColumns: [], - separator: " - ", - }, - })); - return; - } - - // sourceTable 결정 우선순위: - // 1. entityDisplayConfig.sourceTable - // 2. entityJoinInfo.sourceTable - // 3. config.selectedTable - // 4. screenTableName - const sourceTable = - column.entityDisplayConfig?.sourceTable || - column.entityJoinInfo?.sourceTable || - config.selectedTable || - screenTableName; - - // sourceTable이 비어있으면 빈 상태로 설정 - if (!sourceTable) { - console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName); - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - sourceColumns: [], - joinColumns: [], - selectedColumns: column.entityDisplayConfig?.displayColumns || [], - separator: column.entityDisplayConfig?.separator || " - ", - }, - })); - return; - } - - let joinTable = column.entityDisplayConfig?.joinTable; - - // joinTable이 없으면 tableTypeApi로 조회해서 설정 - if (!joinTable) { - try { - console.log("🔍 tableTypeApi로 컬럼 정보 조회:", { - tableName: sourceTable, - columnName: column.columnName, - }); - - const columnList = await tableTypeApi.getColumns(sourceTable); - const columnInfo = columnList.find((col: any) => col.column_name === column.columnName); - - console.log("🔍 컬럼 정보 조회 결과:", { - columnInfo: columnInfo, - referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable, - referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn, - }); - - if (columnInfo?.reference_table || columnInfo?.referenceTable) { - joinTable = columnInfo.reference_table || columnInfo.referenceTable; - console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable); - - // entityDisplayConfig 업데이트 - const updatedConfig = { - ...column.entityDisplayConfig, - sourceTable: sourceTable, - joinTable: joinTable, - displayColumns: column.entityDisplayConfig?.displayColumns || [], - separator: column.entityDisplayConfig?.separator || " - ", - }; - - // 컬럼 설정 업데이트 - const updatedColumns = config.columns?.map((col) => - col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col, - ); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - } - } else { - console.warn("⚠️ tableTypeApi에서 조인 테이블 정보를 찾지 못함:", column.columnName); - } - } catch (error) { - console.error("tableTypeApi 컬럼 정보 조회 실패:", error); - } - } - - console.log("🔍 최종 추출한 값:", { sourceTable, joinTable }); - - try { - // 기본 테이블 컬럼 정보는 항상 로드 - const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable); - const sourceColumns = sourceResult.columns || []; - - // joinTable이 있으면 조인 테이블 컬럼도 로드 - let joinColumns: Array<{ column_name: string; display_name: string; data_type: string }> = []; - if (joinTable) { - try { - const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable); - joinColumns = joinResult.columns || []; - } catch (joinError) { - console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError); - // 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시 - } - } - - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - sourceColumns, - joinColumns, - selectedColumns: column.entityDisplayConfig?.displayColumns || [], - separator: column.entityDisplayConfig?.separator || " - ", - }, - })); - } catch (error) { - console.error("엔티티 표시 컬럼 정보 로드 실패:", error); - // 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제 - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - sourceColumns: [], - joinColumns: [], - selectedColumns: column.entityDisplayConfig?.displayColumns || [], - separator: column.entityDisplayConfig?.separator || " - ", - }, - })); - } - }; - - // 🎯 엔티티 표시 컬럼 선택 토글 - const toggleEntityDisplayColumn = (columnName: string, selectedColumn: string) => { - const configKey = `${columnName}`; - const localConfig = entityDisplayConfigs[configKey]; - if (!localConfig) return; - - const newSelectedColumns = localConfig.selectedColumns.includes(selectedColumn) - ? localConfig.selectedColumns.filter((col) => col !== selectedColumn) - : [...localConfig.selectedColumns, selectedColumn]; - - // 로컬 상태 업데이트 - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - ...prev[configKey], - selectedColumns: newSelectedColumns, - }, - })); - - // 실제 컬럼 설정도 업데이트 - const updatedColumns = config.columns?.map((col) => { - if (col.columnName === columnName && col.entityDisplayConfig) { - return { - ...col, - entityDisplayConfig: { - ...col.entityDisplayConfig, - displayColumns: newSelectedColumns, - }, - }; - } - return col; - }); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - } - }; - - // 🎯 엔티티 표시 구분자 업데이트 - const updateEntityDisplaySeparator = (columnName: string, separator: string) => { - const configKey = `${columnName}`; - const localConfig = entityDisplayConfigs[configKey]; - if (!localConfig) return; - - // 로컬 상태 업데이트 - setEntityDisplayConfigs((prev) => ({ - ...prev, - [configKey]: { - ...prev[configKey], - separator, - }, - })); - - // 실제 컬럼 설정도 업데이트 - const updatedColumns = config.columns?.map((col) => { - if (col.columnName === columnName && col.entityDisplayConfig) { - return { - ...col, - entityDisplayConfig: { - ...col.entityDisplayConfig, - separator, - }, - }; - } - return col; - }); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - } - }; - - // 컬럼 순서 변경 - const moveColumn = (columnName: string, direction: "up" | "down") => { - const columns = [...(config.columns || [])]; - const index = columns.findIndex((col) => col.columnName === columnName); - - if (index === -1) return; - - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= columns.length) return; - - [columns[index], columns[targetIndex]] = [columns[targetIndex], columns[index]]; - - // order 값 재정렬 - columns.forEach((col, idx) => { - col.order = idx; - }); - - handleChange("columns", columns); - }; - - return ( -
-
테이블 리스트 설정
- -
- {/* 툴바 버튼 설정 */} -
-
-

툴바 버튼 설정

-

테이블 상단에 표시할 버튼을 선택합니다

-
-
-
-
- handleNestedChange("toolbar", "showEditMode", checked)} - /> - -
-
- handleNestedChange("toolbar", "showExcel", checked)} - /> - -
-
- handleNestedChange("toolbar", "showPdf", checked)} - /> - -
-
- handleNestedChange("toolbar", "showCopy", checked)} - /> - -
-
- handleNestedChange("toolbar", "showSearch", checked)} - /> - -
-
- handleNestedChange("toolbar", "showFilter", checked)} - /> - -
-
- handleNestedChange("toolbar", "showRefresh", checked)} - /> - -
-
- handleNestedChange("toolbar", "showPaginationRefresh", checked)} - /> - -
-
-
- - {/* 체크박스 설정 */} -
-
-

체크박스 설정

-
-
-
-
- handleNestedChange("checkbox", "enabled", checked)} - /> - -
- - {config.checkbox?.enabled && ( - <> -
- handleNestedChange("checkbox", "selectAll", checked)} - /> - -
- -
- - -
- - )} -
-
- - {/* 가로 스크롤 및 컬럼 고정 */} -
-
-

가로 스크롤 및 컬럼 고정

-
-
-
- handleNestedChange("horizontalScroll", "enabled", checked)} - /> - -
- - {config.horizontalScroll?.enabled && ( -
-
- - - handleNestedChange("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8) - } - min={3} - max={20} - placeholder="8" - className="h-8" - /> -
이 수를 넘는 컬럼이 있으면 가로 스크롤이 생성됩니다
-
-
- )} -
- - {/* 컬럼 설정 */} - {/* 🎯 엔티티 컬럼 표시 설정 섹션 */} - {config.columns?.some((col) => col.isEntityJoin) && ( -
- {config.columns - ?.filter((col) => col.isEntityJoin && col.entityDisplayConfig) - .map((column) => ( -
-
- - {column.displayName || column.columnName} - -
- - {entityDisplayConfigs[column.columnName] ? ( -
- {/* 구분자 설정 */} -
- - updateEntityDisplaySeparator(column.columnName, e.target.value)} - className="h-6 w-full text-xs" - style={{ fontSize: "12px" }} - placeholder=" - " - /> -
- - {/* 표시 컬럼 선택 (다중 선택) */} -
- - {entityDisplayConfigs[column.columnName].sourceColumns.length === 0 && - entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? ( -
- 표시 가능한 컬럼이 없습니다. - {!column.entityDisplayConfig?.joinTable && ( -

- 테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다. -

- )} -
- ) : ( - - - - - - - - - 컬럼을 찾을 수 없습니다. - {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.column_name)} - className="text-xs" - > - - {col.display_name} - - ))} - - )} - {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.column_name)} - className="text-xs" - > - - {col.display_name} - - ))} - - )} - - - - - )} -
- - {/* 참조 테이블 미설정 안내 */} - {!column.entityDisplayConfig?.joinTable && - entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( -
- 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 - 테이블의 컬럼도 선택할 수 있습니다. -
- )} - - {/* 선택된 컬럼 미리보기 */} - {entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && ( -
- -
- {entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => ( - - - {colName} - - {idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && ( - - {entityDisplayConfigs[column.columnName].separator} - - )} - - ))} -
-
- )} -
- ) : ( -
컬럼 정보 로딩 중...
- )} -
- ))} -
- )} - - {!screenTableName ? ( -
-
-

테이블이 연결되지 않았습니다.

-

화면에 테이블을 연결한 후 컬럼을 설정할 수 있습니다.

-
-
- ) : availableColumns.length === 0 ? ( -
-
-

컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나

-

기본 설정 탭에서 테이블을 설정해주세요.

-

현재 화면 테이블: {screenTableName}

-
-
- ) : ( - <> -
-
-

컬럼 선택

-

표시할 컬럼을 선택하세요

-
-
- {availableColumns.length > 0 ? ( -
- {availableColumns.map((column) => { - const isAdded = config.columns?.some((c) => c.columnName === column.columnName); - return ( -
{ - if (isAdded) { - // 컬럼 제거 - handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); - } else { - // 컬럼 추가 - addColumn(column.columnName); - } - }} - > - { - if (isAdded) { - handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); - } else { - addColumn(column.columnName); - } - }} - className="pointer-events-none h-3.5 w-3.5" - /> - - {column.label || column.columnName} - {column.input_type || column.dataType} -
- ); - })} -
- ) : ( -
컬럼 정보를 불러오는 중...
- )} -
- - {/* Entity 조인 컬럼 추가 */} - {entityJoinColumns.joinTables.length > 0 && ( -
-
-

Entity 조인 컬럼

-

연관 테이블의 컬럼을 선택하세요

-
-
-
- {entityJoinColumns.joinTables.map((joinTable, tableIndex) => ( -
-
- - {joinTable.tableName} - - {joinTable.currentDisplayColumn} - -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = entityJoinColumns.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); - - const isAlreadyAdded = config.columns?.some( - (col) => col.columnName === matchingJoinColumn?.joinAlias, - ); - - if (!matchingJoinColumn) return null; - - return ( -
{ - if (isAlreadyAdded) { - // 컬럼 제거 - handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); - } else { - // 컬럼 추가 - addEntityColumn(matchingJoinColumn); - } - }} - > - { - if (isAlreadyAdded) { - handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); - } else { - addEntityColumn(matchingJoinColumn); - } - }} - className="pointer-events-none h-3.5 w-3.5" - /> - - {column.columnLabel} - {column.dataType} -
- ); - })} -
-
- ))} -
-
- )} - - )} - - {/* 선택된 컬럼 순서 변경 (DnD) */} - {config.columns && config.columns.length > 0 && ( -
-
-

표시할 컬럼 ({config.columns.length}개 선택)

-

- 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다 -

-
-
- { - const { active, over } = event; - if (!over || active.id === over.id) return; - const columns = [...(config.columns || [])]; - const oldIndex = columns.findIndex((c) => c.columnName === active.id); - const newIndex = columns.findIndex((c) => c.columnName === over.id); - if (oldIndex !== -1 && newIndex !== -1) { - const reordered = arrayMove(columns, oldIndex, newIndex); - reordered.forEach((col, idx) => { col.order = idx; }); - handleChange("columns", reordered); - } - }} - > - c.columnName)} - strategy={verticalListSortingStrategy} - > -
- {(config.columns || []).map((column, idx) => { - const resolvedLabel = - column.displayName && column.displayName !== column.columnName - ? column.displayName - : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; - - const colWithLabel = { ...column, displayName: resolvedLabel }; - return ( - updateColumn(column.columnName, { displayName: value })} - onWidthChange={(value) => updateColumn(column.columnName, { width: value })} - onRemove={() => removeColumn(column.columnName)} - /> - ); - })} -
-
-
-
- )} - - {/* 🆕 데이터 필터링 설정 */} -
-
-

데이터 필터링

-

특정 컬럼 값으로 데이터를 필터링합니다

-
-
- - ({ - columnName: col.columnName, - columnLabel: col.label || col.columnName, - dataType: col.dataType, - input_type: col.input_type, // 🆕 실제 input_type 전달 - }) as any, - )} - config={config.dataFilter} - onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} - /> -
- -
-
- ); -}; diff --git a/frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx b/frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx deleted file mode 100644 index 841aec5b..00000000 --- a/frontend/lib/registry/components/table/_shared/V2TableListComponent.tsx +++ /dev/null @@ -1,7222 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; -import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes"; -import { tableTypeApi } from "@/lib/api/screen"; -import { entityJoinApi } from "@/lib/api/entityJoin"; -import { codeCache } from "@/lib/caching/codeCache"; -import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; -import { getFullImageUrl } from "@/lib/api/client"; -import { getFilePreviewUrl } from "@/lib/api/file"; -import { Button } from "@/components/ui/button"; -import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; -import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; -import { useTabId } from "@/contexts/TabIdContext"; -import { formatNumber as centralFormatNumber, formatCurrency as centralFormatCurrency } from "@/lib/formatting"; -import { filterDOMProps } from "@/lib/utils/domPropsFilter"; - -// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 -// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 -// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시 -const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { - const [imgSrc, setImgSrc] = React.useState(null); - const [displayObjid, setDisplayObjid] = React.useState(""); - const [error, setError] = React.useState(false); - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - let mounted = true; - const rawValue = String(value); - const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean); - - // 단일 값 또는 경로인 경우 - if (parts.length <= 1) { - const strValue = parts[0] || rawValue; - setDisplayObjid(strValue); - const isObjid = /^\d+$/.test(strValue); - - if (isObjid) { - loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading); - } else { - setImgSrc(getFullImageUrl(strValue)); - setLoading(false); - } - return () => { mounted = false; }; - } - - // 다중 objid: 대표 이미지를 찾아서 표시 - const objids = parts.filter(s => /^\d+$/.test(s)); - if (objids.length === 0) { - setLoading(false); - setError(true); - return () => { mounted = false; }; - } - - (async () => { - try { - const { getFileInfoByObjid } = await import("@/lib/api/file"); - let representativeId: string | null = null; - - // 각 objid의 대표 여부를 확인 - for (const objid of objids) { - const info = await getFileInfoByObjid(objid); - if (info.success && info.data?.is_representative) { - representativeId = objid; - break; - } - } - - // 대표 이미지가 없으면 첫 번째 사용 - const targetObjid = representativeId || objids[0]; - if (mounted) { - setDisplayObjid(targetObjid); - loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading); - } - } catch { - if (mounted) { - // 대표 조회 실패 시 첫 번째 사용 - setDisplayObjid(objids[0]); - loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading); - } - } - })(); - - return () => { mounted = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); - - if (loading) { - return ( -
-
-
- ); - } - - if (error || !imgSrc) { - return ( -
-
- -
-
- ); - } - - return ( -
- 이미지 { - e.stopPropagation(); - const isObjid = /^\d+$/.test(displayObjid); - const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid); - window.open(openUrl, "_blank"); - }} - onError={() => setError(true)} - /> -
- ); -}); -TableCellImage.displayName = "TableCellImage"; - -// 📎 테이블 셀 파일 컴포넌트 -// objid(콤마 구분 포함) 또는 JSON 배열 값을 받아 파일명 표시 + 클릭 시 읽기 전용 모달 -const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => { - const [fileInfos, setFileInfos] = React.useState>([]); - const [loading, setLoading] = React.useState(true); - const [modalOpen, setModalOpen] = React.useState(false); - - React.useEffect(() => { - let mounted = true; - const rawValue = String(value).trim(); - if (!rawValue || rawValue === "-") { - setLoading(false); - return; - } - - // JSON 배열 형태인지 확인 - try { - const parsed = JSON.parse(rawValue); - if (Array.isArray(parsed)) { - const infos = parsed.map((f: any) => ({ - objid: String(f.objid || f.id || ""), - name: f.realFileName || f.real_file_name || f.name || "파일", - ext: f.fileExt || f.file_ext || "", - size: f.fileSize || f.file_size || 0, - })); - if (mounted) { - setFileInfos(infos); - setLoading(false); - } - return; - } - } catch { - // JSON 파싱 실패 → objid 문자열로 처리 - } - - // 콤마 구분 objid 또는 단일 objid - const objids = rawValue.split(",").map(s => s.trim()).filter(Boolean); - if (objids.length === 0) { - if (mounted) setLoading(false); - return; - } - - Promise.all( - objids.map(async (oid) => { - try { - const { getFileInfoByObjid } = await import("@/lib/api/file"); - const res = await getFileInfoByObjid(oid); - if (res.success && res.data) { - return { - objid: oid, - name: res.data.real_file_name || "파일", - ext: res.data.file_ext || "", - size: res.data.file_size || 0, - }; - } - } catch {} - return { objid: oid, name: `파일(${oid})`, ext: "" }; - }) - ).then((results) => { - if (mounted) { - setFileInfos(results); - setLoading(false); - } - }); - - return () => { mounted = false; }; - }, [value]); - - if (loading) { - return ...; - } - - if (fileInfos.length === 0) { - return -; - } - - const { Paperclip, Download: DownloadIcon, FileText: FileTextIcon } = require("lucide-react"); - const fileNames = fileInfos.map(f => f.name).join(", "); - - const getFileIconClass = (ext: string) => { - const e = (ext || "").toLowerCase().replace(".", ""); - if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(e)) return "text-primary"; - if (["pdf"].includes(e)) return "text-destructive"; - if (["doc", "docx", "hwp", "hwpx"].includes(e)) return "text-blue-500"; - if (["xls", "xlsx"].includes(e)) return "text-emerald-500"; - return "text-muted-foreground"; - }; - - const handleDownload = async (file: { objid: string; name: string }) => { - if (!file.objid) return; - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/files/download/${file.objid}`, { - responseType: "blob", - }); - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = file.name || "download"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch (err) { - console.error("파일 다운로드 오류:", err); - } - }; - - - return ( - <> -
{ - e.stopPropagation(); - setModalOpen(true); - }} - > - - - {fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}건`} - -
- - {modalOpen && ( -
{ - e.stopPropagation(); - setModalOpen(false); - }} - > -
e.stopPropagation()} - > -
-
- - 첨부파일 ({fileInfos.length}) -
- -
-
- {fileInfos.map((file, idx) => ( -
- -
-

{file.name}

- {file.size ? ( -

- {file.size > 1048576 - ? `${(file.size / 1048576).toFixed(1)} MB` - : `${(file.size / 1024).toFixed(0)} KB`} -

- ) : null} -
- -
- ))} -
-
-
- )} - - ); -}); -TableCellFile.displayName = "TableCellFile"; - -// 이미지 blob 로딩 헬퍼 -function loadImageBlob( - objid: string, - mounted: boolean, - setImgSrc: (url: string) => void, - setError: (err: boolean) => void, - setLoading: (loading: boolean) => void, -) { - import("@/lib/api/client").then(({ apiClient }) => { - apiClient.get(`/files/preview/${objid}`, { responseType: "blob" }) - .then((response) => { - if (mounted) { - const blob = new Blob([response.data]); - setImgSrc(window.URL.createObjectURL(blob)); - setLoading(false); - } - }) - .catch(() => { - if (mounted) { - setError(true); - setLoading(false); - } - }); - }); -} - -// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 -declare global { - interface Window { - __relatedButtonsTargetTables?: Set; - } -} -import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - RefreshCw, - ArrowUp, - ArrowDown, - TableIcon, - Settings, - X, - Layers, - ChevronDown, - Filter, - Check, - Download, - FileSpreadsheet, - Copy, - ClipboardCopy, - Edit, - CheckSquare, - Trash2, - Lock, - GripVertical, -} from "lucide-react"; -import * as XLSX from "xlsx"; -import { FileText, ChevronRightIcon } from "lucide-react"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/utils/toastUtils"; -import { tableDisplayStore } from "@/stores/tableDisplayStore"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Label } from "@/components/ui/label"; -import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; -import { SingleTableWithSticky } from "./SingleTableWithSticky"; -import { CardModeRenderer } from "./CardModeRenderer"; -import { TableOptionsModal } from "@/components/common/TableOptionsModal"; -import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; -import { useAuth } from "@/hooks/useAuth"; -import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; -import type { DataProvidable, DataReceivable, DataReceiverConfig, DataReceivableComponentType, EntityJoinColumnMeta } from "@/types/data-transfer"; - -// ======================================== -// 인터페이스 -// ======================================== - -// 그룹화된 데이터 인터페이스 -interface GroupedData { - groupKey: string; - groupValues: Record; - items: any[]; - count: number; - summary?: Record; // 🆕 그룹별 소계 -} - -// ======================================== -// 캐시 및 유틸리티 -// ======================================== - -const tableColumnCache = new Map(); -const tableInfoCache = new Map(); -const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 -const DESIGN_PREVIEW_PAGE_SIZE = 10; - -const cleanupTableCache = () => { - const now = Date.now(); - for (const [key, entry] of tableColumnCache.entries()) { - if (now - entry.timestamp > TABLE_CACHE_TTL) { - tableColumnCache.delete(key); - } - } - for (const [key, entry] of tableInfoCache.entries()) { - if (now - entry.timestamp > TABLE_CACHE_TTL) { - tableInfoCache.delete(key); - } - } -}; - -if (typeof window !== "undefined") { - setInterval(cleanupTableCache, 10 * 60 * 1000); -} - -const debounceTimers = new Map(); -const activeRequests = new Map>(); - -const debouncedApiCall = (key: string, fn: (...args: T) => Promise, delay: number = 300) => { - return (...args: T): Promise => { - const activeRequest = activeRequests.get(key); - if (activeRequest) { - return activeRequest as Promise; - } - - return new Promise((resolve, reject) => { - const existingTimer = debounceTimers.get(key); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - try { - const requestPromise = fn(...args); - activeRequests.set(key, requestPromise); - const result = await requestPromise; - resolve(result); - } catch (error) { - reject(error); - } finally { - debounceTimers.delete(key); - activeRequests.delete(key); - } - }, delay); - - debounceTimers.set(key, timer); - }); - }; -}; - -// ======================================== -// Filter Builder 인터페이스 -// ======================================== - -interface FilterCondition { - id: string; - column: string; - operator: - | "equals" - | "notEquals" - | "contains" - | "notContains" - | "startsWith" - | "endsWith" - | "greaterThan" - | "lessThan" - | "greaterOrEqual" - | "lessOrEqual" - | "isEmpty" - | "isNotEmpty"; - value: string; -} - -interface FilterGroup { - id: string; - logic: "AND" | "OR"; - conditions: FilterCondition[]; -} - -// ======================================== -// Props 인터페이스 -// ======================================== - -export interface TableListComponentProps { - component: any; - isDesignMode?: boolean; - isSelected?: boolean; - isInteractive?: boolean; - onClick?: () => void; - onDragStart?: (e: React.DragEvent) => void; - onDragEnd?: (e: React.DragEvent) => void; - className?: string; - style?: React.CSSProperties; - formData?: Record; - onFormDataChange?: (data: any) => void; - config?: TableListConfig; - size?: { width: number; height: number }; - position?: { x: number; y: number; z?: number }; - componentConfig?: any; - selectedScreen?: any; - onZoneComponentDrop?: any; - onZoneClick?: any; - tableName?: string; - onRefresh?: () => void; - onClose?: () => void; - screenId?: number | string; // 화면 ID (필터 설정 저장용) - userId?: string; // 사용자 ID (컬럼 순서 저장용) - onSelectedRowsChange?: ( - selectedRows: any[], - selectedRowsData: any[], - sortBy?: string, - sortOrder?: "asc" | "desc", - columnOrder?: string[], - tableDisplayData?: any[], - ) => void; - onConfigChange?: (config: any) => void; - refreshKey?: number; - // 탭 관련 정보 (탭 내부의 테이블에서 사용) - parentTabId?: string; // 부모 탭 ID - parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID - // 🆕 프리뷰용 회사 코드 (DynamicComponentRenderer에서 전달, 최고 관리자만 오버라이드 가능) - companyCode?: string; - renderer?: any; -} - -// ======================================== -// 메인 컴포넌트 -// ======================================== - -export const TableListComponent: React.FC = ({ - component, - isDesignMode = false, - isSelected = false, - onClick, - onDragStart, - onDragEnd, - config, - className, - style, - formData: propFormData, - onFormDataChange, - componentConfig, - onSelectedRowsChange, - onConfigChange, - refreshKey, - tableName, - userId, - screenId, - parentTabId, - parentTabsComponentId, - companyCode, -}) => { - // ======================================== - // 설정 및 스타일 - // ======================================== - - const tableConfig = { - ...config, - ...component.config, - ...componentConfig, - } as TableListConfig; - - // selectedTable 안전하게 추출 (문자열인지 확인) - let finalSelectedTable = - componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName; - - // 디버그 로그 제거 (성능 최적화) - - // 객체인 경우 tableName 속성 추출 시도 - if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { - finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; - } - - tableConfig.selectedTable = finalSelectedTable; - - // 디버그 로그 제거 (성능 최적화) - - const currentTabId = useTabId(); - - const buttonColor = getAdaptiveLabelColor(component.style?.labelColor); - const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; - - const gridColumns = component.gridColumns || 1; - let calculatedWidth: string; - - if (isDesignMode) { - if (gridColumns === 1) { - calculatedWidth = "400px"; - } else if (gridColumns === 2) { - calculatedWidth = "800px"; - } else { - calculatedWidth = "100%"; - } - } else { - calculatedWidth = "100%"; - } - - const componentStyle: React.CSSProperties = { - position: "relative", - display: "flex", - flexDirection: "column", - backgroundColor: "hsl(var(--background))", - overflow: "hidden", - boxSizing: "border-box", - width: "100%", - height: "100%", - minHeight: isDesignMode ? "300px" : "100%", - ...style, // style prop이 위의 기본값들을 덮어씀 - }; - - // ======================================== - // 상태 관리 - // ======================================== - - // 사용자 정보 (props에서 받거나 useAuth에서 가져오기) - const { userId: authUserId } = useAuth(); - const currentUserId = userId || authUserId; - - // 화면 컨텍스트 (데이터 제공자로 등록) - const screenContext = useScreenContextOptional(); - - // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) - const splitPanelContext = useSplitPanelContext(); - // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) - const splitPanelPosition = screenContext?.split_panel_position; - - // 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) - const [linkedFilterValues, setLinkedFilterValues] = useState>({}); - - // 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태 - const [relatedButtonFilter, setRelatedButtonFilter] = useState<{ - filterColumn: string; - filterValue: any; - } | null>(null); - - // 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부 - const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => { - // 초기값: 전역 레지스트리에서 확인 - if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) { - return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || ""); - } - return false; - }); - - // TableOptions Context - const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); - const [filters, setFilters] = useState([]); - const [grouping, setGrouping] = useState([]); - const [columnVisibility, setColumnVisibility] = useState([]); - - // filters가 변경되면 searchValues 업데이트 (실시간 검색) - useEffect(() => { - const newSearchValues: Record = {}; - filters.forEach((filter) => { - if (filter.value) { - // operator 정보도 함께 전달 (백엔드에서 equals/contains 구분) - newSearchValues[filter.column_name] = { - value: filter.value, - operator: filter.operator || "contains", - }; - } - }); - - // filters → searchValues 변환 완료 - - setSearchValues(newSearchValues); - setCurrentPage(1); // 필터 변경 시 첫 페이지로 - }, [filters]); - - // grouping이 변경되면 groupByColumns 업데이트 - useEffect(() => { - setGroupByColumns(grouping); - }, [grouping]); - - // 초기 로드 시 localStorage에서 저장된 설정 불러오기 - useEffect(() => { - if (tableConfig.selectedTable && currentUserId) { - const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; - const savedSettings = localStorage.getItem(storageKey); - - if (savedSettings) { - try { - const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; - setColumnVisibility(parsed); - } catch (error) { - console.error("저장된 컬럼 설정 불러오기 실패:", error); - } - } - } - }, [tableConfig.selectedTable, currentUserId]); - - // columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용 - useEffect(() => { - if (columnVisibility.length > 0) { - const newOrder = columnVisibility.map((cv) => cv.column_name).filter((name) => name !== "__checkbox__"); // 체크박스 제외 - setColumnOrder(newOrder); - - // 너비 적용 - const newWidths: Record = {}; - columnVisibility.forEach((cv) => { - if (cv.width) { - newWidths[cv.column_name] = cv.width; - } - }); - if (Object.keys(newWidths).length > 0) { - setColumnWidths((prev) => ({ ...prev, ...newWidths })); - - // table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용) - if (tableConfig.selectedTable && userId) { - const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; - try { - const existing = localStorage.getItem(widthsKey); - const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths; - localStorage.setItem(widthsKey, JSON.stringify(merged)); - } catch { /* ignore */ } - } - } - - // localStorage에 저장 (사용자별) - if (tableConfig.selectedTable && currentUserId) { - const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; - localStorage.setItem(storageKey, JSON.stringify(columnVisibility)); - } - } - }, [columnVisibility, tableConfig.selectedTable, currentUserId]); - - // 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용) - const [columnOrder, setColumnOrder] = useState([]); - - // 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해) - const visibleColumns = useMemo(() => { - let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - - // columnVisibility가 있으면 가시성 적용 - if (columnVisibility.length > 0) { - cols = cols.filter((col) => { - const visibilityConfig = columnVisibility.find((cv) => cv.column_name === col.columnName); - return visibilityConfig ? visibilityConfig.visible : true; - }); - } - - // 체크박스 컬럼 (나중에 위치 결정) - // 기본값: enabled가 undefined면 true로 처리 - let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled ?? true) { - checkboxCol = { - columnName: "__checkbox__", - displayName: "", - visible: true, - sortable: false, - searchable: false, - width: 40, - align: "center" as const, - order: -1, - editable: false, // 체크박스는 편집 불가 - }; - } - - // columnOrder가 있으면 해당 순서로 정렬 - if (columnOrder.length > 0) { - const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); - cols = [...cols].sort((a, b) => { - const aIdx = orderMap.get(a.columnName) ?? 9999; - const bIdx = orderMap.get(b.columnName) ?? 9999; - return aIdx - bIdx; - }); - } - - // 체크박스 위치 결정 - if (checkboxCol) { - const checkboxPosition = tableConfig.checkbox?.position || "left"; - if (checkboxPosition === "left") { - return [checkboxCol, ...cols]; - } else { - return [...cols, checkboxCol]; - } - } - - return cols; - }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); - - const [data, setData] = useState[]>([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) - const [headerFilters, setHeaderFilters] = useState>>({}); - const [openFilterColumn, setOpenFilterColumn] = useState(null); - - // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 - const [filterGroups, setFilterGroups] = useState([]); - - // 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함 - const [joinColumnMapping, setJoinColumnMapping] = useState>({}); - - // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 - const filteredData = useMemo(() => { - let result = data; - - // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 - if (splitPanelPosition === "left" && splitPanelContext?.added_item_ids && splitPanelContext.added_item_ids.size > 0) { - const addedIds = splitPanelContext.added_item_ids; - result = result.filter((row) => { - const rowId = String(row.id || row.po_item_id || row.item_id || ""); - return !addedIds.has(rowId); - }); - } - - // 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함) - if (Object.keys(headerFilters).length > 0) { - result = result.filter((row) => { - return Object.entries(headerFilters).every(([columnName, values]) => { - if (values.size === 0) return true; - - // joinColumnMapping을 사용하여 조인된 컬럼명 확인 - const mappedColumnName = joinColumnMapping[columnName] || columnName; - - // 여러 가능한 컬럼명 시도 (mappedColumnName 우선) - const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; - const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - - return values.has(cellStr); - }); - }); - } - - // 3. 🆕 Filter Builder 적용 - if (filterGroups.length > 0) { - result = result.filter((row) => { - return filterGroups.every((group) => { - const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), - ); - if (validConditions.length === 0) return true; - - const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => { - const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; - const condValue = condition.value.toLowerCase(); - - switch (condition.operator) { - case "equals": - return strValue === condValue; - case "notEquals": - return strValue !== condValue; - case "contains": - return strValue.includes(condValue); - case "notContains": - return !strValue.includes(condValue); - case "startsWith": - return strValue.startsWith(condValue); - case "endsWith": - return strValue.endsWith(condValue); - case "greaterThan": - return parseFloat(strValue) > parseFloat(condValue); - case "lessThan": - return parseFloat(strValue) < parseFloat(condValue); - case "greaterOrEqual": - return parseFloat(strValue) >= parseFloat(condValue); - case "lessOrEqual": - return parseFloat(strValue) <= parseFloat(condValue); - case "isEmpty": - return strValue === "" || value === null || value === undefined; - case "isNotEmpty": - return strValue !== "" && value !== null && value !== undefined; - default: - return true; - } - }; - - if (group.logic === "AND") { - return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); - } else { - return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); - } - }); - }); - } - - return result; - }, [data, splitPanelPosition, splitPanelContext?.added_item_ids, headerFilters, filterGroups, joinColumnMapping]); - - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [totalItems, setTotalItems] = useState(0); - const [pageInputValue, setPageInputValue] = useState("1"); - const [searchTerm, setSearchTerm] = useState(""); - const [sortColumn, setSortColumn] = useState(null); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - const hasInitializedSort = useRef(false); - const [columnLabels, setColumnLabels] = useState>({}); - const [tableLabel, setTableLabel] = useState(""); - const pageSizeKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`; - return `pageSize_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, currentTabId]); - - const [localPageSize, setLocalPageSize] = useState(() => { - const key = - currentTabId && tableConfig.selectedTable - ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` - : tableConfig.selectedTable - ? `pageSize_${tableConfig.selectedTable}` - : null; - if (key) { - const val = sessionStorage.getItem(key); - if (val) return Number(val); - } - return tableConfig.pagination?.pageSize || 20; - }); - const [displayColumns, setDisplayColumns] = useState([]); - const [columnMeta, setColumnMeta] = useState< - Record - >({}); - // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) - const [joinedColumnMeta, setJoinedColumnMeta] = useState< - Record - >({}); - const [categoryMappings, setCategoryMappings] = useState< - Record> - >({}); - const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); - const [searchValues, setSearchValues] = useState>({}); - const [selectedRows, setSelectedRows] = useState>(new Set()); - const [columnWidths, setColumnWidths] = useState>({}); - const [refreshTrigger, setRefreshTrigger] = useState(0); - // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) - const columnRefs = useRef>({}); - const [isAllSelected, setIsAllSelected] = useState(false); - const hasInitializedWidths = useRef(false); - const isResizing = useRef(false); - - // 필터 설정 관련 상태 - const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); - const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); - - // 🆕 키보드 네비게이션 관련 상태 - const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); - const tableContainerRef = useRef(null); - - // 🆕 인라인 셀 편집 관련 상태 - const [editingCell, setEditingCell] = useState<{ - rowIndex: number; - colIndex: number; - columnName: string; - originalValue: any; - } | null>(null); - const [editingValue, setEditingValue] = useState(""); - const editInputRef = useRef(null); - - // 🆕 배치 편집 관련 상태 - const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 - const [pendingChanges, setPendingChanges] = useState< - Map< - string, - { - rowIndex: number; - columnName: string; - originalValue: any; - newValue: any; - primaryKeyValue: any; - } - > - >(new Map()); // key: `${rowIndex}-${columnName}` - const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 - - // 🆕 유효성 검사 관련 상태 - const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` - - // 🆕 유효성 검사 규칙 타입 - type ValidationRule = { - required?: boolean; - min?: number; - max?: number; - minLength?: number; - maxLength?: number; - pattern?: RegExp; - customMessage?: string; - validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null) - }; - - // 🆕 Cascading Lookups 관련 상태 - const [cascadingOptions, setCascadingOptions] = useState>({}); - const [loadingCascading, setLoadingCascading] = useState>({}); - - // 🆕 Multi-Level Headers (Column Bands) 타입 - type ColumnBand = { - caption: string; - columns: string[]; // 포함될 컬럼명 배열 - }; - - // 그룹 설정 관련 상태 - const [groupByColumns, setGroupByColumns] = useState([]); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - - // 🆕 그룹별 합산 설정 상태 - const [groupSumConfig, setGroupSumConfig] = useState(null); - - // 🆕 Master-Detail 관련 상태 - const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 - const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 - - // 🆕 Drag & Drop 재정렬 관련 상태 - const [draggedRowIndex, setDraggedRowIndex] = useState(null); - const [dropTargetIndex, setDropTargetIndex] = useState(null); - const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); - - // 🆕 Virtual Scrolling 관련 상태 - const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); - const [scrollTop, setScrollTop] = useState(0); - const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀) - const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수 - const scrollContainerRef = useRef(null); - - // 🆕 Column Reordering 관련 상태 - const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); - const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); - const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); - - // 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage) - const tableStateKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`; - return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); - - // 🆕 Real-Time Updates 관련 상태 - const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); - const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">( - "disconnected", - ); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - - // 🆕 Context Menu 관련 상태 - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - rowIndex: number; - colIndex: number; - row: any; - } | null>(null); - - // 사용자 옵션 모달 관련 상태 - const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); - const [showGridLines, setShowGridLines] = useState(true); - const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); - const [frozenColumns, setFrozenColumns] = useState([]); - const [frozenColumnCount, setFrozenColumnCount] = useState(0); - - // 🆕 Search Panel (통합 검색) 관련 상태 - const [globalSearchTerm, setGlobalSearchTerm] = useState(""); - const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); - const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 - - // 🆕 Filter Builder (고급 필터) 관련 상태 추가 - const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); - const [activeFilterCount, setActiveFilterCount] = useState(0); - - // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) - useEffect(() => { - const linkedFilters = tableConfig.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); - - // searchValues에 연결된 필터 값 병합 - setSearchValues((prev) => ({ - ...prev, - ...newFilterValues, - })); - - // 첫 페이지로 이동 - setCurrentPage(1); - } - }; - - // 초기 체크 - checkLinkedFilters(); - - // 주기적으로 체크 (500ms마다) - const intervalId = setInterval(checkLinkedFilters, 500); - - return () => { - clearInterval(intervalId); - }; - }, [screenContext, tableConfig.linkedFilters, linkedFilterValues]); - - // DataProvidable 인터페이스 구현 - const dataProvider: DataProvidable = { - component_id: component.id, - component_type: "table-list", - table_name: tableConfig.selectedTable, - - getSelectedData: () => { - // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) - const selectedData = filteredData.filter((row) => { - const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); - return selectedRows.has(rowId); - }); - return selectedData; - }, - - getAllData: () => { - // 🆕 필터링된 데이터 반환 - return filteredData; - }, - - clearSelection: () => { - setSelectedRows(new Set()); - setIsAllSelected(false); - }, - - // ★ snake_case 페이로드는 EntityJoinColumnMeta 와 동치 (런타임 컨트랙트 그대로), - // 타입 inference 한계만 cast 로 풀어준다 (2026-05-20 canonical cleanup). - getEntityJoinColumns: () => { - return (tableConfig.columns || []) - .filter((col) => col.additionalJoinInfo) - .map((col) => ({ - source_table: col.additionalJoinInfo!.sourceTable || tableConfig.selectedTable, - source_column: col.additionalJoinInfo!.sourceColumn, - join_alias: col.additionalJoinInfo!.joinAlias, - reference_table: col.additionalJoinInfo!.referenceTable, - })) as unknown as EntityJoinColumnMeta[]; - }, - }; - - // DataReceivable 인터페이스 구현 - const dataReceiver: DataReceivable = { - component_id: component.id, - component_type: "table" as DataReceivableComponentType, - - receiveData: async (receivedData: any[], config: DataReceiverConfig) => { - try { - let newData: any[] = []; - - switch (config.mode) { - case "append": - // 기존 데이터에 추가 - newData = [...data, ...receivedData]; - break; - - case "replace": - // 기존 데이터를 완전히 교체 - newData = receivedData; - break; - - case "merge": - // 기존 데이터와 병합 (ID 기반) - const existingMap = new Map(data.map((item) => [item.id, item])); - receivedData.forEach((item) => { - if (item.id && existingMap.has(item.id)) { - // 기존 데이터 업데이트 - existingMap.set(item.id, { ...existingMap.get(item.id), ...item }); - } else { - // 새 데이터 추가 - existingMap.set(item.id || Date.now() + Math.random(), item); - } - }); - newData = Array.from(existingMap.values()); - break; - } - - // 상태 업데이트 - setData(newData); - - // 총 아이템 수 업데이트 - setTotalItems(newData.length); - } catch (error) { - console.error("데이터 수신 실패:", error); - throw error; - } - }, - - getData: () => { - return data; - }, - }; - - // 화면 컨텍스트에 데이터 제공자/수신자로 등록 - useEffect(() => { - if (screenContext && component.id) { - screenContext.registerDataProvider(component.id, dataProvider); - screenContext.registerDataReceiver(component.id, dataReceiver); - - return () => { - screenContext.unregisterDataProvider(component.id); - screenContext.unregisterDataReceiver(component.id); - }; - } - }, [screenContext, component.id, data, selectedRows]); - - // 분할 패널 컨텍스트에 데이터 수신자로 등록 - // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) - const currentSplitPosition = - splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; - - useEffect(() => { - if (splitPanelContext && component.id && currentSplitPosition) { - const splitPanelReceiver = { - component_id: component.id, - component_type: "table-list", - receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => { - await dataReceiver.receiveData(incomingData, { - target_component_id: component.id, - target_component_type: "table" as DataReceivableComponentType, - mode, - mapping_rules: [], - }); - }, - }; - - splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); - - return () => { - splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); - }; - } - }, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]); - - // 테이블 등록 (Context에 등록) - const tableId = `table-list-${component.id}`; - - useEffect(() => { - // tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음) - const columnsToRegister = (tableConfig.columns || []).filter( - (col) => col.visible !== false && col.columnName !== "__checkbox__", - ); - - if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) { - return; - } - - // 컬럼의 고유 값 조회 함수 - const getColumnUniqueValues = async (columnName: string) => { - const { apiClient } = await import("@/lib/api/client"); - - // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 - const filterParam = companyCode && companyCode !== "*" - ? `?filterCompanyCode=${encodeURIComponent(companyCode)}` - : ""; - - // 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도) - try { - const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values${filterParam}`); - if (response.data.success && response.data.data && response.data.data.length > 0) { - return response.data.data.map((item: any) => ({ - value: item.value_code, - label: item.value_label, - })); - } - } catch { - // 카테고리 API 실패 시 다음 단계로 - } - - // 2단계: DISTINCT API (백엔드에서 category_values/code_info 라벨 변환 포함) - try { - const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); - if (response.data.success && response.data.data && response.data.data.length > 0) { - let options = response.data.data.map((item: any) => ({ - value: String(item.value), - label: String(item.label), - })); - - // 프론트엔드 카테고리 매핑으로 추가 라벨 변환 - const mapping = categoryMappings[columnName]; - if (mapping && Object.keys(mapping).length > 0) { - options = options.map((opt: any) => ({ - value: opt.value, - label: mapping[opt.value]?.label || opt.label, - })); - } - - return options; - } - } catch { - // DISTINCT API 실패 시 다음 단계로 - } - - // 3단계: 현재 로드된 데이터에서 고유 값 추출 (최종 fallback) - const uniqueValuesMap = new Map(); - const mapping = categoryMappings[columnName]; - - data.forEach((row) => { - const value = row[columnName]; - if (value !== null && value !== undefined && value !== "") { - const strValue = String(value); - // _name 필드 또는 카테고리 매핑에서 라벨 가져오기 - const nameField = row[`${columnName}_name`]; - const mappedLabel = mapping?.[strValue]?.label; - const label = mappedLabel || nameField || strValue; - uniqueValuesMap.set(strValue, label); - } - }); - - return Array.from(uniqueValuesMap.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label)); - }; - - const registration = { - table_id: tableId, - label: tableLabel || tableConfig.selectedTable, - table_name: tableConfig.selectedTable, - data_count: totalItems || data.length, // 초기 데이터 건수 포함 - columns: columnsToRegister.map((col) => ({ - column_name: col.columnName, - column_label: columnLabels[col.columnName] || col.displayName || col.columnName, - input_type: columnMeta[col.columnName]?.inputType || "text", - visible: col.visible !== false, - width: columnWidths[col.columnName] || col.width || 150, - sortable: col.sortable !== false, - filterable: col.searchable !== false, - })), - onFilterChange: setFilters, - onGroupChange: setGrouping, - onColumnVisibilityChange: setColumnVisibility, - getColumnUniqueValues, // 고유 값 조회 함수 등록 - onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 - // 틀고정 컬럼 관련 - frozen_column_count: frozenColumnCount, // 현재 틀고정 컬럼 수 - onFrozenColumnCountChange: (count: number) => { - setFrozenColumnCount(count); - const visibleCols = columnsToRegister - .filter((col) => col.visible !== false) - .map((col) => col.columnName); - setFrozenColumns(visibleCols.slice(0, count)); - }, - // 탭 관련 정보 (탭 내부의 테이블인 경우) - parent_tab_id: parentTabId, - parent_tabs_component_id: parentTabsComponentId, - screen_id: screenId ? Number(screenId) : undefined, - }; - - registerTable(registration); - - return () => { - unregisterTable(tableId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - tableId, - tableConfig.selectedTable, - tableConfig.columns, - columnLabels, - columnMeta, - categoryMappings, - columnWidths, - tableLabel, - data, - totalItems, - registerTable, - ]); - - // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용) - useEffect(() => { - if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; - - const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; - const savedSort = localStorage.getItem(storageKey); - - if (savedSort) { - try { - const { column, direction } = JSON.parse(savedSort); - if (column && direction) { - setSortColumn(column); - setSortDirection(direction); - hasInitializedSort.current = true; - return; - } - } catch (error) { - // 정렬 상태 복원 실패 - } - } - - // localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용 - if (tableConfig.defaultSort?.columnName) { - setSortColumn(tableConfig.defaultSort.columnName); - setSortDirection(tableConfig.defaultSort.direction || "asc"); - hasInitializedSort.current = true; - } - }, [tableConfig.selectedTable, tableConfig.defaultSort, userId]); - - // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 - useEffect(() => { - if (!tableConfig.selectedTable || !userId) return; - - const userKey = userId || "guest"; - const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`; - const savedOrder = localStorage.getItem(storageKey); - - if (savedOrder) { - try { - const parsedOrder = JSON.parse(savedOrder); - setColumnOrder(parsedOrder); - - // 부모 컴포넌트에 초기 컬럼 순서 전달 - if (onSelectedRowsChange && parsedOrder.length > 0) { - - // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) - const initialData = data.map((row: any) => { - const reordered: any = {}; - parsedOrder.forEach((colName: string) => { - if (colName in row) { - reordered[colName] = row[colName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - // 전역 저장소에 데이터 저장 - if (tableConfig.selectedTable) { - // 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - initialData, - parsedOrder.filter((col: string) => col !== "__checkbox__"), - sortColumn ?? null, - sortDirection, - { - filter_conditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, - search_term: searchTerm || undefined, - visible_columns: cols.map((col) => col.columnName), - column_labels: labels, - current_page: currentPage, - page_size: localPageSize, - total_items: totalItems, - }, - ); - } - - onSelectedRowsChange([], [], sortColumn || undefined, sortDirection, parsedOrder, initialData); - } - } catch (error) { - console.error("❌ 컬럼 순서 파싱 실패:", error); - } - } - }, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행) - - const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { - enableBatchLoading: true, - preloadCommonCodes: true, - maxBatchSize: 5, - }); - - // ======================================== - // 컬럼 라벨 가져오기 - // ======================================== - - const fetchColumnLabels = useCallback(async () => { - if (!tableConfig.selectedTable) return; - - try { - // 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원) - const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}"); - const companyCode = currentUser.company_code || "UNKNOWN"; - const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`; - const cached = tableColumnCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { - const labels: Record = {}; - const meta: Record = {}; - - const inputTypeMap: Record = {}; - const categoryRefMap: Record = {}; - if (cached.inputTypes) { - cached.inputTypes.forEach((col: any) => { - inputTypeMap[col.column_name] = col.input_type; - if (col.category_ref) { - categoryRefMap[col.column_name] = col.category_ref; - } - }); - } - - cached.columns.forEach((col: any) => { - labels[col.column_name] = col.display_name || col.comment || col.column_name; - meta[col.column_name] = { - webType: col.web_type, - codeInfo: col.code_info, - inputType: inputTypeMap[col.column_name], - categoryRef: categoryRefMap[col.column_name], - }; - }); - - setColumnLabels(labels); - setColumnMeta(meta); - return; - } - - const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); - - const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); - const inputTypeMap: Record = {}; - const categoryRefMap: Record = {}; - inputTypes.forEach((col: any) => { - inputTypeMap[col.column_name] = col.input_type; - if (col.category_ref) { - categoryRefMap[col.column_name] = col.category_ref; - } - }); - - tableColumnCache.set(cacheKey, { - columns, - inputTypes, - timestamp: Date.now(), - }); - - const labels: Record = {}; - const meta: Record = {}; - - columns.forEach((col: any) => { - labels[col.column_name] = col.display_name || col.comment || col.column_name; - meta[col.column_name] = { - webType: col.web_type, - codeInfo: col.code_info, - inputType: inputTypeMap[col.column_name], - categoryRef: categoryRefMap[col.column_name], - }; - }); - - setColumnLabels(labels); - setColumnMeta(meta); - } catch (error) { - console.error("컬럼 라벨 가져오기 실패:", error); - } - }, [tableConfig.selectedTable]); - - // ======================================== - // 테이블 라벨 가져오기 - // ======================================== - - const fetchTableLabel = useCallback(async () => { - if (!tableConfig.selectedTable) return; - - try { - const cacheKey = `table_info_${tableConfig.selectedTable}`; - const cached = tableInfoCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { - const tables = cached.tables || []; - const tableInfo = tables.find((t: any) => t.table_name === tableConfig.selectedTable); - const label = - (tableInfo as any)?.display_name || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; - setTableLabel(label); - return; - } - - const tables = await tableTypeApi.getTables(); - - tableInfoCache.set(cacheKey, { - tables, - timestamp: Date.now(), - }); - - const tableInfo = tables.find((t: any) => t.table_name === tableConfig.selectedTable); - const label = - (tableInfo as any)?.display_name || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable; - setTableLabel(label); - } catch (error) { - console.error("테이블 라벨 가져오기 실패:", error); - } - }, [tableConfig.selectedTable]); - - // ======================================== - // 카테고리 값 매핑 로드 - // ======================================== - - // 카테고리 컬럼 목록 추출 (useMemo로 최적화) - const categoryColumns = useMemo(() => { - return Object.entries(columnMeta) - .filter(([_, meta]) => meta.inputType === "category") - .map(([columnName, _]) => columnName); - }, [columnMeta]); - - // 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행) - useEffect(() => { - const loadCategoryMappings = async () => { - if (!tableConfig.selectedTable) { - return; - } - - try { - const mappings: Record> = {}; - const apiClient = (await import("@/lib/api/client")).apiClient; - - // 최고관리자가 특정 회사 프리뷰 시 해당 회사 카테고리만 필터링 - const filterCompanyParam = companyCode && companyCode !== "*" - ? `&filterCompanyCode=${encodeURIComponent(companyCode)}` - : ""; - - // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) - // valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴) - const flattenTree = (items: any[], mapping: Record) => { - items.forEach((item: any) => { - if (item.value_code) { - mapping[String(item.value_code)] = { - label: item.value_label, - color: item.color, - }; - } - if (item.children && Array.isArray(item.children) && item.children.length > 0) { - flattenTree(item.children, mapping); - } - }); - }; - - for (const columnName of categoryColumns) { - try { - let targetTable = tableConfig.selectedTable; - let targetColumn = columnName; - - // category_ref가 있으면 참조 테이블.컬럼 기준으로 조회 - const meta = columnMeta[columnName]; - if (meta?.categoryRef) { - const refParts = meta.categoryRef.split("."); - if (refParts.length === 2) { - targetTable = refParts[0]; - targetColumn = refParts[1]; - } - } else if (columnName.includes(".")) { - // 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태 - const parts = columnName.split("."); - targetTable = parts[0]; - targetColumn = parts[1]; - } - - // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true${filterCompanyParam}`); - - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { - const mapping: Record = {}; - flattenTree(response.data.data, mapping); - - if (Object.keys(mapping).length > 0) { - mappings[columnName] = mapping; - } - } - } catch { - // 카테고리 값 로드 실패 - 무시 - } - } - - // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 - // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 - const joinedColumns = - tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || []; - - // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) - const additionalJoinColumns = - tableConfig.columns - ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) - .map((col: any) => ({ - columnName: col.columnName, // 예: item_code_material - referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info - // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) - actualColumn: - col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") || - col.columnName, - })) || []; - - // 조인 테이블별로 그룹화 - const joinedTableColumns: Record = {}; - - // "테이블명.컬럼명" 형태 처리 - for (const joinedColumn of joinedColumns) { - const parts = joinedColumn.split("."); - if (parts.length !== 2) continue; - - const joinedTable = parts[0]; - const joinedColumnName = parts[1]; - - if (!joinedTableColumns[joinedTable]) { - joinedTableColumns[joinedTable] = []; - } - joinedTableColumns[joinedTable].push({ - columnName: joinedColumn, - actualColumn: joinedColumnName, - }); - } - - // additionalJoinInfo 형태 처리 - for (const col of additionalJoinColumns) { - if (!joinedTableColumns[col.referenceTable]) { - joinedTableColumns[col.referenceTable] = []; - } - joinedTableColumns[col.referenceTable].push({ - columnName: col.columnName, // 예: item_code_material - actualColumn: col.actualColumn, // 예: material - }); - } - - // 조인된 테이블별로 inputType 정보 가져오기 - const newJoinedColumnMeta: Record = {}; - - for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { - try { - // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) - const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); - - for (const col of columns) { - const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn); - - // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) - newJoinedColumnMeta[col.columnName] = { - inputType: inputTypeInfo?.inputType, - }; - - // inputType이 category인 경우 카테고리 매핑 로드 - if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { - try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true${filterCompanyParam}`); - - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { - const mapping: Record = {}; - flattenTree(response.data.data, mapping); - - if (Object.keys(mapping).length > 0) { - mappings[col.columnName] = mapping; - } - } - } catch { - // 조인 테이블 카테고리 없음 - 무시 - } - } - } - } catch { - // 조인 테이블 inputType 로드 실패 - 무시 - } - } - - // 조인 컬럼 메타데이터 상태 업데이트 - if (Object.keys(newJoinedColumnMeta).length > 0) { - setJoinedColumnMeta(newJoinedColumnMeta); - } - - // 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping) - try { - const cascadingResponse = await apiClient.get( - `/category-value-cascading/table/${tableConfig.selectedTable}/mappings`, - ); - if (cascadingResponse.data.success && cascadingResponse.data.data) { - const cascadingMappings = cascadingResponse.data.data; - - // 각 자식 컬럼에 대한 매핑 추가 - for (const [columnName, columnMappings] of Object.entries( - cascadingMappings as Record>, - )) { - if (!mappings[columnName]) { - mappings[columnName] = {}; - } - // 연쇄관계 매핑 추가 - for (const item of columnMappings) { - mappings[columnName][item.code] = { - label: item.label, - color: undefined, // 연쇄관계는 색상 없음 - }; - } - } - // 카테고리 연쇄관계 매핑 로드 완료 - } - } catch { - // 연쇄관계 매핑이 없는 경우 무시 - } - - setCategoryMappings(mappings); - if (Object.keys(mappings).length > 0) { - setCategoryMappingsKey((prev) => prev + 1); - } - } catch { - // 카테고리 매핑 로드 실패 - 무시 - } - }; - - loadCategoryMappings(); - }, [ - tableConfig.selectedTable, - categoryColumns.length, - JSON.stringify(categoryColumns), - JSON.stringify(tableConfig.columns), - columnMeta, - companyCode, - ]); - - // ======================================== - // 데이터 가져오기 - // ======================================== - - const fetchTableDataInternal = useCallback(async () => { - if (!tableConfig.selectedTable) { - setData([]); - setTotalPages(0); - setTotalItems(0); - return; - } - - setLoading(true); - setError(null); - - try { - const isPreviewMode = isDesignMode; - const page = isPreviewMode ? 1 : currentPage; - const pageSize = isPreviewMode ? Math.min(localPageSize || DESIGN_PREVIEW_PAGE_SIZE, DESIGN_PREVIEW_PAGE_SIZE) : localPageSize; - // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 - const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; - const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection); - const search = isPreviewMode ? undefined : searchTerm || undefined; - - // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) - const linkedFilterValues: Record = {}; - let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 - let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - - if (!isPreviewMode && splitPanelContext) { - // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) - const linkedFiltersConfig = splitPanelContext.linked_filters || []; - hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter: any) => - filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || - filter.targetColumn === tableConfig.selectedTable, - ); - - // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = - !!(splitPanelContext.selected_left_data && Object.keys(splitPanelContext.selected_left_data).length > 0); - - const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); - - // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) - // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 - for (const [key, value] of Object.entries(allLinkedFilters)) { - if (key.includes(".")) { - const [tableName, columnName] = key.split("."); - if (tableName === tableConfig.selectedTable) { - // 연결 필터는 코드 값이므로 equals 연산자 사용 - linkedFilterValues[columnName] = { value, operator: "equals" }; - hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 - } - } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) - linkedFilterValues[key] = { value, operator: "equals" }; - } - } - - // 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도 - // 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면 - // 동일한 컬럼명이 있는 경우 자동으로 필터링 적용 - if ( - splitPanelPosition === "right" && - hasSelectedLeftData && - Object.keys(linkedFilterValues).length === 0 && - !hasLinkedFiltersConfigured - ) { - const leftData = splitPanelContext.selected_left_data!; - const tableColumns = (tableConfig.columns || []).map((col) => col.columnName); - - // 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인 - for (const [colName, colValue] of Object.entries(leftData)) { - // null, undefined, 빈 문자열 제외 - if (colValue === null || colValue === undefined || colValue === "") continue; - // id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명) - if (colName === "id" || colName === "objid" || colName === "company_code") continue; - - // 현재 테이블에 동일한 컬럼이 있는지 확인 - if (tableColumns.includes(colName)) { - // 자동 컬럼 매칭도 equals 연산자 사용 - linkedFilterValues[colName] = { value: colValue, operator: "equals" }; - hasLinkedFiltersConfigured = true; - } - } - } - } - - // 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 - // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) - if (!isPreviewMode && hasLinkedFiltersConfigured && !hasSelectedLeftData) { - setData([]); - setTotalPages(0); - setTotalItems(0); - setLoading(false); - return; - } - - // RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우 - // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) - if (!isPreviewMode && isRelatedButtonTarget && !relatedButtonFilter) { - setData([]); - setTotalPages(0); - setTotalItems(0); - setLoading(false); - return; - } - - // RelatedDataButtons 필터 값 준비 - const relatedButtonFilterValues: Record = {}; - if (!isPreviewMode && relatedButtonFilter) { - relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { - value: relatedButtonFilter.filterValue, - operator: "equals", - }; - } - - // 검색 필터, 연결 필터, RelatedDataButtons 필터 병합 - const filters = { - ...(Object.keys(searchValues).length > 0 ? searchValues : {}), - ...linkedFilterValues, - ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 - }; - const hasFilters = Object.keys(filters).length > 0; - - // 🆕 REST API 데이터 소스 처리 - const isRestApiTable = - tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); - - let response: any; - - if (isRestApiTable) { - // REST API 데이터 소스인 경우 - const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/); - const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; - - if (connectionId) { - // REST API 연결 정보 가져오기 및 데이터 조회 - const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); - const restApiData = await ExternalRestApiConnectionAPI.fetchData( - connectionId, - undefined, // endpoint - 연결 정보에서 가져옴 - "response", // jsonPath - 기본값 response - ); - - response = { - data: restApiData.rows || [], - total: restApiData.total || restApiData.rows?.length || 0, - totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), - }; - } else { - throw new Error("REST API 연결 ID를 찾을 수 없습니다."); - } - } else { - // 일반 DB 테이블인 경우 (기존 로직) - const entityJoinColumns = (tableConfig.columns || []) - .filter((col) => col.additionalJoinInfo) - .map((col) => ({ - sourceTable: col.additionalJoinInfo!.sourceTable, - sourceColumn: col.additionalJoinInfo!.sourceColumn, - joinAlias: col.additionalJoinInfo!.joinAlias, - referenceTable: col.additionalJoinInfo!.referenceTable, - })); - - // 🎯 화면별 엔티티 표시 설정 수집 - const screenEntityConfigs: Record = {}; - (tableConfig.columns || []) - .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0) - .forEach((col) => { - screenEntityConfigs[col.columnName] = { - displayColumns: col.entityDisplayConfig!.displayColumns, - separator: col.entityDisplayConfig!.separator || " - ", - sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable, - joinTable: col.entityDisplayConfig!.joinTable, - }; - }); - - // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) - let excludeFilterParam: any = undefined; - if (tableConfig.excludeFilter?.enabled) { - const excludeConfig = tableConfig.excludeFilter; - let filterValue: any = undefined; - - // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널) - if (excludeConfig.filterColumn && excludeConfig.filterValueField) { - const fieldName = excludeConfig.filterValueField; - - // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) - if (propFormData && propFormData[fieldName]) { - filterValue = propFormData[fieldName]; - } - // 2순위: URL 파라미터에서 값 가져오기 - else if (typeof window !== "undefined") { - const urlParams = new URLSearchParams(window.location.search); - filterValue = urlParams.get(fieldName); - } - // 3순위: 분할 패널 부모 데이터에서 값 가져오기 - if (!filterValue && splitPanelContext?.selected_left_data) { - filterValue = splitPanelContext.selected_left_data[fieldName]; - } - } - - if (filterValue || !excludeConfig.filterColumn) { - excludeFilterParam = { - enabled: true, - referenceTable: excludeConfig.referenceTable, - referenceColumn: excludeConfig.referenceColumn, - sourceColumn: excludeConfig.sourceColumn, - filterColumn: excludeConfig.filterColumn, - filterValue: filterValue, - }; - } - } - - // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) - response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { - page, - size: pageSize, - sortBy, - sortOrder, - search: hasFilters ? filters : undefined, - enableEntityJoin: true, - additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, - screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 - dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 - excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 - company_code_override: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만) - }); - - // 실제 데이터의 item_number만 추출하여 중복 확인 - const itemNumbers = (response.data || []).map((item: any) => item.item_number); - const uniqueItemNumbers = [...new Set(itemNumbers)]; - - // console.log("✅ [TableList] API 응답 받음"); - // console.log(` - dataLength: ${response.data?.length || 0}`); - // console.log(` - total: ${response.total}`); - // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); - // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); - // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); - - } - - setData(response?.data || []); - setTotalPages(response?.totalPages || 0); - setTotalItems(response?.total || 0); - setError(null); - - if (isPreviewMode) { - return; - } - - // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - response?.data || [], - cols.map((col) => col.columnName), - sortBy ?? null, - sortOrder, - { - filter_conditions: filters, - search_term: search, - visible_columns: cols.map((col) => col.columnName), - column_labels: labels, - current_page: page, - page_size: pageSize, - total_items: response?.total || 0, - }, - ); - } catch (err: any) { - console.error("데이터 가져오기 실패:", err); - setData([]); - setTotalPages(0); - setTotalItems(0); - setError(err.message || "데이터를 불러오지 못했습니다."); - } finally { - setLoading(false); - } - }, [ - tableConfig.selectedTable, - tableConfig.columns, - currentPage, - localPageSize, - sortColumn, - sortDirection, - searchTerm, - searchValues, - isDesignMode, - // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요) - splitPanelPosition, - currentSplitPosition, - splitPanelContext?.selected_left_data, - // 🆕 RelatedDataButtons 필터 추가 - relatedButtonFilter, - isRelatedButtonTarget, - // 🆕 프리뷰용 회사 코드 오버라이드 - companyCode, - ]); - - const fetchTableDataDebounced = useCallback( - (...args: Parameters) => { - const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`; - return debouncedApiCall(key, fetchTableDataInternal, 300)(...args); - }, - [fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection], - ); - - // ======================================== - // 이벤트 핸들러 - // ======================================== - - const handlePageChange = (newPage: number) => { - if (newPage < 1 || newPage > totalPages) return; - setCurrentPage(newPage); - if (tableConfig.pagination) { - tableConfig.pagination.currentPage = newPage; - } - if (onConfigChange) { - onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); - } - }; - - useEffect(() => { - setPageInputValue(String(currentPage)); - }, [currentPage]); - - const commitPageInput = () => { - const parsed = parseInt(pageInputValue, 10); - if (isNaN(parsed) || pageInputValue.trim() === "") { - setPageInputValue(String(currentPage)); - return; - } - const clamped = Math.max(1, Math.min(parsed, totalPages || 1)); - if (clamped !== currentPage) { - handlePageChange(clamped); - } - setPageInputValue(String(clamped)); - }; - - const handleSort = (column: string) => { - let newSortColumn = column; - let newSortDirection: "asc" | "desc" = "asc"; - - if (sortColumn === column) { - newSortDirection = sortDirection === "asc" ? "desc" : "asc"; - setSortDirection(newSortDirection); - } else { - setSortColumn(column); - setSortDirection("asc"); - newSortColumn = column; - newSortDirection = "asc"; - } - - // 정렬 상태를 localStorage에 저장 (사용자별) - if (tableConfig.selectedTable && userId) { - const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; - try { - localStorage.setItem( - storageKey, - JSON.stringify({ - column: newSortColumn, - direction: newSortDirection, - }), - ); - } catch (error) { - // 정렬 상태 저장 실패 - } - } - - // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 - if (onSelectedRowsChange) { - const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); - - // 1단계: 데이터를 정렬 - const sortedData = [...data].sort((a, b) => { - const aVal = a[newSortColumn]; - const bVal = b[newSortColumn]; - - // null/undefined 처리 - if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; - - // 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교) - const aNum = Number(aVal); - const bNum = Number(bVal); - - // 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우 - if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") { - return newSortDirection === "desc" ? bNum - aNum : aNum - bNum; - } - - // 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬) - const aStr = String(aVal).toLowerCase(); - const bStr = String(bVal).toLowerCase(); - - // 자연스러운 정렬 (숫자 포함 문자열) - const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" }); - return newSortDirection === "desc" ? -comparison : comparison; - }); - - // 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬 - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const reorderedData = sortedData.map((row: any) => { - const reordered: any = {}; - cols.forEach((col) => { - if (col.columnName in row) { - reordered[col.columnName] = row[col.columnName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - onSelectedRowsChange( - Array.from(selectedRows), - selectedRowsData, - newSortColumn, - newSortDirection, - columnOrder.length > 0 ? columnOrder : undefined, - reorderedData, - ); - - // 전역 저장소에 정렬된 데이터 저장 - if (tableConfig.selectedTable) { - const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)).filter( - (col) => col !== "__checkbox__", - ); - - // 컬럼 라벨 정보도 함께 저장 - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - reorderedData, - cleanColumnOrder, - newSortColumn, - newSortDirection, - // ★ store 의 setTableData snake_case 시그니처와 호출자 camelCase 가 혼재 (legacy). - // 기능 그대로 유지하려면 키 이름 변경 X — 좁은 cast 로 TS 만 우회. - { - filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, - searchTerm: searchTerm || undefined, - visibleColumns: cols.map((col) => col.columnName), - columnLabels: labels, - currentPage: currentPage, - pageSize: localPageSize, - totalItems: totalItems, - } as any, - ); - } - } else { - console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); - } - }; - - const handleSearchValueChange = (columnName: string, value: any) => { - setSearchValues((prev) => ({ ...prev, [columnName]: value })); - }; - - const handleAdvancedSearch = () => { - setCurrentPage(1); - fetchTableDataDebounced(); - }; - - const handleClearAdvancedFilters = useCallback(() => { - // 상태를 초기화하고 useEffect로 데이터 새로고침 - setSearchValues({}); - setCurrentPage(1); - - // 강제로 데이터 새로고침 트리거 - setRefreshTrigger((prev) => prev + 1); - }, [searchValues]); - - const handleRefresh = () => { - fetchTableDataDebounced(); - }; - - const getRowKey = (row: any, index: number) => { - return row.id || row.uuid || `row-${index}`; - }; - - const handleRowSelection = (rowKey: string, checked: boolean) => { - if (isDesignMode) return; - - const isMultiSelect = tableConfig.checkbox?.multiple !== false; - let newSelectedRows: Set; - - if (isMultiSelect) { - newSelectedRows = new Set(selectedRows); - if (checked) { - newSelectedRows.add(rowKey); - } else { - newSelectedRows.delete(rowKey); - } - } else { - // 단일 선택: 기존 선택 해제 후 새 항목만 선택 - newSelectedRows = checked ? new Set([rowKey]) : new Set(); - } - setSelectedRows(newSelectedRows); - - const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index))); - if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData, - }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생 - v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, { - tableName: tableConfig.selectedTable || "", - data: selectedRowsData, - totalCount: selectedRowsData.length, - source: component.id || "table-list", - }); - - // dataBinding 연동용 window CustomEvent - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("v2-table-selection", { - detail: { - tableName: tableConfig.selectedTable || "", - data: selectedRowsData, - source: component.id || "table-list", - }, - }) - ); - } - - // 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId) - if (tableConfig.selectedTable && selectedRowsData.length > 0) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - const modalItems = selectedRowsData.map((row, idx) => ({ - id: getRowKey(row, idx), - original_data: row, - additional_data: {}, - })); - useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); - }); - } else if (tableConfig.selectedTable && selectedRowsData.length === 0) { - // 선택 해제 시 데이터 제거 - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - useModalDataStore.getState().clearData(tableConfig.selectedTable!); - }); - } - - const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); - setIsAllSelected(allRowsSelected && filteredData.length > 0); - }; - - const handleSelectAll = (checked: boolean) => { - if (isDesignMode) return; - - if (checked) { - const allKeys = filteredData.map((row, index) => getRowKey(row, index)); - const newSelectedRows = new Set(allKeys); - setSelectedRows(newSelectedRows); - setIsAllSelected(true); - - if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ - selectedRows: Array.from(newSelectedRows), - selectedRowsData: filteredData, - }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생 - v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, { - tableName: tableConfig.selectedTable || "", - data: filteredData, - totalCount: filteredData.length, - source: component.id || "table-list", - }); - - // 🆕 modalDataStore에 전체 데이터 저장 - if (tableConfig.selectedTable && filteredData.length > 0) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - const modalItems = filteredData.map((row, idx) => ({ - id: getRowKey(row, idx), - original_data: row, - additional_data: {}, - })); - - useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); - }); - } - } else { - setSelectedRows(new Set()); - setIsAllSelected(false); - - if (onSelectedRowsChange) { - onSelectedRowsChange([], [], sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ selectedRows: [], selectedRowsData: [] }); - } - - // 🆕 리피터 컨테이너/집계 위젯 연동용 V2 이벤트 발생 (선택 해제) - v2EventBus.emitSync(V2_EVENTS.TABLE_DATA_CHANGE, { - tableName: tableConfig.selectedTable || "", - data: [], - totalCount: 0, - source: component.id || "table-list", - }); - - // 🆕 modalDataStore 데이터 제거 - if (tableConfig.selectedTable) { - import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - useModalDataStore.getState().clearData(tableConfig.selectedTable!); - }); - } - } - }; - - const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { - if (isDesignMode) return; - - // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) - const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) { - return; - } - - // 행 선택/해제 토글 - const rowKey = getRowKey(row, index); - const isCurrentlySelected = selectedRows.has(rowKey); - - handleRowSelection(rowKey, !isCurrentlySelected); - - // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - - if (splitPanelContext && effectiveSplitPosition === "left" && !(splitPanelContext as any).disableAutoDataTransfer) { - if (!isCurrentlySelected) { - // 선택된 경우: 데이터 저장 - splitPanelContext.setSelectedLeftData(row); - } else { - // 선택 해제된 경우: 데이터 초기화 - splitPanelContext.setSelectedLeftData(null); - } - } - }; - - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) - const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { - if (isDesignMode) return; - - e.stopPropagation(); - - // 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지) - if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) { - return; - } - - setFocusedCell({ rowIndex, colIndex }); - tableContainerRef.current?.focus(); - - const row = filteredData[rowIndex]; - if (!row) return; - - // 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵 - const column = visibleColumns[colIndex]; - if (column?.columnName === "__checkbox__") return; - - const rowKey = getRowKey(row, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - - if (splitPanelContext && effectiveSplitPosition === "left" && !(splitPanelContext as any).disableAutoDataTransfer) { - // 분할 패널 좌측: 단일 행 선택 모드 - if (!isCurrentlySelected) { - setSelectedRows(new Set([rowKey])); - setIsAllSelected(false); - - splitPanelContext.setSelectedLeftData(row); - - if (onSelectedRowsChange) { - onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); - } - if (onFormDataChange) { - onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); - } - } - } else { - // 일반 모드: 행 선택/해제 토글 - handleRowSelection(rowKey, !isCurrentlySelected); - - if (splitPanelContext && effectiveSplitPosition === "left") { - if (!isCurrentlySelected) { - splitPanelContext.setSelectedLeftData(row); - } else { - splitPanelContext.setSelectedLeftData(null); - } - } - } - }; - - // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 - const handleCellDoubleClick = useCallback( - (rowIndex: number, colIndex: number, columnName: string, value: any) => { - if (isDesignMode) return; - - // 체크박스 컬럼은 편집 불가 - if (columnName === "__checkbox__") return; - - // 🆕 편집 불가 컬럼 체크 - const column = visibleColumns.find((col) => col.columnName === columnName); - if (column?.editable === false) { - toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); - return; - } - - setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); - setEditingValue(value !== null && value !== undefined ? String(value) : ""); - setFocusedCell({ rowIndex, colIndex }); - }, - [visibleColumns], - ); - - // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) - const startEditingRef = useRef<() => void>(() => {}); - - // 🆕 각 컬럼의 고유값 목록 계산 - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - - if (data.length === 0) return result; - - (tableConfig.columns || []).forEach((column: { columnName: string }) => { - if (column.columnName === "__checkbox__") return; - - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const values = new Set(); - - data.forEach((row) => { - const val = row[mappedColumnName]; - if (val !== null && val !== undefined && val !== "") { - values.add(String(val)); - } - }); - - result[column.columnName] = Array.from(values).sort(); - }); - - return result; - }, [data, tableConfig.columns, joinColumnMapping]); - - // 🆕 헤더 필터 토글 - const toggleHeaderFilter = useCallback((columnName: string, value: string) => { - setHeaderFilters((prev) => { - const current = prev[columnName] || new Set(); - const newSet = new Set(current); - - if (newSet.has(value)) { - newSet.delete(value); - } else { - newSet.add(value); - } - - return { ...prev, [columnName]: newSet }; - }); - }, []); - - // 🆕 헤더 필터 초기화 - const clearHeaderFilter = useCallback((columnName: string) => { - setHeaderFilters((prev) => { - const newFilters = { ...prev }; - delete newFilters[columnName]; - return newFilters; - }); - setOpenFilterColumn(null); - }, []); - - // 🆕 모든 헤더 필터 초기화 - const clearAllHeaderFilters = useCallback(() => { - setHeaderFilters({}); - setOpenFilterColumn(null); - }, []); - - // 🆕 데이터 요약 (Total Summaries) 설정 - // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } - const summaryConfig = useMemo(() => { - const config: Record = {}; - - // tableConfig에서 summary 설정 읽기 - if (tableConfig.summaries) { - tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { - config[summary.columnName] = { type: summary.type, label: summary.label }; - }); - } - - return config; - }, [tableConfig.summaries]); - - // 🆕 요약 데이터 계산 - const summaryData = useMemo(() => { - if (Object.keys(summaryConfig).length === 0 || data.length === 0) { - return null; - } - - const result: Record = {}; - - Object.entries(summaryConfig).forEach(([columnName, config]) => { - const values = data - .map((row) => { - const mappedColumnName = joinColumnMapping[columnName] || columnName; - const val = row[mappedColumnName]; - return typeof val === "number" ? val : parseFloat(val); - }) - .filter((v) => !isNaN(v)); - - let value: number | string = 0; - let label = config.label || ""; - - switch (config.type) { - case "sum": - value = values.reduce((acc, v) => acc + v, 0); - label = label || "합계"; - break; - case "avg": - value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; - label = label || "평균"; - break; - case "count": - value = data.length; - label = label || "개수"; - break; - case "min": - value = values.length > 0 ? Math.min(...values) : 0; - label = label || "최소"; - break; - case "max": - value = values.length > 0 ? Math.max(...values) : 0; - label = label || "최대"; - break; - default: - value = 0; - } - - result[columnName] = { value, label }; - }); - - return result; - }, [data, summaryConfig, joinColumnMapping]); - - // 🆕 편집 취소 - const cancelEditing = useCallback(() => { - setEditingCell(null); - setEditingValue(""); - tableContainerRef.current?.focus(); - }, []); - - // 🆕 편집 저장 (즉시 저장 또는 배치 저장) - const saveEditing = useCallback(async () => { - if (!editingCell) return; - - const { rowIndex, columnName, originalValue } = editingCell; - const newValue = editingValue; - - // 값이 변경되지 않았으면 그냥 닫기 - if (String(originalValue ?? "") === newValue) { - setCellValidationError(rowIndex, columnName, null); // 에러 초기화 - cancelEditing(); - return; - } - - // 현재 행 데이터 가져오기 - const row = data[rowIndex]; - if (!row || !tableConfig.selectedTable) { - cancelEditing(); - return; - } - - // 🆕 유효성 검사 실행 - const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); - if (validationError) { - setCellValidationError(rowIndex, columnName, validationError); - toast.error(validationError); - // 편집 상태 유지 (에러 수정 가능하도록) - return; - } - // 유효성 통과 시 에러 초기화 - setCellValidationError(rowIndex, columnName, null); - - // 기본 키 필드 찾기 (id 또는 첫 번째 컬럼) - const primaryKeyField = tableConfig.primaryKey || "id"; - const primaryKeyValue = row[primaryKeyField]; - - if (primaryKeyValue === undefined || primaryKeyValue === null) { - console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField); - cancelEditing(); - return; - } - - // 🆕 배치 모드: 변경사항을 pending에 저장 - if (editMode === "batch") { - const changeKey = `${rowIndex}-${columnName}`; - setPendingChanges((prev) => { - const newMap = new Map(prev); - newMap.set(changeKey, { - rowIndex, - columnName, - originalValue, - newValue: newValue === "" ? null : newValue, - primaryKeyValue, - }); - return newMap; - }); - - // 로컬 수정 데이터 업데이트 (화면 표시용) - setLocalEditedData((prev) => ({ - ...prev, - [rowIndex]: { - ...(prev[rowIndex] || {}), - [columnName]: newValue === "" ? null : newValue, - }, - })); - - cancelEditing(); - return; - } - - // 즉시 모드: 바로 저장 - try { - const { apiClient } = await import("@/lib/api/client"); - - await apiClient.put("/dynamic-form/update-field", { - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: primaryKeyValue, - updateField: columnName, - updateValue: newValue === "" ? null : newValue, - }); - - // 데이터 새로고침 트리거 - setRefreshTrigger((prev) => prev + 1); - } catch (error) { - // 셀 편집 저장 실패 - } - - cancelEditing(); - }, [ - editingCell, - editingValue, - data, - tableConfig.selectedTable, - tableConfig.primaryKey, - cancelEditing, - editMode, - pendingChanges.size, - ]); - - // 🆕 배치 저장: 모든 변경사항 한번에 저장 - const saveBatchChanges = useCallback(async () => { - if (pendingChanges.size === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 모든 변경사항 저장 - const savePromises = Array.from(pendingChanges.values()).map((change) => - apiClient.put("/dynamic-form/update-field", { - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: change.primaryKeyValue, - updateField: change.columnName, - updateValue: change.newValue, - }), - ); - - await Promise.all(savePromises); - - // 상태 초기화 - setPendingChanges(new Map()); - setLocalEditedData({}); - setRefreshTrigger((prev) => prev + 1); - - toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); - } catch (error) { - showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); - } - }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); - - // 배치 취소: 모든 변경사항 롤백 - const cancelBatchChanges = useCallback(() => { - if (pendingChanges.size === 0) return; - - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("변경사항이 취소되었습니다."); - }, [pendingChanges.size]); - - // 🆕 특정 셀이 수정되었는지 확인 - const isCellModified = useCallback( - (rowIndex: number, columnName: string) => { - return pendingChanges.has(`${rowIndex}-${columnName}`); - }, - [pendingChanges], - ); - - // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) - const getDisplayValue = useCallback( - (row: any, rowIndex: number, columnName: string) => { - const localValue = localEditedData[rowIndex]?.[columnName]; - if (localValue !== undefined) { - return localValue; - } - return row[columnName]; - }, - [localEditedData], - ); - - // 🆕 유효성 검사 함수 - const validateValue = useCallback( - (value: any, columnName: string, row: any): string | null => { - // tableConfig.validation에서 컬럼별 규칙 가져오기 - const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; - if (!rules) return null; - - const strValue = value !== null && value !== undefined ? String(value) : ""; - const numValue = parseFloat(strValue); - - // 필수 검사 - if (rules.required && (!strValue || strValue.trim() === "")) { - return rules.customMessage || "필수 입력 항목입니다."; - } - - // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) - if (!strValue || strValue.trim() === "") return null; - - // 최소값 검사 - if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { - return rules.customMessage || `최소값은 ${rules.min}입니다.`; - } - - // 최대값 검사 - if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { - return rules.customMessage || `최대값은 ${rules.max}입니다.`; - } - - // 최소 길이 검사 - if (rules.minLength !== undefined && strValue.length < rules.minLength) { - return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; - } - - // 최대 길이 검사 - if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { - return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; - } - - // 패턴 검사 - if (rules.pattern && !rules.pattern.test(strValue)) { - return rules.customMessage || "입력 형식이 올바르지 않습니다."; - } - - // 커스텀 검증 - if (rules.validate) { - const customError = rules.validate(value, row); - if (customError) return customError; - } - - return null; - }, - [tableConfig], - ); - - // 🆕 셀 유효성 에러 여부 확인 - const getCellValidationError = useCallback( - (rowIndex: number, columnName: string): string | null => { - return validationErrors.get(`${rowIndex}-${columnName}`) || null; - }, - [validationErrors], - ); - - // 🆕 유효성 검사 에러 설정 - const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { - setValidationErrors((prev) => { - const newMap = new Map(prev); - const key = `${rowIndex}-${columnName}`; - if (error) { - newMap.set(key, error); - } else { - newMap.delete(key); - } - return newMap; - }); - }, []); - - // 🆕 모든 유효성 에러 초기화 - const clearAllValidationErrors = useCallback(() => { - setValidationErrors(new Map()); - }, []); - - // 🆕 Excel 내보내기 함수 - const exportToExcel = useCallback( - (exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 (선택된 행만 또는 전체) - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - // 선택된 행만 내보내기 - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } - - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 헤더 행 생성 - const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); - - // 데이터 행 생성 - const rows = exportData.map((row) => { - return exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - const value = row[mappedColumnName]; - - // 카테고리 매핑된 값 처리 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) { - return mapping.label; - } - } - - // null/undefined 처리 - if (value === null || value === undefined) { - return ""; - } - - return value; - }); - }); - - // 워크시트 생성 - const wsData = [headers, ...rows]; - const ws = XLSX.utils.aoa_to_sheet(wsData); - - // 컬럼 너비 자동 조정 - const colWidths = exportColumns.map((col, idx) => { - const headerLength = headers[idx]?.length || 10; - const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length)); - return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; - }); - ws["!cols"] = colWidths; - - // 워크북 생성 - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); - - // 파일명 생성 - const _en = new Date(); - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`; - - // 파일 다운로드 - XLSX.writeFile(wb, fileName); - - toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); - } catch (error) { - showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); - } - }, - [ - filteredData, - selectedRows, - visibleColumns, - columnLabels, - joinColumnMapping, - categoryMappings, - tableLabel, - tableConfig.selectedTable, - getRowKey, - ], - ); - - // 🆕 행 확장/축소 토글 - const toggleRowExpand = useCallback( - async (rowKey: string, row: any) => { - setExpandedRows((prev) => { - const newSet = new Set(prev); - if (newSet.has(rowKey)) { - newSet.delete(rowKey); - } else { - newSet.add(rowKey); - // 상세 데이터 로딩 (아직 없는 경우) - if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { - loadDetailData(rowKey, row); - } - } - return newSet; - }); - }, - [detailData, tableConfig], - ); - - // 🆕 상세 데이터 로딩 - const loadDetailData = useCallback( - async (rowKey: string, row: any) => { - const masterDetailConfig = (tableConfig as any).masterDetail; - if (!masterDetailConfig?.detailTable) return; - - try { - const { apiClient } = await import("@/lib/api/client"); - - // masterKey 값 가져오기 - const masterKeyField = masterDetailConfig.masterKey || "id"; - const masterKeyValue = row[masterKeyField]; - - // 상세 테이블에서 데이터 조회 - const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { - page: 1, - size: 100, - search: { - [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, - }, - autoFilter: true, - }); - - const details = response.data?.data?.data || []; - - setDetailData((prev) => ({ - ...prev, - [rowKey]: details, - })); - } catch (error) { - setDetailData((prev) => ({ - ...prev, - [rowKey]: [], - })); - } - }, - [tableConfig], - ); - - // 🆕 모든 행 확장/축소 - const expandAllRows = useCallback(() => { - if (expandedRows.size === filteredData.length) { - // 모두 축소 - setExpandedRows(new Set()); - } else { - // 모두 확장 - const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); - setExpandedRows(allKeys); - } - }, [expandedRows.size, filteredData, getRowKey]); - - // 🆕 Multi-Level Headers: Band 정보 계산 - const columnBandsInfo = useMemo(() => { - const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; - if (!bands || bands.length === 0) return null; - - // 각 band의 시작 인덱스와 colspan 계산 - const bandInfo = bands - .map((band) => { - const visibleBandColumns = band.columns.filter((colName) => - visibleColumns.some((vc) => vc.columnName === colName), - ); - - const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName)); - - return { - caption: band.caption, - columns: visibleBandColumns, - colSpan: visibleBandColumns.length, - startIndex, - }; - }) - .filter((b) => b.colSpan > 0); - - // Band에 포함되지 않은 컬럼 찾기 - const bandedColumns = new Set(bands.flatMap((b) => b.columns)); - const unbandedColumns = visibleColumns - .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) - .filter((c) => !bandedColumns.has(c.columnName)); - - return { - bands: bandInfo, - unbandedColumns, - hasBands: bandInfo.length > 0, - }; - }, [tableConfig, visibleColumns]); - - // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 - const loadCascadingOptions = useCallback( - async (columnName: string, parentColumnName: string, parentValue: any) => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return; - - const cacheKey = `${columnName}_${parentValue}`; - - // 이미 로딩 중이면 스킵 - if (loadingCascading[cacheKey]) return; - - // 이미 캐시된 데이터가 있으면 스킵 - if (cascadingOptions[cacheKey]) return; - - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); - - try { - const { apiClient } = await import("@/lib/api/client"); - - // API에서 연계 옵션 로딩 - const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { - page: 1, - size: 1000, - search: { - [cascadingConfig.parentKeyField || parentColumnName]: parentValue, - }, - autoFilter: true, - }); - - const items = response.data?.data?.data || []; - const options = items.map((item: any) => ({ - value: item[cascadingConfig.valueField || "id"], - label: item[cascadingConfig.labelField || "name"], - })); - - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: options, - })); - } catch (error) { - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: [], - })); - } finally { - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); - } - }, - [tableConfig, cascadingOptions, loadingCascading], - ); - - // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 - const getCascadingOptions = useCallback( - (columnName: string, row: any): { value: string; label: string }[] => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return []; - - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue === undefined || parentValue === null) return []; - - const cacheKey = `${columnName}_${parentValue}`; - return cascadingOptions[cacheKey] || []; - }, - [tableConfig, cascadingOptions], - ); - - // 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조) - - // 🆕 Virtual Scrolling: 스크롤 핸들러 - const handleVirtualScroll = useCallback( - (e: React.UIEvent) => { - if (!isVirtualScrollEnabled) return; - setScrollTop(e.currentTarget.scrollTop); - }, - [isVirtualScrollEnabled], - ); - - // 🆕 State Persistence: 통합 상태 저장 - const saveTableState = useCallback(() => { - if (!tableStateKey) return; - - const state = { - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - frozenColumnCount, // 틀고정 컬럼 수 저장 - showGridLines, - headerFilters: Object.fromEntries( - Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), - ), - pageSize: localPageSize, - timestamp: Date.now(), - }; - - try { - sessionStorage.setItem(tableStateKey, JSON.stringify(state)); - } catch (error) { - console.error("❌ 테이블 상태 저장 실패:", error); - } - }, [ - tableStateKey, - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - frozenColumnCount, - showGridLines, - headerFilters, - localPageSize, - ]); - - // 🆕 State Persistence: 통합 상태 복원 - const loadTableState = useCallback(() => { - if (!tableStateKey) return; - - try { - const saved = sessionStorage.getItem(tableStateKey); - if (!saved) return; - - const state = JSON.parse(saved); - - if (state.columnWidths) setColumnWidths(state.columnWidths); - if (state.columnOrder) setColumnOrder(state.columnOrder); - if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); - if (state.sortDirection) setSortDirection(state.sortDirection); - if (state.groupByColumns) setGroupByColumns(state.groupByColumns); - if (state.frozenColumns) { - // 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지) - const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__"); - setFrozenColumns(restoredFrozenColumns); - } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 - if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); - if (state.headerFilters) { - const filters: Record> = {}; - Object.entries(state.headerFilters).forEach(([key, values]) => { - filters[key] = new Set(values as string[]); - }); - setHeaderFilters(filters); - } - } catch (error) { - console.error("❌ 테이블 상태 복원 실패:", error); - } - }, [tableStateKey]); - - // 🆕 State Persistence: 상태 초기화 - const resetTableState = useCallback(() => { - if (!tableStateKey) return; - - try { - sessionStorage.removeItem(tableStateKey); - setColumnWidths({}); - setColumnOrder([]); - setSortColumn(null); - setSortDirection("asc"); - setGroupByColumns([]); - setFrozenColumns([]); - setShowGridLines(true); - setHeaderFilters({}); - toast.success("테이블 설정이 초기화되었습니다."); - } catch (error) { - console.error("❌ 테이블 상태 초기화 실패:", error); - } - }, [tableStateKey]); - - // 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원 - useEffect(() => { - loadTableState(); - }, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지) - - // 🆕 Real-Time Updates: WebSocket 연결 - const connectWebSocket = useCallback(() => { - if (!isRealTimeEnabled || !tableConfig.selectedTable) return; - - const wsUrl = - (tableConfig as any).wsUrl || - `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; - - try { - setWsConnectionStatus("connecting"); - wsRef.current = new WebSocket(wsUrl); - - wsRef.current.onopen = () => { - setWsConnectionStatus("connected"); - }; - - wsRef.current.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - - switch (message.type) { - case "insert": - // 새 데이터 추가 - setRefreshTrigger((prev) => prev + 1); - toast.info("새 데이터가 추가되었습니다."); - break; - case "update": - // 데이터 업데이트 - setRefreshTrigger((prev) => prev + 1); - toast.info("데이터가 업데이트되었습니다."); - break; - case "delete": - // 데이터 삭제 - setRefreshTrigger((prev) => prev + 1); - toast.info("데이터가 삭제되었습니다."); - break; - case "refresh": - // 전체 새로고침 - setRefreshTrigger((prev) => prev + 1); - break; - default: - // 알 수 없는 메시지 타입 - break; - } - } catch (error) { - // WebSocket 메시지 파싱 오류 - } - }; - - wsRef.current.onclose = () => { - setWsConnectionStatus("disconnected"); - - // 자동 재연결 (5초 후) - if (isRealTimeEnabled) { - reconnectTimeoutRef.current = setTimeout(() => { - connectWebSocket(); - }, 5000); - } - }; - - wsRef.current.onerror = () => { - setWsConnectionStatus("disconnected"); - }; - } catch (error) { - setWsConnectionStatus("disconnected"); - } - }, [isRealTimeEnabled, tableConfig.selectedTable]); - - // 🆕 Real-Time Updates: 연결 관리 - useEffect(() => { - if (isRealTimeEnabled) { - connectWebSocket(); - } - - return () => { - // 정리 - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [isRealTimeEnabled, tableConfig.selectedTable]); - - // 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스) - useEffect(() => { - const timeoutId = setTimeout(() => { - saveTableState(); - }, 1000); // 1초 후 저장 (디바운스) - - return () => clearTimeout(timeoutId); - }, [ - columnWidths, - columnOrder, - sortColumn, - sortDirection, - groupByColumns, - frozenColumns, - showGridLines, - headerFilters, - ]); - - // 🆕 Clipboard: 선택된 데이터 복사 - const handleCopy = useCallback(async () => { - try { - // 선택된 행 데이터 가져오기 - let copyData: any[]; - - if (selectedRows.size > 0) { - // 선택된 행만 - copyData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } else if (focusedCell) { - // 포커스된 셀만 - const row = filteredData[focusedCell.rowIndex]; - if (row) { - const column = visibleColumns[focusedCell.colIndex]; - const value = row[column?.columnName]; - await navigator.clipboard.writeText(String(value ?? "")); - toast.success("셀 복사됨"); - return; - } - return; - } else { - toast.info("복사할 데이터를 선택해주세요."); - return; - } - - // TSV 형식으로 변환 (탭으로 구분) - const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); - const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); - const rows = copyData.map((row) => - exportColumns - .map((c) => { - const value = row[c.columnName]; - return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; - }) - .join("\t"), - ); - - const tsvContent = [headers.join("\t"), ...rows].join("\n"); - await navigator.clipboard.writeText(tsvContent); - - toast.success(`${copyData.length}행 복사됨`); - } catch (error) { - showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." }); - } - }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); - - // 🆕 전체 행 선택 - const handleSelectAllRows = useCallback(() => { - if (selectedRows.size === filteredData.length) { - // 전체 해제 - setSelectedRows(new Set()); - setIsAllSelected(false); - } else { - // 전체 선택 - const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); - setSelectedRows(allKeys); - setIsAllSelected(true); - } - }, [selectedRows.size, filteredData, getRowKey]); - - // 🆕 Context Menu: 열기 - const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { - e.preventDefault(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - rowIndex, - colIndex, - row, - }); - }, []); - - // 🆕 Context Menu: 닫기 - const closeContextMenu = useCallback(() => { - setContextMenu(null); - }, []); - - // 🆕 Context Menu: 외부 클릭 시 닫기 - useEffect(() => { - if (contextMenu) { - const handleClick = () => closeContextMenu(); - document.addEventListener("click", handleClick); - return () => document.removeEventListener("click", handleClick); - } - }, [contextMenu, closeContextMenu]); - - // 🆕 Search Panel: 통합 검색 실행 - const executeGlobalSearch = useCallback( - (term: string) => { - if (!term.trim()) { - setSearchHighlights(new Set()); - return; - } - - const lowerTerm = term.toLowerCase(); - const highlights = new Set(); - - filteredData.forEach((row, rowIndex) => { - visibleColumns.forEach((col, colIndex) => { - const value = row[col.columnName]; - if (value !== null && value !== undefined) { - const strValue = String(value).toLowerCase(); - if (strValue.includes(lowerTerm)) { - highlights.add(`${rowIndex}-${colIndex}`); - } - } - }); - }); - - setSearchHighlights(highlights); - - // 첫 번째 검색 결과로 포커스 이동 - if (highlights.size > 0) { - const firstHighlight = Array.from(highlights)[0]; - const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - toast.success(`${highlights.size}개 검색 결과`); - } else { - toast.info("검색 결과가 없습니다"); - } - }, - [filteredData, visibleColumns], - ); - - // 🆕 Search Panel: 다음 검색 결과로 이동 - const goToNextSearchResult = useCallback(() => { - if (searchHighlights.size === 0) return; - - const highlightArray = Array.from(searchHighlights).sort((a, b) => { - const [aRow, aCol] = a.split("-").map(Number); - const [bRow, bCol] = b.split("-").map(Number); - if (aRow !== bRow) return aRow - bRow; - return aCol - bCol; - }); - - if (!focusedCell) { - const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - return; - } - - const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; - const currentIndex = highlightArray.indexOf(currentKey); - const nextIndex = (currentIndex + 1) % highlightArray.length; - const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - }, [searchHighlights, focusedCell]); - - // 🆕 Search Panel: 이전 검색 결과로 이동 - const goToPrevSearchResult = useCallback(() => { - if (searchHighlights.size === 0) return; - - const highlightArray = Array.from(searchHighlights).sort((a, b) => { - const [aRow, aCol] = a.split("-").map(Number); - const [bRow, bCol] = b.split("-").map(Number); - if (aRow !== bRow) return aRow - bRow; - return aCol - bCol; - }); - - if (!focusedCell) { - const lastIdx = highlightArray.length - 1; - const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - return; - } - - const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; - const currentIndex = highlightArray.indexOf(currentKey); - const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; - const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - }, [searchHighlights, focusedCell]); - - // 🆕 Search Panel: 검색 초기화 - const clearGlobalSearch = useCallback(() => { - setGlobalSearchTerm(""); - setSearchHighlights(new Set()); - setIsSearchPanelOpen(false); - }, []); - - // 🆕 Filter Builder: 조건 추가 - const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: [ - ...group.conditions, - { - id: `cond-${Date.now()}`, - column: defaultColumn || "", - operator: "contains" as const, - value: "", - }, - ], - } - : group, - ), - ); - }, []); - - // 🆕 Filter Builder: 조건 삭제 - const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: group.conditions.filter((c) => c.id !== conditionId), - } - : group, - ), - ); - }, []); - - // 🆕 Filter Builder: 조건 업데이트 - const updateFilterCondition = useCallback( - (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { - setFilterGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)), - } - : group, - ), - ); - }, - [], - ); - - // 🆕 Filter Builder: 그룹 추가 - const addFilterGroup = useCallback((defaultColumn?: string) => { - setFilterGroups((prev) => [ - ...prev, - { - id: `group-${Date.now()}`, - logic: "AND" as const, - conditions: [ - { - id: `cond-${Date.now()}`, - column: defaultColumn || "", - operator: "contains" as const, - value: "", - }, - ], - }, - ]); - }, []); - - // 🆕 Filter Builder: 그룹 삭제 - const removeFilterGroup = useCallback((groupId: string) => { - setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); - }, []); - - // 🆕 Filter Builder: 그룹 로직 변경 - const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { - setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group))); - }, []); - - // 🆕 Filter Builder: 필터 적용 - const applyFilterBuilder = useCallback(() => { - // 유효한 조건 개수 계산 - let validConditions = 0; - filterGroups.forEach((group) => { - group.conditions.forEach((cond) => { - if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { - validConditions++; - } - }); - }); - setActiveFilterCount(validConditions); - setIsFilterBuilderOpen(false); - toast.success(`${validConditions}개 필터 조건 적용됨`); - }, [filterGroups]); - - // 🆕 Filter Builder: 필터 초기화 - const clearFilterBuilder = useCallback(() => { - setFilterGroups([]); - setActiveFilterCount(0); - toast.info("필터 초기화됨"); - }, []); - - // 🆕 Filter Builder: 조건 평가 함수 - const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { - const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; - const condValue = condition.value.toLowerCase(); - - switch (condition.operator) { - case "equals": - return strValue === condValue; - case "notEquals": - return strValue !== condValue; - case "contains": - return strValue.includes(condValue); - case "notContains": - return !strValue.includes(condValue); - case "startsWith": - return strValue.startsWith(condValue); - case "endsWith": - return strValue.endsWith(condValue); - case "greaterThan": - return parseFloat(strValue) > parseFloat(condValue); - case "lessThan": - return parseFloat(strValue) < parseFloat(condValue); - case "greaterOrEqual": - return parseFloat(strValue) >= parseFloat(condValue); - case "lessOrEqual": - return parseFloat(strValue) <= parseFloat(condValue); - case "isEmpty": - return strValue === "" || value === null || value === undefined; - case "isNotEmpty": - return strValue !== "" && value !== null && value !== undefined; - default: - return true; - } - }, []); - - // 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인 - const rowPassesFilterBuilder = useCallback( - (row: any): boolean => { - if (filterGroups.length === 0) return true; - - // 모든 그룹이 AND로 연결됨 (그룹 간) - return filterGroups.every((group) => { - const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), - ); - if (validConditions.length === 0) return true; - - if (group.logic === "AND") { - return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); - } else { - return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); - } - }); - }, - [filterGroups, evaluateCondition], - ); - - // 🆕 컬럼 드래그 시작 - const handleColumnDragStart = useCallback( - (e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled) return; - - setDraggedColumnIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", `col-${index}`); - }, - [isColumnDragEnabled], - ); - - // 🆕 컬럼 드래그 오버 - const handleColumnDragOver = useCallback( - (e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled || draggedColumnIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedColumnIndex) { - setDropTargetColumnIndex(index); - } - }, - [isColumnDragEnabled, draggedColumnIndex], - ); - - // 🆕 컬럼 드래그 종료 - const handleColumnDragEnd = useCallback(() => { - setDraggedColumnIndex(null); - setDropTargetColumnIndex(null); - }, []); - - // 🆕 컬럼 드롭 - const handleColumnDrop = useCallback( - (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { - handleColumnDragEnd(); - return; - } - - // 컬럼 순서 변경 - const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; - const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); - newOrder.splice(targetIndex, 0, movedColumn); - - setColumnOrder(newOrder); - toast.info("컬럼 순서가 변경되었습니다."); - - handleColumnDragEnd(); - }, - [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd], - ); - - // 🆕 행 드래그 시작 - const handleRowDragStart = useCallback( - (e: React.DragEvent, index: number) => { - if (!isDragEnabled) return; - - setDraggedRowIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(index)); - - // 드래그 이미지 설정 (반투명) - const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; - dragImage.style.opacity = "0.5"; - dragImage.style.position = "absolute"; - dragImage.style.top = "-1000px"; - document.body.appendChild(dragImage); - e.dataTransfer.setDragImage(dragImage, 0, 0); - setTimeout(() => document.body.removeChild(dragImage), 0); - }, - [isDragEnabled], - ); - - // 🆕 행 드래그 오버 - const handleRowDragOver = useCallback( - (e: React.DragEvent, index: number) => { - if (!isDragEnabled || draggedRowIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedRowIndex) { - setDropTargetIndex(index); - } - }, - [isDragEnabled, draggedRowIndex], - ); - - // 🆕 행 드래그 종료 - const handleRowDragEnd = useCallback(() => { - setDraggedRowIndex(null); - setDropTargetIndex(null); - }, []); - - // 🆕 행 드롭 - const handleRowDrop = useCallback( - async (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { - handleRowDragEnd(); - return; - } - - try { - // 로컬 데이터 재정렬 - const newData = [...filteredData]; - const [movedRow] = newData.splice(draggedRowIndex, 1); - newData.splice(targetIndex, 0, movedRow); - - // 서버에 순서 저장 (order_index 필드가 있는 경우) - const orderField = (tableConfig as any).orderField || "order_index"; - const hasOrderField = newData[0] && orderField in newData[0]; - - if (hasOrderField && tableConfig.selectedTable) { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 영향받는 행들의 순서 업데이트 - const updates = newData.map((row, idx) => ({ - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: row[primaryKeyField], - updateField: orderField, - updateValue: idx + 1, - })); - - // 배치 업데이트 - await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update))); - - toast.success("순서가 변경되었습니다."); - setRefreshTrigger((prev) => prev + 1); - } else { - // 로컬에서만 순서 변경 (저장 안함) - toast.info("순서가 변경되었습니다. (로컬만)"); - } - } catch (error) { - toast.error("순서 변경 중 오류가 발생했습니다."); - } - - handleRowDragEnd(); - }, - [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd], - ); - - // 🆕 PDF 내보내기 (인쇄용 HTML 생성) - const exportToPdf = useCallback( - (exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } - - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 인쇄용 HTML 생성 - const printContent = ` - - - - - ${tableLabel || tableConfig.selectedTable || "데이터"} - - - -

${tableLabel || tableConfig.selectedTable || "데이터 목록"}

-
- 출력일: ${new Date().toLocaleDateString("ko-KR")} | - 총 ${exportData.length}건 -
- - - - ${exportColumns.map((col) => ``).join("")} - - - - ${exportData - .map( - (row) => ` - - ${exportColumns - .map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - let value = row[mappedColumnName]; - - // 카테고리 매핑 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) value = mapping.label; - } - - const meta = columnMeta[col.columnName]; - const inputType = meta?.inputType || (col as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return ``; - }) - .join("")} - - `, - ) - .join("")} - -
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
- - - `; - - // 새 창에서 인쇄 - const printWindow = window.open("", "_blank"); - if (printWindow) { - printWindow.document.write(printContent); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; - toast.success("인쇄 창이 열렸습니다."); - } else { - toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); - } - } catch (error) { - console.error("❌ PDF 내보내기 실패:", error); - showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); - } - }, - [ - filteredData, - selectedRows, - visibleColumns, - columnLabels, - joinColumnMapping, - categoryMappings, - columnMeta, - tableLabel, - tableConfig.selectedTable, - getRowKey, - ], - ); - - // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) - const handleEditKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case "Enter": - e.preventDefault(); - saveEditing(); - break; - case "Escape": - e.preventDefault(); - cancelEditing(); - break; - case "Tab": - e.preventDefault(); - saveEditing(); - // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 - break; - } - }, - [saveEditing, cancelEditing], - ); - - // 🆕 편집 입력 필드가 나타나면 자동 포커스 - useEffect(() => { - if (editingCell && editInputRef.current) { - editInputRef.current.focus(); - // select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가) - if (typeof editInputRef.current.select === "function") { - editInputRef.current.select(); - } - } - }, [editingCell]); - - // 🆕 포커스된 셀로 스크롤 - useEffect(() => { - if (focusedCell && tableContainerRef.current) { - const focusedCellElement = tableContainerRef.current.querySelector( - `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`, - ) as HTMLElement; - - if (focusedCellElement) { - focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); - } - } - }, [focusedCell]); - - // 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능) - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(); - }; - - // ======================================== - // 컬럼 관련 (visibleColumns는 상단에서 정의됨) - // ======================================== - - // 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달 - const lastColumnOrderRef = useRef(""); - - useEffect(() => { - // console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", { - // hasCallback: !!onSelectedRowsChange, - // visibleColumnsLength: visibleColumns.length, - // visibleColumnsNames: visibleColumns.map((c) => c.columnName), - // }); - - if (!onSelectedRowsChange) { - // console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!"); - return; - } - - if (visibleColumns.length === 0) { - // console.warn("⚠️ visibleColumns가 비어있습니다!"); - return; - } - - const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외 - - // console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder); - - // 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지) - const columnOrderString = currentColumnOrder.join(","); - // console.log("🔍 [컬럼 순서] 비교:", { - // current: columnOrderString, - // last: lastColumnOrderRef.current, - // isDifferent: columnOrderString !== lastColumnOrderRef.current, - // }); - - if (columnOrderString === lastColumnOrderRef.current) { - // console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵"); - return; - } - - lastColumnOrderRef.current = columnOrderString; - // console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder); - - // 선택된 행 데이터 가져오기 - const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); - - // 화면에 표시된 데이터를 컬럼 순서대로 재정렬 - const reorderedData = data.map((row: any) => { - const reordered: any = {}; - visibleColumns.forEach((col) => { - if (col.columnName in row) { - reordered[col.columnName] = row[col.columnName]; - } - }); - // 나머지 컬럼 추가 - Object.keys(row).forEach((key) => { - if (!(key in reordered)) { - reordered[key] = row[key]; - } - }); - return reordered; - }); - - onSelectedRowsChange( - Array.from(selectedRows), - selectedRowsData, - sortColumn ?? undefined, - sortDirection, - currentColumnOrder, - reorderedData, - ); - }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 - - // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) - const handleTableKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) - if (editingCell) return; - - if (!focusedCell || data.length === 0) return; - - const { rowIndex, colIndex } = focusedCell; - const maxRowIndex = data.length - 1; - const maxColIndex = visibleColumns.length - 1; - - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); - } - break; - case "ArrowDown": - e.preventDefault(); - if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } - break; - case "ArrowRight": - e.preventDefault(); - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } - break; - case "Enter": - e.preventDefault(); - // 현재 행 선택/해제 - const enterRow = data[rowIndex]; - if (enterRow) { - const rowKey = getRowKey(enterRow, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); - } - break; - case " ": // Space - e.preventDefault(); - // 체크박스 토글 - const spaceRow = data[rowIndex]; - if (spaceRow) { - const currentRowKey = getRowKey(spaceRow, rowIndex); - const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked); - } - break; - case "F2": - // 🆕 F2: 편집 모드 진입 - e.preventDefault(); - { - const col = visibleColumns[colIndex]; - if (col && col.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (col.editable === false) { - toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - const row = data[rowIndex]; - const mappedCol = joinColumnMapping[col.columnName] || col.columnName; - const val = row?.[mappedCol]; - setEditingCell({ - rowIndex, - colIndex, - columnName: col.columnName, - originalValue: val, - }); - setEditingValue(val !== null && val !== undefined ? String(val) : ""); - } - } - break; - case "b": - case "B": - // 🆕 Ctrl+B: 배치 편집 모드 토글 - if (e.ctrlKey) { - e.preventDefault(); - setEditMode((prev) => { - const newMode = prev === "immediate" ? "batch" : "immediate"; - if (newMode === "immediate" && pendingChanges.size > 0) { - // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 - const confirmDiscard = window.confirm( - `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`, - ); - if (confirmDiscard) { - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("배치 편집 모드 종료"); - return "immediate"; - } - return "batch"; - } - toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); - return newMode; - }); - } - break; - case "s": - case "S": - // 🆕 Ctrl+S: 배치 저장 - if (e.ctrlKey && editMode === "batch") { - e.preventDefault(); - saveBatchChanges(); - } - break; - case "c": - case "C": - // 🆕 Ctrl+C: 선택된 행/셀 복사 - if (e.ctrlKey) { - e.preventDefault(); - handleCopy(); - } - break; - case "v": - case "V": - // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) - if (e.ctrlKey && editingCell) { - // 기본 동작 허용 (input에서 처리) - } - break; - case "a": - case "A": - // 🆕 Ctrl+A: 전체 선택 - if (e.ctrlKey) { - e.preventDefault(); - handleSelectAllRows(); - } - break; - case "f": - case "F": - // 🆕 Ctrl+F: 통합 검색 패널 열기 - if (e.ctrlKey) { - e.preventDefault(); - setIsSearchPanelOpen(true); - } - break; - case "F3": - // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - break; - case "Home": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+Home: 첫 번째 셀로 - setFocusedCell({ rowIndex: 0, colIndex: 0 }); - } else { - // Home: 현재 행의 첫 번째 셀로 - setFocusedCell({ rowIndex, colIndex: 0 }); - } - break; - case "End": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+End: 마지막 셀로 - setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); - } else { - // End: 현재 행의 마지막 셀로 - setFocusedCell({ rowIndex, colIndex: maxColIndex }); - } - break; - case "PageUp": - e.preventDefault(); - // 10행 위로 - setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); - break; - case "PageDown": - e.preventDefault(); - // 10행 아래로 - setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); - break; - case "Escape": - e.preventDefault(); - // 포커스 해제 - setFocusedCell(null); - break; - case "Tab": - e.preventDefault(); - if (e.shiftKey) { - // Shift+Tab: 이전 셀 - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } else if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); - } - } else { - // Tab: 다음 셀 - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } else if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); - } - } - break; - default: - // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) - if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - const column = visibleColumns[colIndex]; - if (column && column.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (column.editable === false) { - toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - e.preventDefault(); - // 편집 시작 (현재 키를 초기값으로) - const row = data[rowIndex]; - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const value = row?.[mappedColumnName]; - - setEditingCell({ - rowIndex, - colIndex, - columnName: column.columnName, - originalValue: value, - }); - setEditingValue(e.key); // 입력한 키로 시작 - } - } - break; - } - }, - [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection, isDesignMode], - ); - - const getColumnWidth = (column: ColumnConfig) => { - if (column.columnName === "__checkbox__") return 50; - if (column.width) return column.width; - - switch (column.format) { - case "date": - return 120; - case "number": - case "currency": - return 100; - case "boolean": - return 80; - default: - return 150; - } - }; - - const renderCheckboxHeader = () => { - if (!tableConfig.checkbox?.selectAll) return null; - if (tableConfig.checkbox?.multiple === false) return null; - - return ( - - ); - }; - - const renderCheckboxCell = (row: any, index: number) => { - const rowKey = getRowKey(row, index); - const isChecked = selectedRows.has(rowKey); - - return ( - handleRowSelection(rowKey, checked as boolean)} - aria-label={`행 ${index + 1} 선택`} - style={{ - width: 16, - height: 16, - borderWidth: 1.5, - borderColor: isChecked ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)", - }} - /> - ); - }; - - const formatCellValue = useCallback( - (value: any, column: ColumnConfig, rowData?: Record) => { - // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 - // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) - if (column.entityDisplayConfig && rowData) { - const displayColumns = - column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; - const separator = column.entityDisplayConfig.separator; - - if (displayColumns && displayColumns.length > 0) { - // 선택된 컬럼들의 값을 구분자로 조합 - const values = displayColumns - .map((colName: string) => { - // 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn} - // 예: manager 컬럼에서 user_name 선택 시 → manager_user_name - const joinedKey = `${column.columnName}_${colName}`; - let cellValue = rowData[joinedKey]; - - // fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) - if (cellValue === null || cellValue === undefined) { - cellValue = rowData[colName]; - } - - if (cellValue === null || cellValue === undefined) return ""; - return String(cellValue); - }) - .filter((v: string) => v !== ""); // 빈 값 제외 - - const result = values.join(separator || " - "); - if (result) { - return result; // 결과가 있으면 반환 - } - // 결과가 비어있으면 아래로 계속 진행 (원래 값 사용) - } - } - - // value가 null/undefined면 "-" 반환 - if (value === null || value === undefined) return "-"; - - // 🎯 writer 컬럼 자동 변환: user_id -> user_name - if (column.columnName === "writer" && rowData && rowData.writer_name) { - return rowData.writer_name; - } - - // 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기 - const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName]; - - // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) - const inputType = meta?.inputType || column.inputType; - - // 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용) - if (inputType === "image" && value) { - return ; - } - - // 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원) - const isAttachmentColumn = - inputType === "file" || - inputType === "attachment" || - column.columnName === "attachments" || - column.columnName?.toLowerCase().includes("attachment") || - column.columnName?.toLowerCase().includes("file"); - - if (isAttachmentColumn && value) { - return ; - } - if (isAttachmentColumn && !value) { - return -; - } - - // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) - if (inputType === "category") { - if (!value) return ""; - - // 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기 - // 1. 원래 컬럼명 (item_info.material) - // 2. 점(.) 뒤의 컬럼명만 (material) - let mapping = categoryMappings[column.columnName]; - - if (!mapping && column.columnName.includes(".")) { - const simpleColumnName = column.columnName.split(".").pop(); - if (simpleColumnName) { - mapping = categoryMappings[simpleColumnName]; - } - } - - const { Badge } = require("@/components/ui/badge"); - - // 다중 값 처리: 콤마로 구분된 값들을 분리 - const valueStr = String(value); - const values = valueStr.includes(",") - ? valueStr - .split(",") - .map((v) => v.trim()) - .filter((v) => v) - : [valueStr]; - - // 단일 값인 경우 (기존 로직) - if (values.length === 1) { - const categoryData = mapping?.[values[0]]; - const displayLabel = categoryData?.label || values[0]; - const displayColor = categoryData?.color; - - // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 - if (!displayColor || displayColor === "none" || !categoryData) { - return {displayLabel}; - } - - return ( - - {displayLabel} - - ); - } - - // 다중 값인 경우: 여러 배지 렌더링 - return ( -
- {values.map((val, idx) => { - const categoryData = mapping?.[val]; - const displayLabel = categoryData?.label || val; - const displayColor = categoryData?.color; - - // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 - if (!displayColor || displayColor === "none" || !categoryData) { - return ( - - {displayLabel} - {idx < values.length - 1 && ", "} - - ); - } - - return ( - - {displayLabel} - - ); - })} -
- ); - } - - // 코드 타입: 코드 값 → 코드명 변환 - if (inputType === "code" && meta?.codeInfo && value) { - try { - // optimizedConvertCode(categoryCode, codeValue) 순서 주의! - const convertedValue = optimizedConvertCode(meta.codeInfo, value); - // 변환에 성공했으면 변환된 코드명 반환 - if (convertedValue && convertedValue !== value) { - return convertedValue; - } - } catch (error) { - console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeInfo}, 값: ${value}`, error); - } - // 변환 실패 시 원본 코드 값 반환 - return String(value); - } - - // 날짜 타입 포맷팅 (yyyy-mm-dd) - if (inputType === "date" || inputType === "datetime") { - if (value) { - try { - const date = new Date(value); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - } catch { - return String(value); - } - } - return "-"; - } - - // 숫자 타입 포맷팅 (공통 formatNumber 사용) - if (inputType === "number" || inputType === "decimal") { - if (value !== null && value !== undefined && value !== "") { - if (column.thousandSeparator !== false) { - return centralFormatNumber(value); - } - const numValue = typeof value === "string" ? parseFloat(value) : value; - return isNaN(numValue) ? String(value) : String(numValue); - } - return String(value); - } - - switch (column.format) { - case "number": - if (value !== null && value !== undefined && value !== "") { - if (column.thousandSeparator !== false) { - return centralFormatNumber(value); - } - const numValue = typeof value === "string" ? parseFloat(value) : value; - return isNaN(numValue) ? String(value) : String(numValue); - } - return String(value); - case "date": - if (value) { - try { - const date = new Date(value); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - } catch { - return value; - } - } - return "-"; - case "currency": - if (value !== null && value !== undefined && value !== "") { - return centralFormatCurrency(value); - } - return value; - case "boolean": - return value ? "예" : "아니오"; - default: - return String(value); - } - }, - [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], - ); - - // ======================================== - // useEffect 훅 - // ======================================== - - // 필터 설정 sessionStorage 키 생성 (탭 ID 스코프) - const filterSettingKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - const base = screenId - ? `${tableConfig.selectedTable}_screen_${screenId}` - : tableConfig.selectedTable; - if (currentTabId) return `filterSettings_${currentTabId}_${base}`; - return `filterSettings_${base}`; - }, [tableConfig.selectedTable, screenId, currentTabId]); - - // 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프) - const groupSettingKey = useMemo(() => { - if (!tableConfig.selectedTable) return null; - const base = screenId - ? `${tableConfig.selectedTable}_screen_${screenId}` - : tableConfig.selectedTable; - if (currentTabId) return `groupSettings_${currentTabId}_${base}`; - return `groupSettings_${base}`; - }, [tableConfig.selectedTable, screenId, currentTabId]); - - // 저장된 필터 설정 불러오기 - useEffect(() => { - if (!filterSettingKey || visibleColumns.length === 0) return; - - try { - const saved = sessionStorage.getItem(filterSettingKey); - if (saved) { - const savedFilters = JSON.parse(saved); - setVisibleFilterColumns(new Set(savedFilters)); - } else { - // 초기값: 빈 Set (아무것도 선택 안 함) - setVisibleFilterColumns(new Set()); - } - } catch (error) { - console.error("필터 설정 불러오기 실패:", error); - setVisibleFilterColumns(new Set()); - } - }, [filterSettingKey, visibleColumns]); - - // 필터 설정 저장 - const saveFilterSettings = useCallback(() => { - if (!filterSettingKey) return; - - try { - sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); - setIsFilterSettingOpen(false); - toast.success("검색 필터 설정이 저장되었습니다"); - - // 검색 값 초기화 - setSearchValues({}); - } catch (error) { - console.error("필터 설정 저장 실패:", error); - showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); - } - }, [filterSettingKey, visibleFilterColumns]); - - // 필터 컬럼 토글 - const toggleFilterVisibility = useCallback((columnName: string) => { - setVisibleFilterColumns((prev) => { - const newSet = new Set(prev); - if (newSet.has(columnName)) { - newSet.delete(columnName); - } else { - newSet.add(columnName); - } - return newSet; - }); - }, []); - - // 전체 선택/해제 - const toggleAllFilters = useCallback(() => { - const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - const columnNames = filterableColumns.map((col) => col.columnName); - - if (visibleFilterColumns.size === columnNames.length) { - // 전체 해제 - setVisibleFilterColumns(new Set()); - } else { - // 전체 선택 - setVisibleFilterColumns(new Set(columnNames)); - } - }, [visibleFilterColumns, visibleColumns]); - - // 표시할 필터 목록 (선택된 컬럼만) - const activeFilters = useMemo(() => { - return visibleColumns - .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) - .map((col) => ({ - columnName: col.columnName, - label: columnLabels[col.columnName] || col.displayName || col.columnName, - type: col.format || "text", - })); - }, [visibleColumns, visibleFilterColumns, columnLabels]); - - // 그룹 설정 자동 저장 (localStorage) - useEffect(() => { - if (!groupSettingKey) return; - - try { - sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); - } catch (error) { - console.error("그룹 설정 저장 실패:", error); - } - }, [groupSettingKey, groupByColumns]); - - // 그룹 컬럼 토글 - const toggleGroupColumn = useCallback((columnName: string) => { - setGroupByColumns((prev) => { - if (prev.includes(columnName)) { - return prev.filter((col) => col !== columnName); - } else { - return [...prev, columnName]; - } - }); - }, []); - - // 사용자 옵션 저장 핸들러 - const handleTableOptionsSave = useCallback( - (config: { - columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>; - showGridLines: boolean; - viewMode: "table" | "card" | "grouped-card"; - }) => { - // 컬럼 순서 업데이트 - const newColumnOrder = config.columns.map((col) => col.columnName); - setColumnOrder(newColumnOrder); - - // 컬럼 너비 업데이트 - const newWidths: Record = {}; - config.columns.forEach((col) => { - if (col.width) { - newWidths[col.columnName] = col.width; - } - }); - setColumnWidths(newWidths); - - // 틀고정 컬럼 업데이트 - const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); - setFrozenColumns(newFrozenColumns); - - // 그리드선 표시 업데이트 - setShowGridLines(config.showGridLines); - - // 보기 모드 업데이트 - setViewMode(config.viewMode); - - // 컬럼 표시/숨기기 업데이트 - const newDisplayColumns = displayColumns.map((col) => { - const configCol = config.columns.find((c) => c.columnName === col.columnName); - if (configCol) { - return { ...col, visible: configCol.visible }; - } - return col; - }); - setDisplayColumns(newDisplayColumns); - - toast.success("테이블 옵션이 저장되었습니다"); - }, - [displayColumns], - ); - - // 그룹 펼치기/접기 토글 - const toggleGroupCollapse = useCallback((groupKey: string) => { - setCollapsedGroups((prev) => { - const newSet = new Set(prev); - if (newSet.has(groupKey)) { - newSet.delete(groupKey); - } else { - newSet.add(groupKey); - } - return newSet; - }); - }, []); - - // 그룹 해제 - const clearGrouping = useCallback(() => { - setGroupByColumns([]); - setCollapsedGroups(new Set()); - if (groupSettingKey) { - sessionStorage.removeItem(groupSettingKey); - } - toast.success("그룹이 해제되었습니다"); - }, [groupSettingKey]); - - // 데이터 그룹화 - const groupedData = useMemo((): GroupedData[] => { - if (groupByColumns.length === 0 || filteredData.length === 0) return []; - - const grouped = new Map(); - - filteredData.forEach((item) => { - // 그룹 키 생성: "통화:KRW > 단위:EA" - const keyParts = groupByColumns.map((col) => { - // 카테고리/엔티티 타입인 경우 _name 필드 사용 - const inputType = columnMeta?.[col]?.inputType; - let displayValue = item[col]; - - if (inputType === "category" || inputType === "entity" || inputType === "code") { - // _name 필드가 있으면 사용 (예: division_name, writer_name) - const nameField = `${col}_name`; - if (item[nameField] !== undefined && item[nameField] !== null) { - displayValue = item[nameField]; - } - } - - const label = columnLabels[col] || col; - return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`; - }); - const groupKey = keyParts.join(" > "); - - if (!grouped.has(groupKey)) { - grouped.set(groupKey, []); - } - grouped.get(groupKey)!.push(item); - }); - - return Array.from(grouped.entries()).map(([groupKey, items]) => { - const groupValues: Record = {}; - groupByColumns.forEach((col) => { - groupValues[col] = items[0]?.[col]; - }); - - // 🆕 그룹별 소계 계산 - const groupSummary: Record = {}; - - // 숫자형 컬럼에 대해 소계 계산 - (tableConfig.columns || []).forEach((col: { columnName: string }) => { - if (col.columnName === "__checkbox__") return; - - const colMeta = columnMeta?.[col.columnName]; - const inputType = colMeta?.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - if (isNumeric) { - const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v)); - - if (values.length > 0) { - const sum = values.reduce((a, b) => a + b, 0); - groupSummary[col.columnName] = { - sum, - avg: sum / values.length, - count: values.length, - }; - } - } - }); - - return { - groupKey, - groupValues, - items, - count: items.length, - summary: groupSummary, // 🆕 그룹별 소계 - }; - }); - }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); - - // 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우) - // ★ groupByColumn 은 GroupSumConfig 가 snake_case (group_by_column) 만 정의하지만, - // 런타임은 camelCase 로 받아서 매핑한다 (FilterPanel 측 호환). cast 로만 우회. - const summedData = useMemo(() => { - // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 - if (!groupSumConfig?.enabled || !(groupSumConfig as any)?.groupByColumn) { - return filteredData; - } - - const groupByColumn = (groupSumConfig as any).groupByColumn; - const groupMap = new Map(); - - // 조인 컬럼인지 확인하고 실제 키 추론 - const getActualKey = (columnName: string, item: any): string => { - if (columnName.includes(".")) { - const [refTable, fieldName] = columnName.split("."); - const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); - const exactKey = `${inferredSourceColumn}_${fieldName}`; - if (item[exactKey] !== undefined) return exactKey; - if (fieldName === "item_name" || fieldName === "name") { - const aliasKey = `${inferredSourceColumn}_name`; - if (item[aliasKey] !== undefined) return aliasKey; - } - } - return columnName; - }; - - // 숫자 타입인지 확인하는 함수 - const isNumericValue = (value: any): boolean => { - if (value === null || value === undefined || value === "") return false; - const num = parseFloat(String(value)); - return !isNaN(num) && isFinite(num); - }; - - // 그룹핑 수행 - filteredData.forEach((item) => { - const actualKey = getActualKey(groupByColumn, item); - const groupValue = String(item[actualKey] || item[groupByColumn] || ""); - - if (!groupMap.has(groupValue)) { - // 첫 번째 항목을 기준으로 초기화 - groupMap.set(groupValue, { ...item, _groupCount: 1 }); - } else { - const existing = groupMap.get(groupValue); - existing._groupCount += 1; - - // 모든 키에 대해 숫자면 합산 - Object.keys(item).forEach((key) => { - const value = item[key]; - if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { - const numValue = parseFloat(String(value)); - const existingValue = parseFloat(String(existing[key] || 0)); - existing[key] = existingValue + numValue; - } - }); - - groupMap.set(groupValue, existing); - } - }); - - const result = Array.from(groupMap.values()); - - return result; - }, [filteredData, groupSumConfig]); - - // 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData - const displayData = useMemo(() => { - return groupSumConfig?.enabled ? summedData : filteredData; - }, [groupSumConfig?.enabled, summedData, filteredData]); - - // 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치) - const virtualScrollInfo = useMemo(() => { - const dataSource = displayData; - if (!isVirtualScrollEnabled || dataSource.length === 0) { - return { - startIndex: 0, - endIndex: dataSource.length, - visibleData: dataSource, - topSpacerHeight: 0, - bottomSpacerHeight: 0, - totalHeight: dataSource.length * ROW_HEIGHT, - }; - } - - const containerHeight = scrollContainerRef.current?.clientHeight || 600; - const totalRows = dataSource.length; - const totalHeight = totalRows * ROW_HEIGHT; - - // 현재 보이는 행 범위 계산 - const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); - const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; - const endIndex = Math.min(totalRows, startIndex + visibleRowCount); - - return { - startIndex, - endIndex, - visibleData: dataSource.slice(startIndex, endIndex), - topSpacerHeight: startIndex * ROW_HEIGHT, - bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, - totalHeight, - }; - }, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]); - - // 저장된 그룹 설정 불러오기 - useEffect(() => { - if (!groupSettingKey || visibleColumns.length === 0) return; - - try { - const saved = sessionStorage.getItem(groupSettingKey); - if (saved) { - const savedGroups = JSON.parse(saved); - setGroupByColumns(savedGroups); - } - } catch (error) { - console.error("그룹 설정 불러오기 실패:", error); - } - }, [groupSettingKey, visibleColumns]); - - useEffect(() => { - fetchColumnLabels(); - fetchTableLabel(); - }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); - - // 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성 - const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right"; - const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selected_left_data : null; - - useEffect(() => { - // console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", { - // isDesignMode, - // tableName: tableConfig.selectedTable, - // currentPage, - // sortColumn, - // sortDirection, - // }); - - if (!isDesignMode && tableConfig.selectedTable) { - fetchTableDataDebounced(); - } - }, [ - tableConfig.selectedTable, - currentPage, - localPageSize, - sortColumn, - sortDirection, - searchTerm, - searchValues, // 필터 값 변경 시에도 데이터 새로고침 - refreshKey, - refreshTrigger, // 강제 새로고침 트리거 - isDesignMode, - selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침 - // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 - ]); - - useEffect(() => { - if (tableConfig.refreshInterval && !isDesignMode) { - const interval = setInterval(() => { - fetchTableDataDebounced(); - }, tableConfig.refreshInterval * 1000); - - return () => clearInterval(interval); - } - }, [tableConfig.refreshInterval, isDesignMode]); - - // 🆕 전역 테이블 새로고침 이벤트 리스너 - useEffect(() => { - const handleRefreshTable = () => { - if (tableConfig.selectedTable && !isDesignMode) { - setRefreshTrigger((prev) => prev + 1); - } - }; - - // V2 EventBus 구독 (레거시 어댑터가 window 이벤트도 브릿지) - const unsubscribe = v2EventBus.subscribe( - V2_EVENTS.TABLE_REFRESH, - (payload) => { - // 특정 테이블만 새로고침하거나 전체 새로고침 - if (!payload.tableName || payload.tableName === tableConfig.selectedTable) { - handleRefreshTable(); - } - }, - { componentId: component.id } - ); - - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) - window.addEventListener("refreshTable", handleRefreshTable); - - return () => { - unsubscribe(); - window.removeEventListener("refreshTable", handleRefreshTable); - }; - }, [tableConfig.selectedTable, isDesignMode, component.id]); - - // 테이블명 변경 시 전역 레지스트리에서 확인 - useEffect(() => { - if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) { - const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable); - if (isTarget) { - setIsRelatedButtonTarget(true); - } - } - }, [tableConfig.selectedTable]); - - // RelatedDataButtons 등록/해제 이벤트 리스너 - useEffect(() => { - const handleRelatedButtonRegister = (event: CustomEvent) => { - const { targetTable } = event.detail || {}; - if (targetTable === tableConfig.selectedTable) { - setIsRelatedButtonTarget(true); - } - }; - - const handleRelatedButtonUnregister = (event: CustomEvent) => { - const { targetTable } = event.detail || {}; - if (targetTable === tableConfig.selectedTable) { - setIsRelatedButtonTarget(false); - setRelatedButtonFilter(null); - } - }; - - // V2 EventBus 구독 - const unsubscribeRegister = v2EventBus.subscribe( - V2_EVENTS.RELATED_BUTTON_REGISTER, - (payload) => { - if (payload.targetTables.includes(tableConfig.selectedTable || "")) { - setIsRelatedButtonTarget(true); - } - }, - { componentId: component.id } - ); - - const unsubscribeUnregister = v2EventBus.subscribe( - V2_EVENTS.RELATED_BUTTON_UNREGISTER, - (payload) => { - if (payload.buttonId) { - setIsRelatedButtonTarget(false); - setRelatedButtonFilter(null); - } - }, - { componentId: component.id } - ); - - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) - window.addEventListener("related-button-register" as any, handleRelatedButtonRegister); - window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister); - - return () => { - unsubscribeRegister(); - unsubscribeUnregister(); - window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister); - window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister); - }; - }, [tableConfig.selectedTable, component.id]); - - // RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) - useEffect(() => { - const handleRelatedButtonSelect = (event: CustomEvent) => { - const { targetTable, filterColumn, filterValue } = event.detail || {}; - - // 이 테이블이 대상 테이블인지 확인 - if (targetTable === tableConfig.selectedTable) { - // filterValue가 null이면 선택 해제 (빈 상태) - if (filterValue === null || filterValue === undefined) { - setRelatedButtonFilter(null); - setIsRelatedButtonTarget(true); // 대상으로 등록은 유지 - } else { - setRelatedButtonFilter({ filterColumn, filterValue }); - setIsRelatedButtonTarget(true); - } - } - }; - - // V2 EventBus 구독 - const unsubscribeSelect = v2EventBus.subscribe( - V2_EVENTS.RELATED_BUTTON_SELECT, - (payload) => { - if (payload.tableName === tableConfig.selectedTable) { - if (!payload.selectedData || payload.selectedData.length === 0) { - setRelatedButtonFilter(null); - setIsRelatedButtonTarget(true); - } else { - // 첫 번째 선택된 데이터의 ID를 필터로 사용 - const firstItem = payload.selectedData[0]; - if (firstItem?.id) { - setRelatedButtonFilter({ filterColumn: "id", filterValue: firstItem.id }); - } - setIsRelatedButtonTarget(true); - } - } - }, - { componentId: component.id } - ); - - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) - window.addEventListener("related-button-select" as any, handleRelatedButtonSelect); - - return () => { - unsubscribeSelect(); - window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect); - }; - }, [tableConfig.selectedTable, component.id]); - - // 🆕 relatedButtonFilter 변경 시 데이터 다시 로드 - useEffect(() => { - if (!isDesignMode) { - // relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거) - // RelatedDataButtons 상태 변경 - setRefreshTrigger((prev) => prev + 1); - } - }, [relatedButtonFilter, isDesignMode]); - - // 🎯 컬럼 너비 자동 계산 (내용 기반) - const calculateOptimalColumnWidth = useCallback( - (columnName: string, displayName: string): number => { - // 기본 너비 설정 - const MIN_WIDTH = 100; - const MAX_WIDTH = 400; - const PADDING = 48; // 좌우 패딩 + 여유 공간 - const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) - - // 헤더 텍스트 너비 계산 (대략 8px per character) - const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; - - // 데이터 셀 너비 계산 (상위 50개 샘플링) - const sampleSize = Math.min(50, data.length); - let maxDataWidth = headerWidth; - - for (let i = 0; i < sampleSize; i++) { - const cellValue = data[i]?.[columnName]; - if (cellValue !== null && cellValue !== undefined) { - const cellText = String(cellValue); - // 숫자는 좁게, 텍스트는 넓게 계산 - const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; - const charWidth = isNumber ? 8 : 9; - const cellWidth = cellText.length * charWidth + PADDING; - maxDataWidth = Math.max(maxDataWidth, cellWidth); - } - } - - // 최소/최대 범위 내로 제한 - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); - }, - [data], - ); - - // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 - useEffect(() => { - if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { - const timer = setTimeout(() => { - const storageKey = - tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null; - - // 1. localStorage에서 저장된 너비 불러오기 - let savedWidths: Record = {}; - if (storageKey) { - try { - const saved = localStorage.getItem(storageKey); - if (saved) { - savedWidths = JSON.parse(saved); - } - } catch (error) { - console.error("컬럼 너비 불러오기 실패:", error); - } - } - - // 2. 자동 계산 또는 저장된 너비 적용 - const newWidths: Record = {}; - let hasAnyWidth = false; - - visibleColumns.forEach((column) => { - // 체크박스 컬럼은 제외 (고정 48px) - if (column.columnName === "__checkbox__") return; - - // 저장된 너비가 있으면 우선 사용 - if (savedWidths[column.columnName]) { - newWidths[column.columnName] = savedWidths[column.columnName]; - hasAnyWidth = true; - } else { - // 저장된 너비가 없으면 자동 계산 - const optimalWidth = calculateOptimalColumnWidth( - column.columnName, - columnLabels[column.columnName] || column.displayName, - ); - newWidths[column.columnName] = optimalWidth; - hasAnyWidth = true; - } - }); - - if (hasAnyWidth) { - setColumnWidths(newWidths); - hasInitializedWidths.current = true; - } - }, 150); // DOM 렌더링 대기 - - return () => clearTimeout(timer); - } - }, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]); - - // ======================================== - // 페이지네이션 JSX - // ======================================== - - const paginationJSX = useMemo(() => { - if (!tableConfig.pagination?.enabled || isDesignMode) return null; - - // 페이지 크기 변경 핸들러 - const handlePageSizeChange = (newSize: number) => { - setLocalPageSize(newSize); - setCurrentPage(1); - if (pageSizeKey) { - sessionStorage.setItem(pageSizeKey, String(newSize)); - } - if (onConfigChange) { - onConfigChange({ - ...tableConfig, - pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, - }); - } - }; - - return ( -
- {/* 좌측: 페이지 크기 입력 */} -
- 표시: - { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); - handlePageSizeChange(value); - }} - onBlur={(e) => { - // 포커스 잃을 때 유효 범위로 조정 - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); - handlePageSizeChange(value); - }} - className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" - /> - -
- - {/* 중앙 페이지네이션 컨트롤 */} -
- - - -
- setPageInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - commitPageInput(); - (e.target as HTMLInputElement).blur(); - } - }} - onBlur={commitPageInput} - onFocus={(e) => e.target.select()} - disabled={loading} - className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm" - /> - / - - {totalPages || 1} - -
- - - -
- - {/* 우측 버튼 그룹 */} -
- {/* 🆕 내보내기 버튼 (Excel/PDF) */} - - - - - -
-
Excel
- - -
-
PDF/인쇄
- - -
- - - - {/* 새로고침 버튼 (하단 페이지네이션) */} - {(tableConfig.toolbar?.showPaginationRefresh ?? true) && ( - - )} -
-
- ); - }, [ - tableConfig.pagination, - tableConfig.toolbar?.showPaginationRefresh, - isDesignMode, - currentPage, - totalPages, - totalItems, - loading, - selectedRows.size, - exportToExcel, - exportToPdf, - localPageSize, - onConfigChange, - tableConfig, - pageInputValue, - ]); - - // ======================================== - // 렌더링 - // ======================================== - - const domProps = { - onClick: handleClick, - onDragStart: isDesignMode ? onDragStart : undefined, - onDragEnd: isDesignMode ? onDragEnd : undefined, - draggable: isDesignMode, - className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일 - style: componentStyle, - }; - - if (!tableConfig.selectedTable) { - return ( -
-
-
-
테이블이 연결되지 않았습니다
-
- 우측 속성의 데이터 소스에서 테이블을 선택하면 목록 미리보기가 표시됩니다. -
-
-
-
- ); - } - - // 카드 모드 - if (tableConfig.displayMode === "card" && !isDesignMode) { - return ( -
- {loading ? ( -
- 로딩 중... -
- ) : error ? ( -
- {error} -
- ) : ( - - )} - {paginationJSX} -
- ); - } - - // SingleTableWithSticky 모드 - if (tableConfig.stickyHeader && !isDesignMode) { - return ( -
- {/* 필터 헤더는 TableSearchWidget으로 이동 */} - - {/* 그룹 표시 배지 */} - {groupByColumns.length > 0 && ( -
-
- 그룹: -
- {groupByColumns.map((col, idx) => ( - - {idx > 0 && } - - {columnLabels[col] || col} - - - ))} -
- -
-
- )} - -
- ) => { - const column = visibleColumns.find((c) => c.columnName === columnName); - return column ? formatCellValue(value, column, rowData) : String(value); - }} - getColumnWidth={getColumnWidth} - containerWidth={calculatedWidth} - onCellDoubleClick={handleCellDoubleClick} - editingCell={editingCell} - editingValue={editingValue} - onEditingValueChange={setEditingValue} - onEditKeyDown={handleEditKeyDown} - onEditSave={saveEditing} - editInputRef={editInputRef} - columnMeta={columnMeta} - categoryMappings={categoryMappings} - /> -
- - {paginationJSX} -
- ); - } - - // 일반 테이블 모드 (네이티브 HTML 테이블) - return ( - <> -
- {isDesignMode && ( -
- 디자인 미리보기 - {tableConfig.selectedTable} · 최대 {DESIGN_PREVIEW_PAGE_SIZE}건 -
- )} - - {/* 필터 헤더는 TableSearchWidget으로 이동 */} - - {/* 🆕 DevExpress 스타일 기능 툴바 */} -
- {/* 편집 모드 토글 */} - {(tableConfig.toolbar?.showEditMode ?? false) && ( -
- -
- )} - - {/* 내보내기 버튼들 */} - {((tableConfig.toolbar?.showExcel ?? false) || (tableConfig.toolbar?.showPdf ?? false)) && ( -
- {(tableConfig.toolbar?.showExcel ?? false) && ( - - )} - {(tableConfig.toolbar?.showPdf ?? false) && ( - - )} -
- )} - - {/* 복사 버튼 */} - {(tableConfig.toolbar?.showCopy ?? false) && ( -
- -
- )} - - {/* 선택 정보 - 숨김 처리 */} - - {/* 🆕 통합 검색 패널 */} - {(tableConfig.toolbar?.showSearch ?? false) && ( -
- {isSearchPanelOpen ? ( -
- setGlobalSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - executeGlobalSearch(globalSearchTerm); - } else if (e.key === "Escape") { - clearGlobalSearch(); - } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - } - }} - placeholder="검색어 입력... (Enter)" - className="border-input bg-background focus:ring-primary h-7 w-32 rounded border px-2 text-xs focus:ring-1 focus:outline-none sm:w-48" - autoFocus - /> - {searchHighlights.size > 0 && ( - {searchHighlights.size}개 - )} - - - -
- ) : ( - - )} -
- )} - - {/* 🆕 Filter Builder (고급 필터) 버튼 */} - {(tableConfig.toolbar?.showFilter ?? false) && ( -
- - {activeFilterCount > 0 && ( - - )} -
- )} - - {/* 새로고침 (상단) */} - {(tableConfig.toolbar?.showRefresh ?? false) && ( -
- -
- )} -
- - {/* 필터 칩 바 */} - {filterGroups.length > 0 && filterGroups.some(g => g.conditions.some(c => c.column && c.value)) && ( -
- {filterGroups.flatMap(group => - group.conditions - .filter(c => c.column && c.value) - .map(condition => { - const label = columnLabels[condition.column] || condition.column; - const opLabel = condition.operator === "equals" ? "=" : condition.operator === "contains" ? "⊃" : condition.operator === "notEquals" ? "≠" : condition.operator === "startsWith" ? "^" : condition.operator === "endsWith" ? "$" : condition.operator === "greaterThan" ? ">" : condition.operator === "lessThan" ? "<" : condition.operator; - return ( - - {label} {opLabel} {condition.value} - - - ); - }) - )} - -
- )} - - {/* 배치 편집 툴바 */} - {(editMode === "batch" || pendingChanges.size > 0) && ( -
-
- - 배치 편집 모드 - - {pendingChanges.size > 0 && ( - {pendingChanges.size}개 변경사항 - )} -
-
- - -
-
- )} - - {/* 그룹 표시 배지 */} - {groupByColumns.length > 0 && ( -
-
- 그룹: -
- {groupByColumns.map((col, idx) => ( - - {idx > 0 && } - - {columnLabels[col] || col} - - - ))} -
- -
-
- )} - - {/* 테이블 컨테이너 - 키보드 네비게이션 지원 */} -
- {/* 스크롤 영역 */} -
- {/* 테이블 */} - - {/* 헤더 (sticky) */} - - {/* 🆕 Multi-Level Headers (Column Bands) */} - {columnBandsInfo?.hasBands && ( - - {visibleColumns.map((column, colIdx) => { - // 이 컬럼이 속한 band 찾기 - const band = columnBandsInfo.bands.find( - (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx, - ); - - // band의 첫 번째 컬럼인 경우에만 렌더링 - if (band) { - return ( - - ); - } - - // band에 속하지 않은 컬럼 (개별 표시) - const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName)); - if (!isInAnyBand) { - return ( - - ); - } - - // band의 중간 컬럼은 렌더링하지 않음 - return null; - })} - - )} - - {visibleColumns.map((column, columnIndex) => { - const columnWidth = columnWidths[column.columnName]; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - // 🆕 Column Reordering 상태 - const isColumnDragging = draggedColumnIndex === columnIndex; - const isColumnDropTarget = dropTargetColumnIndex === columnIndex; - - return ( - - ); - })} - - - - {/* 바디 (스크롤) */} - - {loading ? ( - - - - ) : error ? ( - - - - ) : data.length === 0 ? ( - - - - ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( - // 그룹화된 렌더링 - groupedData.map((group) => { - const isCollapsed = collapsedGroups.has(group.groupKey); - return ( - - {/* 그룹 헤더 */} - - - - {/* 그룹 데이터 */} - {!isCollapsed && - group.items.map((row, index) => ( - handleRowClick(row, index, e)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const cellValue = row[mappedColumnName]; - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = - frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - return ( - - ); - })} - - ))} - {/* 🆕 그룹별 소계 행 */} - {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( - - {visibleColumns.map((column, colIndex) => { - const summary = group.summary?.[column.columnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - if (colIndex === 0 && column.columnName === "__checkbox__") { - return ( - - ); - } - - if (colIndex === 0 && column.columnName !== "__checkbox__") { - return ( - - ); - } - - if (summary) { - return ( - - ); - } - - return - )} - - ); - }) - ) : ( - // 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원 - <> - {/* 🆕 Virtual Scrolling: Top Spacer */} - {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( - - - )} - {/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */} - {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => { - // Virtual Scrolling에서는 실제 인덱스 계산 - const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; - const rowKey = getRowKey(row, index); - const isRowSelected = selectedRows.has(rowKey); - const isRowFocused = focusedCell?.rowIndex === index; - - // 🆕 Drag & Drop 상태 - const isDragging = draggedRowIndex === index; - const isDropTarget = dropTargetIndex === index; - - return ( - handleRowClick(row, index, e)} - role="row" - aria-selected={isRowSelected} - // 🆕 Drag & Drop 이벤트 - draggable={isDragEnabled} - onDragStart={(e) => handleRowDragStart(e, index)} - onDragOver={(e) => handleRowDragOver(e, index)} - onDragEnd={handleRowDragEnd} - onDrop={(e) => handleRowDrop(e, index)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 - const cellValue = - editMode === "batch" - ? getDisplayValue(row, index, mappedColumnName) - : row[mappedColumnName]; - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 셀 포커스 상태 - const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; - - // 🆕 배치 편집: 수정된 셀 여부 - const isModified = isCellModified(index, mappedColumnName); - - // 🆕 유효성 검사 에러 - const cellValidationError = getCellValidationError(index, mappedColumnName); - - // 🆕 검색 하이라이트 여부 - const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = - frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - return ( - - ); - })} - - ); - })} - {/* 🆕 Virtual Scrolling: Bottom Spacer */} - {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( - - - )} - - )} - - - {/* 🆕 데이터 요약 (Total Summaries) */} - {summaryData && Object.keys(summaryData).length > 0 && ( - - - {visibleColumns.map((column, colIndex) => { - const summary = summaryData[column.columnName]; - const columnWidth = columnWidths[column.columnName]; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; - } - } - - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || (column as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return ( - - ); - })} - - - )} -
- {band.caption} - - {columnLabels[column.columnName] || column.columnName} -
{ columnRefs.current[column.columnName] = el; }} - className={cn( - "group text-muted-foreground relative h-8 overflow-hidden text-[10px] font-bold uppercase tracking-[0.04em] text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-xs", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2", - column.sortable !== false && - column.columnName !== "__checkbox__" && - "hover:text-foreground hover:bg-muted/50 cursor-pointer transition-colors", - sortColumn === column.columnName && "!text-primary", - isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", - // 🆕 Column Reordering 스타일 - isColumnDragEnabled && - column.columnName !== "__checkbox__" && - "cursor-grab active:cursor-grabbing", - isColumnDragging && "bg-primary/20 opacity-50", - isColumnDropTarget && "border-l-primary border-l-4", - )} - style={{ - textAlign: column.columnName === "__checkbox__" ? "center" : "center", - width: - column.columnName === "__checkbox__" - ? "48px" - : columnWidth - ? `${columnWidth}px` - : undefined, - minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - userSelect: "none", - backgroundColor: "hsl(var(--muted) / 0.4)", - ...(isFrozen && { left: `${leftPosition}px` }), - }} - // 🆕 Column Reordering 이벤트 - draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} - onDragStart={(e) => handleColumnDragStart(e, columnIndex)} - onDragOver={(e) => handleColumnDragOver(e, columnIndex)} - onDragEnd={handleColumnDragEnd} - onDrop={(e) => handleColumnDrop(e, columnIndex)} - onClick={() => { - if (isResizing.current) return; - if (column.sortable !== false && column.columnName !== "__checkbox__") { - handleSort(column.columnName); - } - }} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxHeader() - ) : ( -
- {isColumnDragEnabled && ( - - )} - {columnLabels[column.columnName] || column.displayName} - {column.sortable !== false && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} - )} - {/* 🆕 헤더 필터 버튼 */} - {tableConfig.headerFilter !== false && - columnUniqueValues[column.columnName]?.length > 0 && ( - setOpenFilterColumn(open ? column.columnName : null)} - > - - - - e.stopPropagation()} - > -
-
- - 필터: {columnLabels[column.columnName] || column.displayName} - - {headerFilters[column.columnName]?.size > 0 && ( - - )} -
-
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { - const isSelected = headerFilters[column.columnName]?.has(val); - return ( -
toggleHeaderFilter(column.columnName, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
- ); - })} - {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( -
- ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 -
- )} -
-
-
-
- )} -
- )} - {/* 리사이즈 핸들 (체크박스 제외) */} - {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && ( -
e.stopPropagation()} // 정렬 클릭 방지 - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - - const thElement = columnRefs.current[column.columnName]; - if (!thElement) return; - - isResizing.current = true; - - const startX = e.clientX; - const startWidth = columnWidth || thElement.offsetWidth; - - // 드래그 중 텍스트 선택 방지 - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - - const handleMouseMove = (moveEvent: MouseEvent) => { - moveEvent.preventDefault(); - - const diff = moveEvent.clientX - startX; - const newWidth = Math.max(80, startWidth + diff); - - // 직접 DOM 스타일 변경 (리렌더링 없음) - if (thElement) { - thElement.style.width = `${newWidth}px`; - } - }; - - const handleMouseUp = () => { - // 최종 너비를 state에 저장 - if (thElement) { - const finalWidth = Math.max(80, thElement.offsetWidth); - setColumnWidths((prev) => { - const newWidths = { ...prev, [column.columnName]: finalWidth }; - - // 🎯 localStorage에 컬럼 너비 저장 (사용자별) - if (tableConfig.selectedTable && userId) { - const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; - try { - localStorage.setItem(storageKey, JSON.stringify(newWidths)); - } catch (error) { - console.error("컬럼 너비 저장 실패:", error); - } - } - - return newWidths; - }); - } - - // 텍스트 선택 복원 - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - - // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) - setTimeout(() => { - isResizing.current = false; - }, 100); - - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - /> - )} -
-
- -
로딩 중...
-
-
-
-
오류 발생
-
{error}
-
-
-
- -
데이터가 없습니다
-
- 조건을 변경하거나 새로운 데이터를 추가해보세요 -
-
-
-
toggleGroupCollapse(group.groupKey)} - > - {isCollapsed ? ( - - ) : ( - - )} - {group.groupKey} - ({group.count}건) -
-
- {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
- 소계 - - - 소계 ({group.count}건) - - - {summary.sum.toLocaleString()} - ; - })} -
-
handleCellClick(index, colIndex, e)} - onDoubleClick={() => - handleCellDoubleClick(index, colIndex, column.columnName, cellValue) - } - onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} - role="gridcell" - tabIndex={isCellFocused ? 0 : -1} - > - {/* 🆕 인라인 편집 모드 */} - {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex - ? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 - (() => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[ - column.columnName - ]; - const options = cascadingConfig - ? getCascadingOptions(column.columnName, row) - : []; - - // 부모 값이 변경되면 옵션 로딩 - if (cascadingConfig && options.length === 0) { - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue !== undefined && parentValue !== null) { - loadCascadingOptions( - column.columnName, - cascadingConfig.parentColumn, - parentValue, - ); - } - } - - // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 - const colMeta = columnMeta[column.columnName]; - const isCategoryType = - colMeta?.inputType === "category" || colMeta?.inputType === "code"; - const hasCategoryOptions = - categoryMappings[column.columnName] && - Object.keys(categoryMappings[column.columnName]).length > 0; - - if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { - const selectOptions = cascadingConfig - ? options - : Object.entries(categoryMappings[column.columnName] || {}).map( - ([value, info]) => ({ - value, - label: info.label, - }), - ); - - return ( - - ); - } - - // 날짜 타입: 캘린더 피커 - const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime"; - if (isDateType) { - const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); - return ( - - ); - } - - // 일반 입력 필드 (행 높이 유지: h-8 고정) - return ( - setEditingValue(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={saveEditing} - className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" - style={{ - textAlign: isNumeric ? "right" : column.align || "left", - }} - /> - ); - })() - : column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : (cellValue === null || cellValue === undefined || cellValue === "") - ? - - : formatCellValue(cellValue, column, row)} -
-
- {summary ? ( -
- {summary.label} - - {typeof summary.value === "number" - ? summary.value.toLocaleString("ko-KR", { - maximumFractionDigits: 2, - }) - : summary.value} - -
- ) : colIndex === 0 ? ( - 요약 - ) : null} -
-
-
- - {/* 페이지네이션 */} - {paginationJSX} -
- - {/* 필터 설정 다이얼로그 */} - - - - 검색 필터 설정 - - 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. - - - -
- {/* 전체 선택/해제 */} -
- col.columnName !== "__checkbox__").length && - visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 - } - onCheckedChange={toggleAllFilters} - /> - - - {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} - 개 - -
- - {/* 컬럼 목록 */} -
- {visibleColumns - .filter((col) => col.columnName !== "__checkbox__") - .map((col) => ( -
- toggleFilterVisibility(col.columnName)} - /> - -
- ))} -
- - {/* 선택된 컬럼 개수 안내 */} -
- {visibleFilterColumns.size === 0 ? ( - 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 - ) : ( - - 총 {visibleFilterColumns.size}개의 검색 필터가 - 표시됩니다 - - )} -
-
- - - - - -
-
- - {/* 🆕 Context Menu (우클릭 메뉴) */} - {contextMenu && ( -
e.stopPropagation()} - > -
- {/* 셀 복사 */} - - - {/* 행 복사 */} - - -
- - {/* 셀 편집 */} - {(() => { - const col = visibleColumns[contextMenu.colIndex]; - const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; - return ( - - ); - })()} - - {/* 행 선택/해제 */} - - -
- - {/* 행 삭제 */} - -
-
- )} - - {/* 🆕 Filter Builder 모달 */} - - - - 고급 필터 - - 여러 조건을 조합하여 데이터를 필터링합니다. - - - -
- {filterGroups.length === 0 ? ( -
- 필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요. -
- ) : ( - filterGroups.map((group, groupIndex) => ( -
-
-
- 조건 그룹 {groupIndex + 1} - -
- -
- -
- {group.conditions.map((condition) => ( -
- {/* 컬럼 선택 */} - - - {/* 연산자 선택 */} - - - {/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */} - {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( - updateFilterCondition(group.id, condition.id, "value", e.target.value)} - placeholder="값 입력" - className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" - /> - )} - - {/* 조건 삭제 */} - -
- ))} -
- - {/* 조건 추가 버튼 */} - -
- )) - )} - - {/* 그룹 추가 버튼 */} - -
- - - - - - -
-
- - {/* 테이블 옵션 모달 */} - setIsTableOptionsOpen(false)} - columns={visibleColumns.map((col) => ({ - columnName: col.columnName, - label: columnLabels[col.columnName] || col.displayName || col.columnName, - visible: col.visible !== false, - width: columnWidths[col.columnName], - frozen: frozenColumns.includes(col.columnName), - }))} - onSave={handleTableOptionsSave} - tableName={tableConfig.selectedTable || "table"} - userId={userId} - /> - - ); -}; - -export const TableListWrapper: React.FC = (props) => { - return ( - - - - ); -}; diff --git a/frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx b/frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx deleted file mode 100644 index 395b19d8..00000000 --- a/frontend/lib/registry/components/table/_shared/V2TableListContainerWrapper.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { TableListWrapper } from "./V2TableListComponent"; -import { fieldsToColumns } from "@/lib/fieldConfig/adapters"; -import type { FieldConfig } from "@/types/invyone-component"; - -/** - * v2-table-list 반응형 래퍼 (2026-04-10, Phase 1 Step 6) - * - * 카드 컨테이너 너비를 ResizeObserver 로 감지해 `displayMode` 를 자동 전환한다. - * 내부 TableListComponent 의 렌더링 로직은 일절 건드리지 않고, props.config 에 - * displayMode 만 덮어쓴다. CardModeRenderer 는 기존 분기를 재사용. - * - * - width >= NARROW_BREAKPOINT → wide (기본 테이블 렌더링) - * - width < NARROW_BREAKPOINT → narrow (기존 CardModeRenderer) - * - * container-type: inline-size 는 향후 다른 @container 쿼리 조합에도 쓰도록 부착. - * - * ─── INVYONE FieldConfig 경로 (Phase 1+) ──────────────────────────────── - * props.fields: FieldConfig[] 이 있으면 화면 수준에서 정의된 단일 필드 규격을 - * 컬럼 설정으로 자동 변환해 기존 config.columns 를 덮어쓴다. 없으면 기존 경로 - * (config.columns) 그대로. 두 경로가 공존하므로 기존 화면은 수정 없이 작동. - */ -const NARROW_BREAKPOINT = 600; - -type AnyProps = Record & { fields?: FieldConfig[] }; - -export const TableListContainerWrapper: React.FC = (props) => { - const rootRef = useRef(null); - const [mode, setMode] = useState<"wide" | "narrow">("wide"); - - useEffect(() => { - const el = rootRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - - const apply = (width: number) => { - setMode((prev) => { - const next = width < NARROW_BREAKPOINT ? "narrow" : "wide"; - return prev === next ? prev : next; - }); - }; - - apply(el.getBoundingClientRect().width); - - const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - apply(entry.contentRect.width); - } - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // INVYONE FieldConfig 를 기존 columns 포맷으로 메모이즈 변환. - // fields 가 없거나 빈 배열이면 null → 기존 config.columns 경로 유지. - const derivedColumns = useMemo(() => { - if (!Array.isArray(props.fields) || props.fields.length === 0) return null; - return fieldsToColumns(props.fields); - }, [props.fields]); - - const { fields: _fields, ...restProps } = props ?? ({} as AnyProps); - const originalConfig = (restProps?.config ?? {}) as Record; - - const effectiveConfig: Record = (() => { - const base = - mode === "narrow" - ? { ...originalConfig, displayMode: "card" } - : originalConfig; - return derivedColumns ? { ...base, columns: derivedColumns } : base; - })(); - - return ( -
- -
- ); -}; - -TableListContainerWrapper.displayName = "TableListContainerWrapper"; diff --git a/frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts b/frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts deleted file mode 100644 index 50cf5ea9..00000000 --- a/frontend/lib/registry/components/table/_shared/tableListConfigTypes.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { ComponentConfig } from "@/types/component"; - -/** - * Entity 조인 정보 - */ -export interface EntityJoinInfo { - sourceTable: string; - sourceColumn: string; - joinAlias: string; -} - -/** - * 자동생성 타입 정의 - */ -export type AutoGenerationType = - | "uuid" // UUID 생성 - | "current_user" // 현재 사용자 ID - | "current_time" // 현재 시간 - | "sequence" // 시퀀스 번호 - | "numbering_rule" // 채번 규칙 - | "random_string" // 랜덤 문자열 - | "random_number" // 랜덤 숫자 - | "company_code" // 회사 코드 - | "department" // 부서 코드 - | "none"; // 자동생성 없음 - -/** - * 자동생성 설정 - */ -export interface AutoGenerationConfig { - type: AutoGenerationType; - enabled: boolean; - options?: { - length?: number; // 랜덤 문자열/숫자 길이 - prefix?: string; // 접두사 - suffix?: string; // 접미사 - format?: string; // 시간 형식 (current_time용) - startValue?: number; // 시퀀스 시작값 - numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용) - }; -} - -/** - * 테이블 컬럼 설정 - */ -export interface ColumnConfig { - columnName: string; - displayName: string; - visible: boolean; - sortable: boolean; - searchable: boolean; - width?: number; - align: "left" | "center" | "right"; - format?: "text" | "number" | "date" | "currency" | "boolean"; - order: number; - dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용) - isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부 - entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보 - - // 숫자 포맷팅 설정 - thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true) - - // 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정) - entityDisplayConfig?: { - displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블) - separator?: string; // 구분자 (기본: " - ") - sourceTable?: string; // 기본 테이블명 - joinTable?: string; // 조인 테이블명 - }; - - // 컬럼 고정 관련 속성 - fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함) - fixedOrder?: number; // 고정된 컬럼들 내에서의 순서 - - // 새로운 기능들 - hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김) - autoGeneration?: AutoGenerationConfig; // 자동생성 설정 - editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가) - - // 🆕 inputType — 컬럼 메타로부터 추론된 webType 캐시 (image / file / date 등) - // 런타임에서 columnMeta[col].inputType 또는 column.inputType 으로 읽혀서 cell rendering 분기에 쓰임. - // table shared 이동 시 노출된 타입 누락 보강 (2026-05-20 canonical cleanup). - inputType?: string; - - // 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들) - additionalJoinInfo?: { - sourceTable: string; // 원본 테이블 - sourceColumn: string; // 원본 컬럼 (예: dept_code) - referenceTable?: string; // 참조 테이블 (예: dept_info) - joinAlias: string; // 조인 별칭 (예: dept_code_company_name) - }; -} - -/** - * 카드 디스플레이 설정 - */ -export interface CardDisplayConfig { - idColumn: string; // ID 컬럼 (사번 등) - titleColumn: string; // 제목 컬럼 (이름 등) - subtitleColumn?: string; // 부제목 컬럼 (부서 등) - descriptionColumn?: string; // 설명 컬럼 - imageColumn?: string; // 이미지 컬럼 - cardsPerRow: number; // 한 행당 카드 수 (기본: 3) - cardSpacing: number; // 카드 간격 (기본: 16px) - showActions: boolean; // 액션 버튼 표시 여부 - cardHeight?: number; // 카드 높이 (기본: auto) -} - -/** - * 필터 설정 - */ -export interface FilterConfig { - enabled: boolean; - // 사용할 필터 목록 (DataTableFilter 타입 사용) - filters: Array<{ - columnName: string; - widgetType: string; - label: string; - gridColumns: number; - numberFilterMode?: "exact" | "range"; // 숫자 필터 모드 - codeInfo?: string; - referenceTable?: string; - referenceColumn?: string; - displayColumn?: string; - }>; - // 필터와 리스트 사이 간격 (px 단위, 기본: 40) - bottomSpacing?: number; -} - -/** - * 액션 설정 - */ -export interface ActionConfig { - showActions: boolean; - actions: Array<{ - type: "view" | "edit" | "delete" | "custom"; - label: string; - icon?: string; - color?: string; - confirmMessage?: string; - targetScreen?: string; - }>; - bulkActions: boolean; - bulkActionList: string[]; -} - -/** - * 스타일 설정 - */ -export interface TableStyleConfig { - theme: "default" | "striped" | "bordered" | "minimal"; - headerStyle: "default" | "dark" | "light"; - rowHeight: "compact" | "normal" | "comfortable"; - alternateRows: boolean; - hoverEffect: boolean; - borderStyle: "none" | "light" | "heavy"; -} - -/** - * 페이지네이션 설정 - */ -export interface PaginationConfig { - enabled: boolean; - pageSize: number; - showSizeSelector: boolean; - showPageInfo: boolean; - pageSizeOptions: number[]; -} - -/** - * 툴바 버튼 표시 설정 - */ -export interface ToolbarConfig { - showEditMode?: boolean; // 즉시 저장/배치 모드 버튼 - showExcel?: boolean; // Excel 내보내기 버튼 - showPdf?: boolean; // PDF 내보내기 버튼 - showCopy?: boolean; // 복사 버튼 - showSearch?: boolean; // 검색 버튼 - showFilter?: boolean; // 필터 버튼 - showRefresh?: boolean; // 상단 툴바 새로고침 버튼 - showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼 -} - -/** - * 체크박스 설정 - */ -export interface CheckboxConfig { - enabled: boolean; // 체크박스 활성화 여부 - multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오) - position: "left" | "right"; // 체크박스 위치 - selectAll: boolean; // 전체 선택/해제 버튼 표시 여부 -} - -/** - * 연결된 필터 설정 - * 다른 컴포넌트(셀렉트박스 등)의 값으로 테이블 데이터를 필터링 - */ -export interface LinkedFilterConfig { - sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등) - sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value) - targetColumn: string; // 필터링할 테이블 컬럼명 - operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals) - enabled?: boolean; // 활성화 여부 (기본: true) -} - -/** - * 제외 필터 설정 - * 다른 테이블에 이미 존재하는 데이터를 제외하고 표시 - * 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외 - */ -export interface ExcludeFilterConfig { - enabled: boolean; // 제외 필터 활성화 여부 - referenceTable: string; // 참조 테이블 (예: customer_item_mapping) - referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id) - sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number) - filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id) - filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url) - filterValueField?: string; // 필터 값 필드명 (예: customer_code) -} - -/** - * TableList 컴포넌트 설정 타입 - */ - -import type { DataFilterConfig } from "@/types/screen-management"; - -export interface TableListConfig extends ComponentConfig { - // 표시 모드 설정 - displayMode?: "table" | "card"; // 기본: "table" - - // 카드 디스플레이 설정 (displayMode가 "card"일 때 사용) - cardConfig?: CardDisplayConfig; - - // 테이블 기본 설정 - selectedTable?: string; - tableName?: string; - title?: string; - showHeader: boolean; - showFooter: boolean; - - // 🆕 커스텀 테이블 설정 (화면 메인 테이블과 다른 테이블 사용 시) - customTableName?: string; // 컴포넌트가 사용할 커스텀 테이블명 - useCustomTable?: boolean; // true면 customTableName 사용, false면 화면 메인 테이블 사용 - isReadOnly?: boolean; // 읽기전용 여부 (조회용 테이블인 경우 true) - - // 체크박스 설정 - checkbox: CheckboxConfig; - - // 높이 설정 - height: "auto" | "fixed" | "viewport"; - fixedHeight?: number; - - // 컬럼 설정 - columns: ColumnConfig[]; - autoWidth: boolean; - stickyHeader: boolean; - - // 가로 스크롤 및 컬럼 고정 설정 - horizontalScroll: { - enabled: boolean; // 가로 스크롤 활성화 여부 - maxVisibleColumns?: number; // 스크롤 없이 표시할 최대 컬럼 수 (이 수를 넘으면 가로 스크롤) - minColumnWidth?: number; // 컬럼 최소 너비 (px) - maxColumnWidth?: number; // 컬럼 최대 너비 (px) - }; - - // 페이지네이션 - pagination: PaginationConfig & { - currentPage?: number; // 현재 페이지 (추가) - }; - - // 필터 설정 - filter: FilterConfig; - - // 액션 설정 - actions: ActionConfig; - - // 스타일 설정 - tableStyle: TableStyleConfig; - - // 데이터 로딩 - autoLoad: boolean; - refreshInterval?: number; // 초 단위 - - // 🆕 기본 정렬 설정 - defaultSort?: { - columnName: string; // 정렬할 컬럼명 - direction: "asc" | "desc"; // 정렬 방향 - }; - - // 🆕 툴바 버튼 표시 설정 - toolbar?: ToolbarConfig; - - // 🆕 컬럼 값 기반 데이터 필터링 - dataFilter?: DataFilterConfig; - - // 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링) - linkedFilters?: LinkedFilterConfig[]; - - // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) - excludeFilter?: ExcludeFilterConfig; - - // 이벤트 핸들러 - onRowClick?: (row: any) => void; - onRowDoubleClick?: (row: any) => void; - onSelectionChange?: (selectedRows: any[]) => void; - onPageChange?: (page: number, pageSize: number) => void; - onSortChange?: (column: string, direction: "asc" | "desc") => void; - onFilterChange?: (filters: any) => void; - - // 선택된 행 정보 전달 핸들러 - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; -} - -/** - * 테이블 데이터 응답 타입 - */ -export interface TableDataResponse { - data: any[]; - pagination: { - page: number; - pageSize: number; - total: number; - totalPages: number; - }; - columns?: Array<{ - name: string; - type: string; - nullable: boolean; - }>; -} - -/** - * TableList 컴포넌트 Props 타입 - */ -export interface TableListProps { - id?: string; - config?: TableListConfig; - className?: string; - style?: React.CSSProperties; - - // 데이터 관련 - data?: any[]; - loading?: boolean; - error?: string; - - // 이벤트 핸들러 - onRowClick?: (row: any) => void; - onRowDoubleClick?: (row: any) => void; - onSelectionChange?: (selectedRows: any[]) => void; - onPageChange?: (page: number, pageSize: number) => void; - onSortChange?: (column: string, direction: "asc" | "desc") => void; - onFilterChange?: (filters: any) => void; - onRefresh?: () => void; - - // 선택된 행 정보 전달 핸들러 - onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; -} diff --git a/frontend/lib/registry/components/table/cell-renderers.tsx b/frontend/lib/registry/components/table/cell-renderers.tsx new file mode 100644 index 00000000..44b62509 --- /dev/null +++ b/frontend/lib/registry/components/table/cell-renderers.tsx @@ -0,0 +1,530 @@ +"use client"; + +/** + * Phase D.5 (2026-05-20) — canonical TableComponent 의 셀 렌더링 helper. + * + * 기존 plain `String(row[col.key])` 자리에 사용. + * + * 책임: + * - image 셀 (TableCellImage) + * - file 셀 (TableCellFile) + * - entity 다중 컬럼 표시 (entityDisplayConfig) + * - number / date / boolean / currency 포맷팅 + * + * 옛 본체에서 사용하던 `TableCellImage` / `TableCellFile` / `formatCellValue` 패턴을 + * 흡수한 canonical 한 단순화 버전. + */ + +import React, { useEffect, useState } from "react"; +import { FileText } from "lucide-react"; +import { getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; +import { + formatDate as centralFormatDate, + formatNumber as centralFormatNumber, + formatCurrency as centralFormatCurrency, +} from "@/lib/formatting"; +import type { TableColumn } from "./types"; + +// ─────────────────────────────────────────────────────── +// TableCellImage — 이미지 썸네일 셀 +// ─────────────────────────────────────────────────────── + +export interface TableCellImageProps { + value: string; + isDesignMode?: boolean; +} + +export const TableCellImage: React.FC = React.memo( + ({ value, isDesignMode }) => { + const [imgSrc, setImgSrc] = useState(null); + const [displayObjid, setDisplayObjid] = useState(""); + const [error, setError] = useState(false); + + useEffect(() => { + let mounted = true; + setError(false); + const rawValue = String(value || "").trim(); + if (!rawValue) { + setImgSrc(null); + setError(true); + return () => { + mounted = false; + }; + } + + const parts = rawValue.split(",").map((s) => s.trim()).filter(Boolean); + const first = parts[0] || rawValue; + setDisplayObjid(first); + const isObjid = /^\d+$/.test(first); + + if (isDesignMode) { + // 디자인 모드: remote lookup 안 함. path 만 직접 src 로. + if (!isObjid) { + setImgSrc(getFullImageUrl(first)); + } else { + setImgSrc(null); + } + return () => { + mounted = false; + }; + } + + if (isObjid) { + setImgSrc(getFilePreviewUrl(first)); + } else { + setImgSrc(getFullImageUrl(first)); + } + return () => { + mounted = false; + }; + }, [value, isDesignMode]); + + if (error || !imgSrc) { + return ( + + - + + ); + } + + return ( + { + if (isDesignMode) return; + e.stopPropagation(); + const isObjid = /^\d+$/.test(displayObjid); + const openUrl = isObjid + ? getFilePreviewUrl(displayObjid) + : getFullImageUrl(displayObjid); + if (typeof window !== "undefined") { + window.open(openUrl, "_blank"); + } + }} + onError={() => setError(true)} + /> + ); + }, +); +TableCellImage.displayName = "TableCellImage"; + +// ─────────────────────────────────────────────────────── +// TableCellFile — 파일 이름 + 다운로드 셀 +// ─────────────────────────────────────────────────────── + +interface FileInfo { + objid: string; + name: string; + ext?: string; +} + +function _parseFileValue(raw: string): FileInfo[] { + const trimmed = String(raw || "").trim(); + if (!trimmed || trimmed === "-") return []; + + // JSON 배열 시도 + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((f: any) => ({ + objid: String(f?.objid ?? f?.id ?? ""), + name: String( + f?.realFileName ?? f?.real_file_name ?? f?.name ?? "파일", + ), + ext: String(f?.fileExt ?? f?.file_ext ?? "") || undefined, + })); + } + } catch { + /* fall through */ + } + } + + // 콤마 구분 + const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean); + return parts.map((p) => ({ + objid: /^\d+$/.test(p) ? p : "", + name: /^\d+$/.test(p) ? p : p.split("/").pop() || p, + })); +} + +export interface TableCellFileProps { + value: string; + isDesignMode?: boolean; +} + +export const TableCellFile: React.FC = React.memo( + ({ value, isDesignMode }) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + setLoading(true); + const parsed = _parseFileValue(value); + if (parsed.length === 0) { + setFiles([]); + setLoading(false); + return () => { + mounted = false; + }; + } + + // JSON 으로 이미 이름이 있는 경우는 즉시 표시. + const hasNames = parsed.every((f) => f.name && f.name !== f.objid); + if (hasNames || isDesignMode) { + setFiles(parsed); + setLoading(false); + return () => { + mounted = false; + }; + } + + // 디자인 모드 X + objid 만 있음 → 비동기로 파일명 lookup (대표 1개만 — 보수적) + const objids = parsed.map((f) => f.objid).filter(Boolean); + if (objids.length === 0) { + setFiles(parsed); + setLoading(false); + return () => { + mounted = false; + }; + } + + (async () => { + try { + const { getFileInfoByObjid } = await import("@/lib/api/file"); + const lookups = await Promise.all( + objids.map(async (oid) => { + try { + const info = await getFileInfoByObjid(oid); + if (info?.success && info.data) { + // backend snake_case + 임의 camelCase fallback 흡수 + const d = info.data as any; + return { + objid: oid, + name: + d.real_file_name || + d.realFileName || + d.name || + oid, + ext: d.file_ext || d.fileExt, + }; + } + } catch { + /* noop */ + } + return { objid: oid, name: oid }; + }), + ); + if (mounted) { + setFiles(lookups); + setLoading(false); + } + } catch { + if (mounted) { + setFiles(parsed); + setLoading(false); + } + } + })(); + + return () => { + mounted = false; + }; + }, [value, isDesignMode]); + + if (loading) { + return ( + + 로딩... + + ); + } + if (files.length === 0) { + return ( + + - + + ); + } + + // 단일 파일 inline link + if (files.length === 1) { + const f = files[0]; + const url = f.objid && /^\d+$/.test(f.objid) + ? getFilePreviewUrl(f.objid) + : null; + const content = ( + + + + {f.name} + + + ); + if (url && !isDesignMode) { + return ( + e.stopPropagation()} + style={{ + color: "hsl(var(--primary))", + textDecoration: "none", + }} + > + {content} + + ); + } + return content; + } + + // 다중 — count 표시 + tooltip 으로 이름들 + const allNames = files.map((f) => f.name).join(", "); + return ( + + + {files[0].name} + + 외 {files.length - 1}개 + + + ); + }, +); +TableCellFile.displayName = "TableCellFile"; + +// ─────────────────────────────────────────────────────── +// entityDisplayConfig 적용 +// ─────────────────────────────────────────────────────── + +/** + * entity 다중 컬럼 표시 — `entityDisplayConfig.displayColumns` 의 각 컬럼을 + * row 에서 찾아 separator (`" - "` 기본) 로 join. + * + * Key fallback 순서 (각 displayColumn 마다 시도): + * 1. `${column.key}_${displayColumn}` + * 2. `${entityJoinInfo?.sourceColumn}_${displayColumn}` + * 3. `${entityDisplayConfig?.joinTable}_${displayColumn}` + * 4. `${entityJoinInfo?.joinAlias}_${displayColumn}` + * 5. direct `displayColumn` + * 6. `displayColumn` 에 `.` 있으면 마지막 segment 도 시도 + * + * 결과 join 이 빈 값이면 null 반환 (호출자가 fallback 으로 일반 포맷). + */ +function _applyEntityDisplayConfig( + column: TableColumn, + row: Record, +): string | null { + const cfg = column.entityDisplayConfig; + if (!cfg) return null; + const displayColumns: string[] = Array.isArray(cfg.displayColumns) + ? cfg.displayColumns + : Array.isArray((cfg as any).selectedColumns) + ? ((cfg as any).selectedColumns as string[]) + : []; + if (displayColumns.length === 0) return null; + + const separator = cfg.separator || " - "; + const sourceColumn = column.entityJoinInfo?.sourceColumn; + const joinAlias = column.entityJoinInfo?.joinAlias; + const joinTable = cfg.joinTable; + + const values: string[] = []; + for (const dc of displayColumns) { + const candidates: string[] = [ + `${column.key}_${dc}`, + sourceColumn ? `${sourceColumn}_${dc}` : "", + joinTable ? `${joinTable}_${dc}` : "", + joinAlias ? `${joinAlias}_${dc}` : "", + dc, + ].filter(Boolean); + if (dc.includes(".")) { + const last = dc.split(".").pop(); + if (last) candidates.push(last); + } + let found: any = undefined; + for (const k of candidates) { + const v = row[k]; + if (v !== undefined && v !== null && v !== "") { + found = v; + break; + } + } + if (found !== undefined && found !== null && found !== "") { + values.push(String(found)); + } + } + const joined = values.join(separator).trim(); + return joined.length > 0 ? joined : null; +} + +// ─────────────────────────────────────────────────────── +// renderTableCellValue — canonical 셀 디스플레이 진입점 +// ─────────────────────────────────────────────────────── + +export interface RenderTableCellArgs { + value: any; + column: TableColumn; + row: Record; + isDesignMode?: boolean; +} + +export function renderTableCellValue(args: RenderTableCellArgs): React.ReactNode { + const { value, column, row, isDesignMode } = args; + + // 1) entityDisplayConfig 우선 — value 가 비어도 다른 row 키에 표시값 있을 수 있음. + const entityDisplay = _applyEntityDisplayConfig(column, row); + if (entityDisplay) { + return entityDisplay; + } + + // 2) image 셀 + const isImage = + column.inputType === "image" || column.format === "image"; + if (isImage) { + if (value === null || value === undefined || value === "") { + return ( + + - + + ); + } + return ; + } + + // 3) file / attachment 셀 + const keyLower = (column.key || "").toLowerCase(); + const looksLikeFileKey = + keyLower.includes("attachment") || + /(^|[_-])files?($|[_-])/.test(keyLower); + const isFile = + column.inputType === "file" || + column.inputType === "attachment" || + column.format === "file" || + column.format === "attachment" || + looksLikeFileKey; + if (isFile) { + if (value === null || value === undefined || value === "") { + return ( + + - + + ); + } + return ; + } + + // 4) null/empty + if (value === null || value === undefined || value === "") { + return isDesignMode ? ( + ... + ) : ( + "" + ); + } + + // 5) boolean + const isBoolean = + column.format === "boolean" || column.inputType === "checkbox"; + if (isBoolean) { + const v = value; + const truthy = + v === true || + v === 1 || + v === "1" || + v === "Y" || + v === "y" || + v === "true" || + v === "TRUE"; + return truthy ? "예" : "아니오"; + } + + // 6) date / datetime + const isDate = + column.inputType === "date" || + column.inputType === "datetime" || + column.format === "date" || + column.format === "datetime"; + if (isDate) { + try { + const formatted = centralFormatDate(value, "display"); + if (formatted && formatted !== String(value)) return formatted; + } catch { + /* fall through to default */ + } + // fallback: ISO date 의 앞 10 자리 + const s = String(value); + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); + return s; + } + + // 7) currency + if (column.format === "currency") { + try { + return centralFormatCurrency(value); + } catch { + const n = Number(value); + if (Number.isFinite(n)) return `₩${n.toLocaleString("ko-KR")}`; + } + return String(value); + } + + // 8) number / decimal + const isNumber = + column.inputType === "number" || + column.inputType === "decimal" || + column.format === "number"; + if (isNumber) { + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + if (column.thousandSeparator === false) { + return String(n); + } + try { + return centralFormatNumber(value); + } catch { + return n.toLocaleString("ko-KR"); + } + } + + // 9) default — plain string + return String(value); +} diff --git a/frontend/lib/registry/components/table/index.ts b/frontend/lib/registry/components/table/index.ts index ca3c6bb1..5f3a236a 100644 --- a/frontend/lib/registry/components/table/index.ts +++ b/frontend/lib/registry/components/table/index.ts @@ -7,19 +7,18 @@ import { InvTableConfigPanel } from "./InvTableConfigPanel"; import type { TableConfig } from "./types"; /** - * Table — 통합 데이터 테이블 컴포넌트 (2026-04-11, Phase C-1) + * Table — canonical data table component * - * 흡수 대상 (9): - * - v2-table-list (base) - * - v2-table-grouped (displayMode='grouped') - * - v2-pivot-grid (displayMode='pivot') - * - v2-split-panel-layout (displayMode='split') - * - table-list, split-panel-layout, split-panel-layout2 (legacy) - * - modal-repeater-table, simple-repeater-table (legacy) - * - pivot-grid, tax-invoice-list (legacy) + * 통합 데이터 테이블. 5가지 displayMode 지원: + * - table (기본 그리드) + * - split (좌우 분할 — master-detail) + * - grouped (그룹화) + * - pivot (피벗 그리드) + * - card (카드 리스트, 좁은 컨테이너 자동 fallback) * * 관련 문서: * notes/gbpark/2026-04-11-component-unification-plan.md §3.1 + * notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md */ const DEFAULT_CONFIG: Partial = { diff --git a/frontend/lib/registry/components/table/types.ts b/frontend/lib/registry/components/table/types.ts index e271cffd..56b138ae 100644 --- a/frontend/lib/registry/components/table/types.ts +++ b/frontend/lib/registry/components/table/types.ts @@ -2,21 +2,17 @@ import { ComponentConfig } from "@/types/component"; import type { FieldConfig } from "@/types/invyone-component"; +import type { DataFilterConfig } from "@/types/screen-management"; +import type { AutoGenerationConfig } from "@/types/screen"; /** * Table 컴포넌트 통합 설정 타입 * - * 9개의 기존 테이블 계열 컴포넌트를 통합한 **범용 데이터 테이블**. - * displayMode 로 변형 (기본/분할/그룹/피벗). + * 기존 테이블 계열 컴포넌트들을 통합한 **범용 데이터 테이블**. + * displayMode 로 변형 (기본/분할/그룹/피벗/카드). * - * 흡수 대상 (9): - * - v2-table-list (base, 기본 테이블) - * - v2-table-grouped (그룹핑 테이블) - * - v2-pivot-grid (피벗 그리드) - * - v2-split-panel-layout (좌우 분할: 목록 | 상세) - * - table-list, split-panel-layout, split-panel-layout2 (legacy) - * - modal-repeater-table, simple-repeater-table (legacy) - * - tax-invoice-list, pivot-grid (legacy) + * 옛 분리 구현 (table-list / v2-table-list / split-panel-layout 등) 은 Phase F 단계에서 + * 본 canonical TableComponent 로 흡수 완료. 더 이상 별도 본체/스키마/엔트리가 존재하지 않음. */ export type TableDisplayMode = "table" | "split" | "grouped" | "pivot" | "card"; @@ -36,15 +32,271 @@ export interface TableColumn { sortable?: boolean; /** 포맷 */ format?: string; - /** 표시 여부 */ + /** 표시 여부 (false 면 운영/디자인 모두 안 보임) */ visible?: boolean; + + // ─── Phase C.2 (2026-05-20) — legacy ColumnConfig 풀 옵션 흡수 ─── + /** + * 좌/우 sticky 컬럼. `false` 또는 미지정 = 고정 없음. + * 실제 sticky CSS / scroll 동작은 D.1 (컬럼 시스템) 에서 wiring. C.2 는 config 보존만. + */ + fixed?: "left" | "right" | false; + /** + * 같은 fixed 그룹 내부 순서 (fixed = "left" 또는 "right" 일 때만 유의미). + * 미지정이면 columns 배열 순서 사용. D.1 에서 wiring. + */ + fixedOrder?: number; + /** + * 디자인 도구용 숨김 — 디자인 모드에서는 흐릿하게, 운영에서는 완전 숨김. + * `visible: false` 와 의미 다름 (visible 은 양쪽 모두 숨김). D.1 에서 wiring. + */ + hidden?: boolean; + /** + * 셀 렌더링 / 인라인 편집 모드를 결정하는 입력 타입. + * 예: `text` / `number` / `date` / `datetime` / `select` / `entity` / `checkbox` / + * `textarea` / `file` / `image` / `code`. + * 미지정이면 `format` 또는 backend 메타로부터 추론. D.3 (인라인 편집) / D.5 (특수 셀) 에서 wiring. + */ + inputType?: string; + /** + * 인라인 편집 허용 여부 (기본 true). false 면 더블클릭해도 편집 진입 안 함. + * D.3 에서 wiring. C.2 는 config 보존만. + */ + editable?: boolean; + /** + * 숫자 표시 시 천단위 콤마 (기본 true). `inputType === "number"` 또는 + * `format === "number" | "currency"` 일 때만 유의미. D.5 (셀 렌더링) 에서 wiring. + */ + thousandSeparator?: boolean; + /** + * 검색 위젯에 노출 / 검색 가능 여부. C.3 (필터) 에서 wiring. + */ + searchable?: boolean; + /** + * 컬럼 표시 순서 (낮은 값 먼저). 미지정이면 columns 배열의 인덱스 사용. + * D.1 에서 wiring (드래그앤드롭 재정렬 시 set). + */ + order?: number; + /** + * backend 컬럼 메타 데이터 타입 (string / number / date / boolean). + * 검색 위젯 종류 추론, 정렬 비교자 결정에 사용. C.3 / D.2 에서 wiring. + */ + dataType?: string; + + // ─── entity 조인 / 다중 표시 메타 (D.5 wiring, C.2 는 보존만) ─── + /** entity 조인 컬럼 여부 */ + isEntityJoin?: boolean; + /** entity 조인 상세 (sourceTable / sourceColumn / joinAlias) */ + entityJoinInfo?: { + sourceTable: string; + sourceColumn: string; + joinAlias: string; + }; + /** + * entity 컬럼 다중 표시 — 조인된 테이블의 여러 컬럼을 합쳐서 한 셀에 보여줌. + * D.5 에서 셀 렌더링 적용. + */ + entityDisplayConfig?: { + displayColumns: string[]; + separator?: string; + sourceTable?: string; + joinTable?: string; + }; + /** 조인 탭에서 추가한 컬럼의 원본/참조 정보 */ + additionalJoinInfo?: { + sourceTable: string; + sourceColumn: string; + referenceTable?: string; + joinAlias: string; + }; + + // ─── Phase D.5 (2026-05-20) — 다국어 라벨 ─── + /** 헤더 라벨 다국어 키 — `useScreenMultiLang().getTranslatedText(langKey, label)` 로 번역 */ + langKey?: string; + /** `lang_keys` 테이블 PK — 일부 화면 메타가 langKey 와 함께 보존 */ + langKeyId?: number; + + // ─── Phase D.10 (2026-05-20) — 자동생성 메타 (legacy ColumnConfig.autoGeneration 흡수) ─── + /** + * 컬럼별 자동생성 설정 (uuid / current_user / current_time / sequence / numbering_rule / + * random_string / random_number / company_code / department). + * + * Phase D.10 정책 — **메타 보존만**. canonical `TableComponent` 에 현재 persisted + * new-row create path 가 없어 runtime 적용은 보류. 향후 row-create 흐름이 추가되면 + * `AutoGenerationUtils.generateValue()` 를 그 경로에서 호출. `numbering_rule` 은 real + * code 할당이 일어나므로 render / fetch / receive 시 절대 호출 금지 — 별도 phase 에서. + */ + autoGeneration?: AutoGenerationConfig; } export interface TablePagination { enabled?: boolean; pageSize?: number; + /** 페이지 크기 변경 드롭다운 노출 (D.6 footer 에서 wiring) */ showSizeSelector?: boolean; + /** "1-20 / 총 100건" 식 페이지 정보 텍스트 노출 (D.6) */ + showPageInfo?: boolean; + /** 사용자가 선택할 수 있는 페이지 크기 옵션 (예: [10, 20, 50, 100]) */ pageSizeOptions?: number[]; + /** 현재 페이지 (런타임 상태 미러링용 — useTableData 가 권위) */ + currentPage?: number; + /** 페이지네이션 위치 — D.6 footer/header 분리 시 사용 */ + position?: "top" | "bottom"; +} + +/** + * Phase C.5 — 테이블 시각 스타일 옵션 (legacy `TableStyleConfig` 흡수) + * + * `alternateRows` / `hoverEffect` 는 root 의 `striped` / `hoverable` 별칭이다. + * 같은 값을 표현하지만 ConfigPanel UI 가 "스타일" CPGroup 으로 묶어 노출하기 + * 위해 둔 alias. runtime 에서 우선순위: `tableStyle.*` → root `striped/hoverable` → default. + * + * `theme` / `headerStyle` / `borderStyle` 는 시각 변형 — D.6 (본체 스타일 적용) 에서 wiring. + */ +export interface TableStyleConfig { + theme?: "default" | "striped" | "bordered" | "minimal"; + headerStyle?: "default" | "dark" | "light"; + borderStyle?: "none" | "light" | "heavy"; + /** root `striped` 별칭 (Phase C.5) — runtime 우선순위가 높음 */ + alternateRows?: boolean; + /** root `hoverable` 별칭 (Phase C.5) — runtime 우선순위가 높음 */ + hoverEffect?: boolean; +} + +/** + * Phase C.3 (2026-05-20) — 검색 필터 위젯 묶음 (legacy `FilterConfig` 흡수). + * + * 컴포넌트 상단/외부의 검색 필터 영역. 각 항목이 하나의 필터 입력 위젯이며 `widgetType` 으로 + * 입력 UI 종류 (`text` / `number` / `date` / `select` / `entity` / `code` / `checkbox`) 결정. + * + * C.3 는 config 보존 + ConfigPanel 편집 UI 까지. 실제 위젯 렌더 + 쿼리 전달은 Phase D.2. + */ +export interface TableFilterConfig { + enabled: boolean; + filters: Array<{ + columnName: string; + widgetType: string; + label: string; + gridColumns: number; + /** number 위젯일 때 단일 입력 / 범위 (min~max) 분기 */ + numberFilterMode?: "exact" | "range"; + /** code 위젯의 공통코드 그룹 키 */ + codeInfo?: string; + /** entity / select 위젯의 참조 테이블 메타 */ + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + }>; + /** 필터 영역과 리스트 사이 간격 (px) */ + bottomSpacing?: number; +} + +/** + * Phase C.3 (2026-05-20) — 연결 필터 (legacy `LinkedFilterConfig` 흡수). + * + * 다른 컴포넌트 (셀렉트박스 / 라디오 / 검색 컴포넌트) 의 현재 값을 받아서 본 테이블의 + * 특정 컬럼을 필터링한다. 예: 거래처 select → 본 테이블의 customer_code 컬럼 동등 매치. + * + * C.3 는 config 보존 + ConfigPanel 편집. 실제 source 컴포넌트 값 구독 / 쿼리 전달은 Phase D.2. + */ +export interface TableLinkedFilterConfig { + sourceComponentId: string; + sourceField?: string; + targetColumn: string; + operator?: "equals" | "contains" | "in"; + enabled?: boolean; +} + +/** + * Phase C.3 (2026-05-20) — 제외 필터 (legacy `ExcludeFilterConfig` 흡수). + * + * 다른 테이블에 이미 존재하는 row 를 본 테이블 결과에서 제외한다. 예: 거래처에 이미 + * 등록된 품목을 품목 선택 모달에서 제외. + * + * C.3 는 config 보존 + ConfigPanel 편집. 실제 SQL `NOT EXISTS` 또는 sub-query 전달은 Phase D.2. + */ +export interface TableExcludeFilterConfig { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValueSource?: "url" | "formData" | "parentData"; + filterValueField?: string; +} + +/** + * Phase C.4 (2026-05-20) — 행 액션 종류 (legacy `ActionConfig.actions[].type` 흡수). + * + * `view` / `edit` 은 보통 화면 이동 (`targetScreen`) 또는 모달 open, `delete` 는 삭제 API, + * `custom` 은 사용자 정의 핸들러. 실제 동작 wiring 은 Phase D.4. + */ +export type TableActionType = "view" | "edit" | "delete" | "custom"; + +/** + * Phase C.4 — 단일 행 액션 (legacy `ActionConfig.actions[]` row 흡수). + * + * 액션 컬럼에 표시되는 한 버튼의 메타. 실제 button 렌더 + click 핸들러 (navigation / + * modal open / delete API / custom handler) 는 Phase D.4 에서. + */ +export interface TableActionItemConfig { + type: TableActionType; + label: string; + /** lucide icon 이름 (예: "Eye" / "Pencil" / "Trash2") */ + icon?: string; + /** primary / destructive / muted 등 의미 토큰 또는 hsl 값 */ + color?: string; + /** delete 등 위험 액션의 확인 메시지 */ + confirmMessage?: string; + /** view/edit 의 화면 이동 대상 (예: "/screen/123") */ + targetScreen?: string; +} + +/** + * Phase C.4 — 액션 묶음 (legacy `ActionConfig` 흡수). + * + * **Table-level row/bulk 액션 config.** 카드 모드의 표시 힌트 + * (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` / + * `cardColumnMapping.actionColumns`) 와는 **별개 layer**. 카드 옵션은 카드 모드 셀 + * 내부 버튼 표시, 본 `TableActionConfig` 는 모든 displayMode 의 행 액션 컨테이너. + * + * C.4 는 config 보존 + ConfigPanel 편집까지. 액션 컬럼 렌더 + 버튼 click 핸들러 + bulk 선택 + * 실행 wiring 은 Phase D.4. + */ +export interface TableActionConfig { + /** 액션 컬럼 표시 여부 — D.4 에서 행 액션 영역 렌더 분기 */ + showActions: boolean; + /** 각 행에 표시될 액션 버튼 목록 — 순서대로 렌더 (D.4) */ + actions: TableActionItemConfig[]; + /** 일괄 액션 영역 표시 여부 — 선택된 행들에 대한 일괄 작업 (D.4) */ + bulkActions: boolean; + /** 일괄 액션 종류 목록 (예: ["delete", "export"]) — D.4 에서 매핑 */ + bulkActionList: string[]; +} + +/** + * Phase C.5 — 툴바 버튼 묶음 (legacy `ToolbarConfig` 흡수) + * + * `showExcel` / `showRefresh` 는 root 동명 옵션의 별칭이다 (UI 그룹화). 그 외 6개는 D.6 + * 에서 실제 버튼 렌더 + 동작 wiring 예정 — C.5 단계는 config 보존만. + */ +export interface TableToolbarConfig { + /** 즉시 저장 / 배치 모드 토글 (D.6) */ + showEditMode?: boolean; + /** Excel 내보내기 (XLSX, D.6 dynamic import). root `showExcel` 의 별칭 */ + showExcel?: boolean; + /** PDF 내보내기 (D.6) */ + showPdf?: boolean; + /** 셀 / 행 복사 (clipboard, D.6) */ + showCopy?: boolean; + /** 자체 검색바 (D.6) — 별도 Search 컴포넌트 사용 시 OFF */ + showSearch?: boolean; + /** 자체 필터 패널 (D.6) */ + showFilter?: boolean; + /** 상단 새로고침 버튼. root `showRefresh` 의 별칭 */ + showRefresh?: boolean; + /** 페이지네이션 새로고침 버튼 (D.6 footer 영역) */ + showPaginationRefresh?: boolean; } // ─── card 모드 보조 타입 ───────────────────────────────────────────── @@ -61,6 +313,8 @@ export interface TableCardStyleConfig { showViewButton?: boolean; showEditButton?: boolean; showDeleteButton?: boolean; + /** Phase D.7 — 카드 고정 높이. `"auto"` 또는 미지정이면 content 에 맞춤 */ + cardHeight?: number | "auto"; } export interface TableCardColumnMapping { @@ -72,6 +326,8 @@ export interface TableCardColumnMapping { displayColumns?: string[]; /** 액션 버튼 셀로 표시할 컬럼들 */ actionColumns?: string[]; + /** Phase D.7 — 카드 헤더 우측 작은 ID 배지에 사용 (legacy cardConfig.idColumn 흡수) */ + idColumn?: string; } // ─── pivot 모드 풍부한 필드 정의 ─────────────────────────────────── @@ -337,8 +593,33 @@ export interface PivotGridProps { } export interface TableConfig extends ComponentConfig { - /** 연결된 테이블명 (DB) */ + /** 연결된 테이블명 (DB). canonical 정규 키. */ selectedTable?: string; + /** + * 옛 layout JSON 이 `tableName` 으로 저장된 경우 흡수용 fallback. canonical 에서는 항상 + * `selectedTable` 을 우선 사용하고, `tableName` 은 fallback. 새 ConfigPanel UI 에서는 노출하지 않는다. + */ + tableName?: string; + /** + * 화면 메인 테이블 (`screenTableName`) 대신 컴포넌트 전용 테이블을 사용할지 여부. + * true 면 `customTableName` 값을 effective table 로 사용한다. false 또는 미지정이면 + * `selectedTable` (지정 시) 또는 화면 메인 테이블을 사용한다. (Phase C.1) + */ + useCustomTable?: boolean; + /** `useCustomTable === true` 일 때 effective table 로 쓰이는 테이블명. (Phase C.1) */ + customTableName?: string; + /** + * 읽기 전용 여부 (조회용 테이블). + * 인라인 편집 / 행 추가 / 삭제 UX 를 비활성화한다. 실제 readonly 적용은 + * Phase D.3 (인라인 편집) 에서. 현재는 config 필드만 보존 + UI 노출. (Phase C.1) + */ + isReadOnly?: boolean; + /** + * 마운트 시 데이터 자동 로드 여부 (기본 true). + * false 면 `useTableData` 가 enabled=false 로 가서 초기 fetch 를 보류한다. + * 외부 search / `refreshTrigger` DataPort 수동 로드는 Phase D.6 에서 wiring. (Phase C.1) + */ + autoLoad?: boolean; /** 표시 모드 (기본/분할/그룹/피벗/카드) */ displayMode?: TableDisplayMode; /** 컬럼 설정 */ @@ -405,6 +686,13 @@ export interface TableConfig extends ComponentConfig { cardStyle?: TableCardStyleConfig; /** card 모드: 데이터 컬럼 → 카드 영역 매핑 */ cardColumnMapping?: TableCardColumnMapping; + /** + * Phase D.7 — 좁은 컨테이너에서 자동으로 카드 모드 fallback 임계값 (px). + * 양수 number: 미만 시 displayMode=table → card 자동 전환 (split/grouped/pivot 영향 X) + * `false` 또는 `0`: 자동 fallback 비활성 + * 미지정: 기본 600 + */ + responsiveCardBreakpoint?: number | false; // ─── 빈 상태 / 로딩 ─── /** 빈 상태 메시지 */ @@ -413,8 +701,94 @@ export interface TableConfig extends ComponentConfig { // ─── 툴바 ─── /** 툴바 표시 */ showToolbar?: boolean; - /** 엑셀 내보내기 버튼 */ + /** 엑셀 내보내기 버튼 (legacy root key, `toolbar.showExcel` 와 alias) */ showExcel?: boolean; - /** 새로고침 버튼 */ + /** 새로고침 버튼 (legacy root key, `toolbar.showRefresh` 와 alias) */ showRefresh?: boolean; + + // ─── Phase C.5 (2026-05-20) — 스타일 / 툴바 / 데이터 동작 확장 ─── + /** + * 시각 스타일 옵션 묶음. `alternateRows` / `hoverEffect` 는 root `striped` / `hoverable` + * 별칭으로 같은 값을 ConfigPanel "스타일" CPGroup 에서 편집한다. runtime 우선: + * `tableStyle.alternateRows` → `striped` → true / `tableStyle.hoverEffect` → `hoverable` → true. + * theme / headerStyle / borderStyle 는 D.6 에서 wiring. + */ + tableStyle?: TableStyleConfig; + /** + * 8개 툴바 버튼 묶음. `showExcel` / `showRefresh` 는 root 별칭. 나머지 6개 (`showEditMode` / + * `showPdf` / `showCopy` / `showSearch` / `showFilter` / `showPaginationRefresh`) 는 + * D.6 에서 실제 버튼 렌더 + 동작 wiring. C.5 는 config 보존만. + */ + toolbar?: TableToolbarConfig; + /** + * 초기 정렬 — useTableData 의 initialSortBy / initialSortOrder 로 전달. + * 사용자가 헤더 클릭 시 즉시 덮어쓴다 (state). (Phase C.5) + */ + defaultSort?: { + columnName: string; + direction: "asc" | "desc"; + }; + /** + * 주기적 자동 새로고침 (초 단위, > 0 시 활성). useEffect 의 setInterval 로 + * `tableData.refresh()` 호출. 디자인 모드에서는 skip. (Phase C.5) + */ + refreshInterval?: number; + + // ─── Phase D.1 (2026-05-20) — 컬럼 시스템 runtime 옵션 (legacy `TableListConfig` 흡수) ─── + /** + * 컬럼 너비를 명시값 / minColumnWidth fallback 으로 강제하지 않고 브라우저 자연 너비 (content-fit) + * 를 허용한다. 기본 false. sticky offset 계산용 수치 너비는 fallback 으로 여전히 사용. (Phase D.1) + */ + autoWidth?: boolean; + /** + * 헤더 sticky 여부 (기본 true — 현재 canonical 동작 유지). false 면 헤더가 일반 흐름으로 빠진다. + * (Phase D.1) + */ + stickyHeader?: boolean; + /** + * 가로 스크롤 + 컬럼 너비 제약 옵션. `enabled` true 시 `` 의 `minWidth` 로 + * effective 너비 합 또는 `maxVisibleColumns * minColumnWidth` 중 큰 값을 강제해 가로 스크롤이 생기게 + * 한다. `maxVisibleColumns` 는 **렌더할 컬럼 수의 하드 cap 이 아니라** 가독성을 위한 레이아웃 + * 임계값 (스크롤 발생 트리거) 일 뿐. (Phase D.1) + */ + horizontalScroll?: { + enabled?: boolean; + maxVisibleColumns?: number; + minColumnWidth?: number; + maxColumnWidth?: number; + }; + + // ─── Phase C.3 (2026-05-20) — 필터 config parity (runtime 적용은 Phase D.2) ─── + /** + * 검색 필터 위젯 묶음 — 컴포넌트 상단 (또는 외부) 의 검색 입력 영역. + * C.3 는 ConfigPanel 편집까지. AdvancedSearchFilters 위젯 렌더 + `tableData.search` 전달은 D.2. + */ + filter?: TableFilterConfig; + /** + * 연결 필터 — 다른 컴포넌트 (셀렉트박스 등) 값 변화 시 본 테이블 컬럼 자동 필터. + * C.3 는 config 보존만. source 컴포넌트 값 구독 + 동적 search params 적용은 D.2. + */ + linkedFilters?: TableLinkedFilterConfig[]; + /** + * 제외 필터 — 참조 테이블의 row 를 본 테이블 결과에서 제외 (sub-query / NOT EXISTS). + * C.3 는 config 보존만. 백엔드 쿼리 빌더 / API 추가 파라미터 전달은 D.2. + */ + excludeFilter?: TableExcludeFilterConfig; + /** + * 정적 컬럼 값 필터 — `DataFilterConfig` 의 14 operator (equals / in / between / + * date_range_contains 등) 와 `match_type` (all/any) 으로 row 필터링. + * `screen-management.ts` 의 `DataFilterConfig` / `ColumnFilter` 계약 재사용 (snake_case 키 유지). + * C.3 는 config 보존 + ConfigPanel 편집. 실제 클라이언트 필터링 또는 쿼리 전달은 D.2. + */ + dataFilter?: DataFilterConfig; + + // ─── Phase C.4 (2026-05-20) — 액션 config parity (runtime 적용은 Phase D.4) ─── + /** + * Table-level row/bulk 액션 묶음 — view/edit/delete/custom row 액션 컬럼 + 일괄 액션 영역. + * 카드 모드의 표시 힌트 (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` / + * `cardColumnMapping.actionColumns`) 와는 **별개 layer** (카드 셀 내부 버튼 vs 행 액션 컨테이너). + * C.4 는 config 보존 + ConfigPanel 편집. 실제 버튼 렌더 + click 핸들러 (navigation / modal / + * delete API / custom handler) + bulk 선택 실행 wiring 은 Phase D.4. + */ + actions?: TableActionConfig; } diff --git a/frontend/lib/registry/components/table/useTableData.ts b/frontend/lib/registry/components/table/useTableData.ts index fd2dfe9b..34c46b20 100644 --- a/frontend/lib/registry/components/table/useTableData.ts +++ b/frontend/lib/registry/components/table/useTableData.ts @@ -1,14 +1,28 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import type { DataFilterConfig } from "@/types/screen-management"; /** * useTableData — 통합 table 컴포넌트 데이터 fetch 훅 * * entityJoinApi.getTableDataWithJoins() 호출. * 페이지네이션, 정렬, 검색 상태를 관리. + * + * Phase D.2 (2026-05-20) — `dataFilter` / `excludeFilter` 를 entityJoinApi 에 그대로 전달. + * 호출자는 객체 ref 가 매 렌더마다 신규 생성되지 않도록 memoize 하는 책임이 있다 — 내부적으로도 + * stable JSON string 으로 dep 추적해 ref 변동만으로 fetch 폭주가 나지 않게 한다. */ +export interface ExcludeFilterPayload { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; +} + export interface UseTableDataParams { tableName?: string; page?: number; @@ -17,6 +31,10 @@ export interface UseTableDataParams { sortOrder?: "asc" | "desc"; search?: Record; enabled?: boolean; // false면 fetch 안 함 (디자인 모드) + /** D.2 — enabled 일 때만 entityJoinApi 에 전달 */ + dataFilter?: DataFilterConfig; + /** D.2 — enabled 일 때만 entityJoinApi 에 전달 */ + excludeFilter?: ExcludeFilterPayload; } export interface UseTableDataResult { @@ -36,6 +54,11 @@ export interface UseTableDataResult { toggleSort: (col: string) => void; setSearch: (s: Record) => void; refresh: () => void; + /** + * Phase D.9 (2026-05-20) — DataReceivable.receiveData() 가 local data 를 override. + * append/replace/merge 결과를 통째 적용. fetch refresh 전까지 유지. totalOverride 미지정 시 length 사용. + */ + setLocalData: (next: Record[], totalOverride?: number) => void; } export function useTableData(params: UseTableDataParams): UseTableDataResult { @@ -47,8 +70,21 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { sortOrder: initialSortOrder = "desc", search: externalSearch, enabled = true, + dataFilter, + excludeFilter, } = params; + // D.2 — dataFilter / excludeFilter 객체 ref 가 매 렌더마다 신규여도 dep 으로 안 잡히도록 + // stable JSON string 으로 변환해 fetchData dep 으로 사용. 호출자 책임 보강. + const dataFilterJson = useMemo( + () => (dataFilter && (dataFilter as any).enabled ? JSON.stringify(dataFilter) : null), + [dataFilter], + ); + const excludeFilterJson = useMemo( + () => (excludeFilter && excludeFilter.enabled ? JSON.stringify(excludeFilter) : null), + [excludeFilter], + ); + const [data, setData] = useState[]>([]); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); @@ -59,14 +95,29 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { const [search, setSearch] = useState>(externalSearch || {}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const refreshKey = useRef(0); + const initialStateRef = useRef({ + tableName, + page: initialPage, + pageSize: initialPageSize, + sortBy: initialSortBy, + sortOrder: initialSortOrder, + }); - // 외부 검색 조건 동기화 + // 외부 검색 조건 동기화. + // D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도 + // 내부 search state 를 비워야 stale 검색 조건이 남지 않는다. useEffect(() => { - if (externalSearch) { - setSearch(externalSearch); - setPage(1); - } + const nextSearch = externalSearch || {}; + setSearch((prev) => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(nextSearch); + const changed = + prevKeys.length !== nextKeys.length || + nextKeys.some((key) => prev[key] !== nextSearch[key]); + if (!changed) return prev; + return nextSearch; + }); + setPage(1); }, [externalSearch]); // 데이터 fetch @@ -87,6 +138,11 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { sortOrder, search: Object.keys(search).length > 0 ? search : undefined, enableEntityJoin: true, + // D.2 — JSON 으로 변환된 stable string 이 dep 이지만 실제 payload 는 원본 객체 사용. + dataFilter: + dataFilter && (dataFilter as any).enabled ? dataFilter : undefined, + excludeFilter: + excludeFilter && excludeFilter.enabled ? excludeFilter : undefined, }); setData(response.data || []); @@ -101,18 +157,51 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { } finally { setLoading(false); } - }, [tableName, page, pageSize, sortBy, sortOrder, search, enabled, refreshKey.current]); + // dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + tableName, + page, + pageSize, + sortBy, + sortOrder, + search, + enabled, + dataFilterJson, + excludeFilterJson, + ]); useEffect(() => { fetchData(); }, [fetchData]); - // 테이블 변경 시 페이지 리셋 + // 테이블 / config 초기값 변경 시 런타임 상태 동기화. + // 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다. useEffect(() => { - setPage(1); - setSortBy(""); - setSearch({}); - }, [tableName]); + const prev = initialStateRef.current; + const changed = + prev.tableName !== tableName || + prev.page !== initialPage || + prev.pageSize !== initialPageSize || + prev.sortBy !== initialSortBy || + prev.sortOrder !== initialSortOrder; + + if (!changed) return; + + initialStateRef.current = { + tableName, + page: initialPage, + pageSize: initialPageSize, + sortBy: initialSortBy, + sortOrder: initialSortOrder, + }; + + setPage(initialPage); + setPageSize(initialPageSize); + setSortBy(initialSortBy); + setSortOrder(initialSortOrder); + setSearch(externalSearch || {}); + }, [tableName, initialPage, initialPageSize, initialSortBy, initialSortOrder, externalSearch]); const toggleSort = useCallback((col: string) => { setSortBy((prev) => { @@ -126,10 +215,28 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { }, []); const refresh = useCallback(() => { - refreshKey.current += 1; fetchData(); }, [fetchData]); + const setPageSizeAction = useCallback((s: number) => { + setPageSize(s); + setPage(1); + }, []); + + const setSearchAction = useCallback((s: Record) => { + setSearch(s); + setPage(1); + }, []); + + const setLocalData = useCallback((next: Record[], totalOverride?: number) => { + const arr = Array.isArray(next) ? next : []; + setData(arr); + const t = typeof totalOverride === "number" && totalOverride >= 0 ? totalOverride : arr.length; + setTotal(t); + const ps = pageSize > 0 ? pageSize : 20; + setTotalPages(Math.max(1, Math.ceil(t / ps))); + }, [pageSize]); + return { data, total, @@ -141,10 +248,12 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult { loading, error, setPage, - setPageSize: (s: number) => { setPageSize(s); setPage(1); }, + setPageSize: setPageSizeAction, setSortBy, toggleSort, - setSearch: (s: Record) => { setSearch(s); setPage(1); }, + setSearch: setSearchAction, refresh, + // Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지. + setLocalData, }; } diff --git a/frontend/lib/registry/components/table/views/CardView.tsx b/frontend/lib/registry/components/table/views/CardView.tsx index 6cfc4cb1..dd8b6c41 100644 --- a/frontend/lib/registry/components/table/views/CardView.tsx +++ b/frontend/lib/registry/components/table/views/CardView.tsx @@ -1,8 +1,15 @@ "use client"; -import React from "react"; +import React, { useMemo } from "react"; import { Eye, Pencil, Trash2 } from "lucide-react"; -import type { TableConfig, TableCardStyleConfig } from "../types"; +import type { + TableColumn, + TableConfig, + TableCardStyleConfig, +} from "../types"; +import { getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; +import { renderTableCellValue } from "../cell-renderers"; export interface CardViewProps { config: TableConfig; @@ -12,6 +19,10 @@ export interface CardViewProps { onView?: (row: any) => void; onEdit?: (row: any) => void; onDelete?: (row: any) => void; + /** Phase D.7 — canonical columns 메타. inference / D.5 cell formatting / display 라벨 위해. */ + columns?: TableColumn[]; + /** Phase D.7 — column 라벨 (langKey 번역 반영) */ + getColumnLabel?: (col: TableColumn) => string; } const DEFAULT_STYLE: Required = { @@ -26,6 +37,7 @@ const DEFAULT_STYLE: Required = { showViewButton: false, showEditButton: false, showDeleteButton: false, + cardHeight: "auto", }; const IMAGE_SIZE_PX: Record, number> = { @@ -34,11 +46,67 @@ const IMAGE_SIZE_PX: Record, numb large: 200, }; +/** + * Phase D.7 — image 컬럼 inference: key 가 image/photo/thumbnail 포함 또는 + * inputType/format 이 "image". + */ +function _isImageColumn(c: TableColumn): boolean { + const key = (c.key || "").toLowerCase(); + if (c.inputType === "image" || c.format === "image") return true; + return /image|photo|thumbnail/.test(key); +} + +/** + * Phase D.7 — file/attachment 컬럼 식별 (image 외): key 가 attachment/file 포함 또는 + * inputType/format 이 "file"/"attachment". + */ +function _isFileColumn(c: TableColumn): boolean { + const key = (c.key || "").toLowerCase(); + if ( + c.inputType === "file" || + c.inputType === "attachment" || + c.format === "file" || + c.format === "attachment" + ) + return true; + return ( + key.includes("attachment") || + /(^|[_-])files?($|[_-])/.test(key) + ); +} + +/** + * Phase D.7 — 이미지 URL 정규화. + * - http(s):// → 그대로 (getFullImageUrl 가 그대로 반환) + * - 숫자 objid → getFilePreviewUrl + * - 그 외 path → getFullImageUrl + */ +function _normalizeImageUrl(raw: any): string | null { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim(); + if (!s) return null; + // 콤마 구분 시 첫 값만 사용 + const first = s.includes(",") ? s.split(",")[0].trim() : s; + if (!first) return null; + if (/^\d+$/.test(first)) { + return getFilePreviewUrl(first); + } + return getFullImageUrl(first); +} + /** * CardView — displayMode="card" * - * 데이터 행을 카드 그리드로 렌더. config.cardColumnMapping 으로 데이터 컬럼을 - * 카드 영역 (title/subtitle/description/image) 에 매핑. + * 데이터 행을 카드 그리드로 렌더. `config.cardColumnMapping` 으로 데이터 컬럼을 + * 카드 영역 (title/subtitle/description/image/displayColumns) 에 매핑. + * + * Phase D.7 (2026-05-20) — legacy 호환: + * - `cardColumnMapping` 비었으면 columns 기반 inference (title/subtitle/description/image) + * - legacy `cardConfig.{titleColumn, idColumn, cardHeight}` 도 fallback + * - 이미지 값 URL normalize (objid → getFilePreviewUrl, path → getFullImageUrl, http(s) 그대로) + * - displayColumns 의 셀 렌더는 D.5 `renderTableCellValue` 사용 (특수 셀 / 포맷) + * - idColumn 있으면 카드 우상단에 작은 ID 배지 + * - cardHeight 가 number 면 카드 고정 높이 */ export function CardView({ config, @@ -48,6 +116,8 @@ export function CardView({ onView, onEdit, onDelete, + columns, + getColumnLabel, }: CardViewProps) { const cardsPerRow = config.cardsPerRow ?? 3; const cardSpacing = config.cardSpacing ?? 12; @@ -55,7 +125,69 @@ export function CardView({ ...DEFAULT_STYLE, ...(config.cardStyle ?? {}), }; - const mapping = config.cardColumnMapping ?? {}; + + // Phase D.7 — mapping fallback (canonical → legacy cardConfig) + const legacyCardConfig = + (config as any).cardConfig && typeof (config as any).cardConfig === "object" + ? ((config as any).cardConfig as Record) + : {}; + const explicitMapping = config.cardColumnMapping ?? {}; + + // Phase D.7 — inference (mapping 비어있을 때 columns 사용) + const inferred = useMemo(() => { + const cols = Array.isArray(columns) ? columns : []; + const nonMedia = cols.filter( + (c) => !_isImageColumn(c) && !_isFileColumn(c), + ); + const imageCol = cols.find(_isImageColumn); + return { + titleColumn: nonMedia[0]?.key, + subtitleColumn: nonMedia[1]?.key, + descriptionColumn: nonMedia[2]?.key, + imageColumn: imageCol?.key, + }; + }, [columns]); + + const mapping = useMemo( + () => ({ + titleColumn: + explicitMapping.titleColumn || + (legacyCardConfig.titleColumn as string | undefined) || + inferred.titleColumn, + subtitleColumn: + explicitMapping.subtitleColumn || + (legacyCardConfig.subtitleColumn as string | undefined) || + inferred.subtitleColumn, + descriptionColumn: + explicitMapping.descriptionColumn || + (legacyCardConfig.descriptionColumn as string | undefined) || + inferred.descriptionColumn, + imageColumn: + explicitMapping.imageColumn || + (legacyCardConfig.imageColumn as string | undefined) || + inferred.imageColumn, + idColumn: + explicitMapping.idColumn || + (legacyCardConfig.idColumn as string | undefined), + displayColumns: explicitMapping.displayColumns, + }), + [explicitMapping, legacyCardConfig, inferred], + ); + + // Phase D.7 — legacy cardHeight (cardConfig.cardHeight) fallback + const cardHeight = + style.cardHeight !== "auto" && style.cardHeight !== undefined + ? style.cardHeight + : (legacyCardConfig.cardHeight as number | undefined); + + // Phase D.7 — column 메타 lookup (displayColumns 의 cell renderer 용) + const columnByKey = useMemo(() => { + const m = new Map(); + if (Array.isArray(columns)) { + for (const c of columns) m.set(c.key, c); + } + return m; + }, [columns]); if (data.length === 0) { return
{config.emptyMessage || "데이터 없음"}
; @@ -78,6 +210,9 @@ export function CardView({ row={row} mapping={mapping} style={style} + cardHeight={typeof cardHeight === "number" ? cardHeight : undefined} + columnByKey={columnByKey} + getColumnLabel={getColumnLabel} onClick={onCardClick ? () => onCardClick(row) : undefined} onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined} onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined} @@ -91,8 +226,18 @@ export function CardView({ interface CardItemProps { row: any; - mapping: NonNullable; + mapping: { + titleColumn?: string; + subtitleColumn?: string; + descriptionColumn?: string; + imageColumn?: string; + idColumn?: string; + displayColumns?: string[]; + }; style: Required; + cardHeight?: number; + columnByKey: Map; + getColumnLabel?: (col: TableColumn) => string; isDesignMode: boolean; onClick?: () => void; onView?: () => void; @@ -100,15 +245,34 @@ interface CardItemProps { onDelete?: () => void; } -function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, onDelete }: CardItemProps) { +function CardItem({ + row, + mapping, + style, + cardHeight, + columnByKey, + getColumnLabel, + isDesignMode, + onClick, + onView, + onEdit, + onDelete, +}: CardItemProps) { + const [imgError, setImgError] = React.useState(false); + const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined; const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined; const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined; - const image = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined; + const imageRaw = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined; + const idValue = mapping.idColumn ? row?.[mapping.idColumn] : undefined; + React.useEffect(() => { + setImgError(false); + }, [mapping.imageColumn, imageRaw]); const description = typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength ? `${descriptionRaw.slice(0, style.maxDescriptionLength)}…` : descriptionRaw; + const imageUrl = _normalizeImageUrl(imageRaw); const imagePx = IMAGE_SIZE_PX[style.imageSize]; const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right"; @@ -121,45 +285,135 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, cursor: onClick ? "pointer" : "default", display: isHorizontal ? "flex" : "block", flexDirection: style.imagePosition === "right" ? "row-reverse" : "row", + ...(typeof cardHeight === "number" && cardHeight > 0 + ? { height: `${cardHeight}px` } + : {}), }; return (
- {style.showImage && image && ( + {style.showImage && imageUrl && !imgError && (
+ > + setImgError(true)} + style={{ + width: "100%", + height: "100%", + objectFit: "cover", + display: "block", + }} + /> +
)} -
+ {style.showImage && imageRaw && imgError && ( +
+ 이미지 없음 +
+ )} +
+ {(idValue !== undefined && idValue !== null && idValue !== "") && ( + // Phase D.7 — idColumn 배지 (헤더 영역 우측) +
+ + {String(idValue)} + +
+ )} {style.showTitle && title !== undefined && title !== null && title !== "" && ( -
{String(title)}
+
{String(title)}
)} {style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && ( -
+
{String(subtitle)}
)} {style.showDescription && description !== undefined && description !== null && description !== "" && ( -
- {String(description)} -
+
{String(description)}
)} {(mapping.displayColumns?.length ?? 0) > 0 && ( -
- {mapping.displayColumns!.map((col) => ( -
- {col}: - {row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"} -
- ))} +
+ {mapping.displayColumns!.map((colKey) => { + const col = columnByKey.get(colKey); + const label = + col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey; + const cellValue = row?.[colKey]; + // Phase D.7 — D.5 cell formatting 사용. col 메타 없으면 raw string. + const rendered = col + ? renderTableCellValue({ + value: cellValue, + column: col, + row, + isDesignMode, + }) + : cellValue !== undefined && cellValue !== null + ? String(cellValue) + : "-"; + return ( +
+ {label}: + + {rendered} + +
+ ); + })}
)} {style.showActions && (onView || onEdit || onDelete) && ( @@ -175,11 +429,15 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, )}
)} - {isDesignMode && Object.keys(mapping).length === 0 && ( -
- [디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다. -
- )} + {isDesignMode && + !mapping.titleColumn && + !mapping.subtitleColumn && + !mapping.descriptionColumn && + !mapping.imageColumn && ( +
+ [디자인 모드] 컬럼 매핑이 비어있어 빈 카드만 표시됩니다. +
+ )}
); diff --git a/frontend/lib/registry/components/table/views/GroupedView.tsx b/frontend/lib/registry/components/table/views/GroupedView.tsx index 05990d9b..3f200001 100644 --- a/frontend/lib/registry/components/table/views/GroupedView.tsx +++ b/frontend/lib/registry/components/table/views/GroupedView.tsx @@ -3,6 +3,8 @@ import React, { useMemo, useState } from "react"; import { ChevronRight, ChevronDown } from "lucide-react"; import type { TableConfig, TableColumn } from "../types"; +import type { GroupSumConfig } from "@/types/table-options"; +import { renderTableCellValue } from "../cell-renderers"; export interface GroupedViewProps { config: TableConfig; @@ -11,13 +13,25 @@ export interface GroupedViewProps { rowHeightPx?: string; isDesignMode?: boolean; onRowClick?: (row: any) => void; + /** Phase D.8 — TableOptions UI 가 set 한 grouping 컬럼 list. 첫 컬럼만 사용 (deep nesting 보류). */ + groupByColumns?: string[]; + /** Phase D.8 — group-sum 활성화 시 group 헤더에 sum/avg/count 표시 */ + groupSumConfig?: GroupSumConfig | null; + /** Phase D.8 — column 라벨 (langKey 번역) */ + getColumnLabel?: (col: TableColumn) => string; } /** * GroupedView — displayMode="grouped" * - * config.groupBy 컬럼 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더. + * 그룹 컬럼 기준으로 데이터를 그룹화해 펼침/접힘 단위로 렌더. * 그룹 헤더에 그룹 키 + 행 개수 표시. 클릭 시 펼침 토글. + * + * Phase D.8 (2026-05-20) — groupByColumns / groupSumConfig 추가: + * - groupByColumns 우선 (TableOptions UI 가 set), 없으면 config.groupBy + * - groupSumConfig.enabled 시 그룹 헤더에 numeric column 의 sum/avg/count 표시 + * - 전체 합계 footer row 도 추가 (groupSum enabled 시) + * - 셀 렌더는 D.5 renderTableCellValue 사용 (image/file/entity/number/date/boolean) */ export function GroupedView({ config, @@ -26,8 +40,28 @@ export function GroupedView({ rowHeightPx = "36px", isDesignMode = false, onRowClick, + groupByColumns, + groupSumConfig, + getColumnLabel, }: GroupedViewProps) { - const groupBy = config.groupBy; + // Phase D.8 — groupByColumns 첫 컬럼 우선, 없으면 config.groupBy. + const groupBy = + Array.isArray(groupByColumns) && groupByColumns.length > 0 + ? groupByColumns[0] + : config.groupBy; + + // numeric column 식별 + const numericColumnKeys = useMemo(() => { + return columns + .filter((c) => { + if (c.key === groupBy) return false; + if (c.inputType === "number" || c.inputType === "decimal") return true; + if (c.dataType === "number" || c.dataType === "decimal") return true; + if (c.format === "number" || c.format === "currency") return true; + return false; + }) + .map((c) => c.key); + }, [columns, groupBy]); const groups = useMemo>(() => { if (!groupBy) return [{ key: "(전체)", rows: data }]; @@ -42,6 +76,59 @@ export function GroupedView({ return Array.from(map.entries()).map(([key, rows]) => ({ key, rows })); }, [data, groupBy]); + // Phase D.8 — 그룹별 numeric summary (sum/avg/count) 계산. group-sum enabled 시만. + const groupSumEnabled = !!groupSumConfig?.enabled; + const summaryByGroup = useMemo(() => { + if (!groupSumEnabled || numericColumnKeys.length === 0) return new Map>(); + const out = new Map>(); + for (const { key, rows } of groups) { + const summary: Record = {}; + for (const colKey of numericColumnKeys) { + let sum = 0; + let count = 0; + for (const row of rows) { + const v = row?.[colKey]; + const n = typeof v === "number" ? v : Number(v); + if (Number.isFinite(n)) { + sum += n; + count += 1; + } + } + summary[colKey] = { + sum, + avg: count > 0 ? sum / count : 0, + count, + }; + } + out.set(key, summary); + } + return out; + }, [groups, numericColumnKeys, groupSumEnabled]); + + // 전체 합계 (grand total) + const grandTotal = useMemo(() => { + if (!groupSumEnabled || numericColumnKeys.length === 0) return null; + const summary: Record = {}; + for (const colKey of numericColumnKeys) { + let sum = 0; + let count = 0; + for (const row of data) { + const v = row?.[colKey]; + const n = typeof v === "number" ? v : Number(v); + if (Number.isFinite(n)) { + sum += n; + count += 1; + } + } + summary[colKey] = { + sum, + avg: count > 0 ? sum / count : 0, + count, + }; + } + return summary; + }, [data, numericColumnKeys, groupSumEnabled]); + const [collapsedKeys, setCollapsedKeys] = useState>(new Set()); const toggle = (key: string) => { setCollapsedKeys((prev) => { @@ -64,6 +151,25 @@ export function GroupedView({ return
{config.emptyMessage || "데이터 없음"}
; } + // 그룹 헤더의 sum hint 텍스트 — numeric 컬럼들의 sum 을 짧게. + const formatSumHint = ( + summary: Record, + ): string => { + const parts: string[] = []; + for (const colKey of numericColumnKeys) { + const s = summary[colKey]; + if (!s || s.count === 0) continue; + const col = columns.find((c) => c.key === colKey); + const label = col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey; + const display = + col?.thousandSeparator === false + ? String(s.sum) + : s.sum.toLocaleString("ko-KR"); + parts.push(`${label}: ${display}`); + } + return parts.join(" · "); + }; + return (
@@ -79,7 +185,7 @@ export function GroupedView({ textAlign: col.align || "left", }} > - {col.label || col.key} + {getColumnLabel ? getColumnLabel(col) : col.label || col.key} ))} @@ -87,6 +193,7 @@ export function GroupedView({ {groups.map(({ key, rows }) => { const collapsed = collapsedKeys.has(key); + const summary = summaryByGroup.get(key); return ( toggle(key)}> @@ -105,6 +212,19 @@ export function GroupedView({ ({rows.length}건) + {/* Phase D.8 — group summary inline */} + {summary && ( + + {formatSumHint(summary)} + + )} {!collapsed && @@ -120,32 +240,114 @@ export function GroupedView({ key={col.key} style={{ ...tdStyle, textAlign: col.align || "left" }} > - {formatCell(row?.[col.key], col.format)} + {renderTableCellValue({ + value: row?.[col.key], + column: col, + row, + isDesignMode, + })} ))} ))} + {/* Phase D.8 — group subtotal row (group-sum enabled + 펼친 상태 + numeric 컬럼 ≥1) */} + {!collapsed && summary && numericColumnKeys.length > 0 && ( + + + {columns.map((col) => { + const s = summary[col.key]; + if (!s || s.count === 0) { + return ( + + ); + })} + + )} ); })} + {/* Phase D.8 — grand total row */} + {grandTotal && numericColumnKeys.length > 0 && ( + + + {columns.map((col) => { + const s = grandTotal[col.key]; + if (!s || s.count === 0) { + if (col === columns[0]) { + return ( + + ); + } + return + ); + })} + + )}
+ ); + } + const display = + col.thousandSeparator === false + ? String(s.sum) + : s.sum.toLocaleString("ko-KR"); + return ( + + ∑ {display} +
+ 전체 합계 + ; + } + const display = + col.thousandSeparator === false + ? String(s.sum) + : s.sum.toLocaleString("ko-KR"); + return ( + + ∑ {display} +
{isDesignMode && (
[디자인 모드] {groups.length}개 그룹 + {groupSumEnabled ? " (group-sum 활성)" : ""}
)}
); } -function formatCell(value: any, _format?: string): React.ReactNode { - if (value === null || value === undefined) return "-"; - if (typeof value === "boolean") return value ? "✓" : "✗"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} - const tableStyle: React.CSSProperties = { width: "100%", borderCollapse: "collapse", @@ -158,7 +360,7 @@ const thStyle: React.CSSProperties = { fontWeight: 700, color: "hsl(var(--foreground))", textTransform: "uppercase", - letterSpacing: "0.03em", + letterSpacing: 0, borderBottom: "1px solid hsl(var(--border))", textAlign: "left", whiteSpace: "nowrap", @@ -179,6 +381,15 @@ const groupHeaderRowStyle: React.CSSProperties = { cursor: "pointer", }; +const groupSubtotalRowStyle: React.CSSProperties = { + background: "hsl(var(--primary) / 0.05)", +}; + +const grandTotalRowStyle: React.CSSProperties = { + background: "hsl(var(--muted) / 0.7)", + borderTop: "2px solid hsl(var(--border))", +}; + const emptyStyle: React.CSSProperties = { padding: 24, textAlign: "center", diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index afa50738..2bdb266a 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -107,7 +107,7 @@ export function UniversalFormModalConfigPanel({ const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; const compConfig = comp.componentConfig || {}; - // 1. Table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') + // 1. Table-like (canonical 'table' 등) // + InteractiveDataTable - 테이블 컬럼 추출 if (isTableLikeComponentType(compType) || compType === "interactive-data-table") { const tableName = compConfig.selectedTable || compConfig.tableName; diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 4c8143d6..92e20287 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -768,7 +768,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const allProviders = screenContext.getAllDataProviders(); - // table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') 우선 탐색 + // table-like (canonical 'table' 등) 우선 탐색 for (const [id, provider] of allProviders) { if (isTableLikeComponentType(provider.component_type)) { sourceProvider = provider; diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index 9c42b203..8b67ac91 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -114,7 +114,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 좌측 패널 대상: card-display만 return tableId.includes("card-display") || tableId.includes("card"); } else if (targetPanelPosition === "right") { - // 우측 패널 대상: datatable, table-list 등 (card-display 제외) + // 우측 패널 대상: datatable, canonical table 등 (card-display 제외) const isCardDisplay = tableId.includes("card-display") || tableId.includes("card"); return !isCardDisplay; } diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 0efec821..cc419d7e 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -125,7 +125,7 @@ export function getComponentUrl(componentType: string): string { // 컴포넌트 타입 추출 함수 (URL에서) // ============================================ export function getComponentTypeFromUrl(componentUrl: string): string { - // "@/lib/registry/components/v2-table-list" → "v2-table-list" + // "@/lib/registry/components/table" → "table" const parts = componentUrl.split("/"); return parts[parts.length - 1]; } @@ -223,36 +223,6 @@ export type LayoutV2 = z.infer; // V2 컴포넌트 overrides 스키마 정의 // ============================================ -// v2-table-list -const v2TableListOverridesSchema = z - .object({ - displayMode: z.enum(["table", "card"]).default("table"), - showHeader: z.boolean().default(true), - showFooter: z.boolean().default(true), - height: z.string().default("auto"), - checkbox: z - .object({ - enabled: z.boolean().default(true), - multiple: z.boolean().default(true), - position: z.string().default("left"), - selectAll: z.boolean().default(true), - }) - .default({ enabled: true, multiple: true, position: "left", selectAll: true }), - columns: z.array(z.any()).default([]), - autoWidth: z.boolean().default(true), - stickyHeader: z.boolean().default(false), - pagination: z - .object({ - enabled: z.boolean().default(true), - pageSize: z.number().default(20), - showSizeSelector: z.boolean().default(true), - showPageInfo: z.boolean().default(true), - }) - .default({ enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }), - autoLoad: z.boolean().default(true), - }) - .passthrough(); - // v2-button-primary const v2ButtonPrimaryOverridesSchema = z .object({ @@ -437,17 +407,7 @@ const v2V2RepeaterOverridesSchema = z // V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수. // override schema / default config 모두 제거. fallback schema (default any.passthrough) 사용. - -// v2-list -const v2ListOverridesSchema = z - .object({ - viewMode: z.string().default("table"), - source: z.string().default("static"), - columns: z.array(z.any()).default([]), - pagination: z.boolean().default(true), - sortable: z.boolean().default(true), - }) - .passthrough(); +// v2-list schema 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수. // v2-layout const v2LayoutOverridesSchema = z @@ -551,7 +511,7 @@ const v2RepeaterOverridesSchema = z // ============================================ const componentOverridesSchemaRegistry: Record>> = { // V2 컴포넌트 (canonical alias 라우팅된 항목은 schema 제거됨 — 2026-05-19) - "v2-table-list": v2TableListOverridesSchema, + // 옛 table-list 계열 schema 폐기 (Phase F.4) — canonical "table" 사용 "v2-button-primary": v2ButtonPrimaryOverridesSchema, "v2-text-display": v2TextDisplayOverridesSchema, "v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema, @@ -573,7 +533,7 @@ const componentOverridesSchemaRegistry: Record> = { // V2 컴포넌트 - "v2-table-list": { - displayMode: "table", - showHeader: true, - showFooter: true, - height: "auto", - checkbox: { enabled: true, multiple: true, position: "left", selectAll: true }, - columns: [], - autoWidth: true, - stickyHeader: false, - pagination: { enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }, - autoLoad: true, - }, + // 옛 table-list 계열 defaults 폐기 (Phase F.4) — canonical "table" 사용 "v2-button-primary": { text: "저장", actionType: "button", @@ -669,13 +618,7 @@ const componentDefaultsRegistry: Record> = { }, // v2-tabs-widget defaults 제거 (2026-05-19): canonical container alias (containerType=tabs) // V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2) - "v2-list": { - viewMode: "table", - source: "static", - columns: [], - pagination: true, - sortable: true, - }, + // 옛 통합 목록 defaults 폐기 (Phase F.8) — canonical table 사용. "v2-layout": { layoutType: "grid", columns: 2, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3298f553..4624195a 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3263,9 +3263,9 @@ export class ButtonActionExecutor { if (autoDetectDataSource) { dataSourceId = config.dataSourceId; - // TableList, V2List 또는 SplitPanelLayout에서 자동 감지 + // canonical table 또는 SplitPanelLayout에서 자동 감지 if (!dataSourceId && context.allComponents) { - // 1. table-like 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식) + // 1. table-like 컴포넌트 찾기 (canonical table 인식) const tableLikeComponent = context.allComponents.find( (comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp), ); @@ -3273,22 +3273,8 @@ export class ButtonActionExecutor { if (tableLikeComponent) { dataSourceId = getTableNameFromTableLikeComponent(tableLikeComponent); } else { - // 2. v2-list 컴포넌트 찾기 - const v2ListComponent = context.allComponents.find( - (comp: any) => - comp.componentType === "v2-list" && - (comp.componentConfig?.dataSource?.table || comp.componentConfig?.tableName), - ); - - if (v2ListComponent) { - dataSourceId = - v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName; - console.log("✨ V2List 자동 감지:", { - componentId: v2ListComponent.id, - tableName: dataSourceId, - }); - } else { - // 3. split-panel-layout 컴포넌트 찾기 + { + // 2. split-panel-layout 컴포넌트 찾기 const splitPanelComponent = context.allComponents.find( (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName, @@ -3461,7 +3447,7 @@ export class ButtonActionExecutor { let dataSourceId = config.dataSourceId; if (!dataSourceId && context.allComponents) { - // table-like 우선 감지 (canonical table / legacy table-list / hidden v2-table-list) + // table-like 우선 감지 (canonical table 등) const tableLikeComponent = context.allComponents.find( (comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp), ); @@ -5280,7 +5266,7 @@ export class ButtonActionExecutor { layoutData.components = JSON.parse(layoutData.components); } - // 테이블 리스트 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식) + // 테이블 컴포넌트 찾기 (canonical table 등 table-like 컴포넌트 인식) const findTableListComponent = (components: any[]): any => { if (!Array.isArray(components)) return null; diff --git a/frontend/lib/utils/componentTypeUtils.ts b/frontend/lib/utils/componentTypeUtils.ts index 117686bc..737b72bc 100644 --- a/frontend/lib/utils/componentTypeUtils.ts +++ b/frontend/lib/utils/componentTypeUtils.ts @@ -197,14 +197,11 @@ export const getComponentLabel = (component: ComponentData): string => { * ──────────────────────────────────────────────────────────────────────── * Canonical Table-like helpers * - * INVYONE canonical data-view cleanup 이후, 화면 전반에서 `table-list` 단독 - * 체크를 canonical-aware 형태로 옮긴다. + * INVYONE canonical data-view 정리(F phase) 이후, 화면 전반에서 + * 테이블 유형 인식은 canonical id 와 외부 코드가 쓰는 비표준 id 만 본다. * - * - 새 생성 경로: `table` (canonical) - * - 레거시 호환: `table-list` - * - 폐기 예정 hidden legacy: `v2-table-list` (registry/schema에 hard blocker로 - * 보존 중이므로 런타임에서 인식해야 함) - * - 일부 외부 코드가 사용: `data-table`, `datatable` + * - canonical: `table` + * - 외부 비표준(보존): `data-table`, `datatable` * * 단일 typeValue 비교용은 `isTableLikeComponentType`, 컴포넌트 객체용은 * `isTableLikeComponent`, table name 추출은 `getTableNameFromTableLikeComponent` @@ -212,13 +209,10 @@ export const getComponentLabel = (component: ComponentData): string => { * ──────────────────────────────────────────────────────────────────────── */ /** - * canonical table 및 호환 alias 전체 집합. - * v2-table-list 는 registry/schemas hard blocker 로 보존되므로 여기서도 인식한다. + * canonical table 및 외부 비표준 별칭 집합. */ const TABLE_LIKE_COMPONENT_TYPES: ReadonlySet = new Set([ "table", - "table-list", - "v2-table-list", "data-table", "datatable", ]); diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index bb0d6606..8b4801d9 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -24,7 +24,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { // ========== V2 컴포넌트 ========== // V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공. - "v2-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"), + // v2-list 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수. // v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공. "v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"), "v2-group": () => import("@/components/v2/config-panels/V2GroupConfigPanel"), @@ -65,7 +65,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"), // ========== 테이블/리스트 ========== - // ★ 2026-05-19 table-list / v2-table-list → CONFIG_PANEL_ALIAS["..."]="table" 로 라우팅 + // 옛 table-list / v2-table-list alias 는 Phase F.6 에서 제거됨 — canonical "table" 만 사용 "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"), @@ -143,7 +143,6 @@ const CONFIG_PANEL_ALIAS: Record = { "v2-table-search-widget": "search", "table-search-widget": "search", "v2-aggregation-widget": "stats", "aggregation-widget": "stats", "v2-status-count": "stats", - "v2-table-list": "table", "table-list": "table", "v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container", "v2-section-card": "container", "v2-section-paper": "container", @@ -169,7 +168,7 @@ export async function getComponentConfigPanel(componentId: string): Promise MailRecipientSelectorConfigPanel) - // 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel) + // 2차: v2- 접두사 제거 후 PascalCase (예: v2-text-display -> TextDisplayConfigPanel) // 3차: *ConfigPanel로 끝나는 첫 번째 named export // 4차: default export const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`; diff --git a/frontend/lib/utils/responsiveDefaults.ts b/frontend/lib/utils/responsiveDefaults.ts index 737e5796..03199167 100644 --- a/frontend/lib/utils/responsiveDefaults.ts +++ b/frontend/lib/utils/responsiveDefaults.ts @@ -4,6 +4,7 @@ import { ComponentData } from "@/types/screen-management"; import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive"; +import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils"; /** * 컴포넌트 크기에 따른 스마트 기본값 생성 @@ -18,13 +19,13 @@ export function generateSmartDefaults( screenWidth: number = 1920, rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수 ): ResponsiveComponentConfig["responsive"] { - // 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등) + // 특정 컴포넌트는 항상 전체 너비 (datatable, table 등). + // Phase E.3 — 옛 table ID literal 은 제거하고 table-like helper 로 호환 유지. // ★ 2026-05-18: canonical data-view 추가 (table / grouped-table / card-list). // chart 는 자연스럽게 작은 크기(예: 4컬럼)도 자주 쓰므로 제외하고 자동 추론. const fullWidthComponents = [ "datatable", "data-table", - "table-list", "repeater-field-group", // canonical (Phase G.3 ~) "table", @@ -34,7 +35,12 @@ export function generateSmartDefaults( const componentId = (component as any).componentId || (component as any).id; const componentType = (component as any).componentType || component.type; - if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) { + if ( + isTableLikeComponentType(componentId) || + isTableLikeComponentType(componentType) || + fullWidthComponents.includes(componentId) || + fullWidthComponents.includes(componentType) + ) { return { desktop: { grid_columns: 12, // 전체 너비 diff --git a/frontend/lib/utils/templateMigrate.ts b/frontend/lib/utils/templateMigrate.ts index ea558185..d70ddc83 100644 --- a/frontend/lib/utils/templateMigrate.ts +++ b/frontend/lib/utils/templateMigrate.ts @@ -41,8 +41,6 @@ const LEGACY_TO_UNIFIED: Record = { 'v2-aggregation-widget': 'stats', 'aggregation-widget': 'stats', 'v2-status-count': 'stats', - 'v2-table-list': 'table', - 'table-list': 'table', 'v2-tabs-widget': 'container', 'tabs-widget': 'container', 'tabs': 'container', diff --git a/frontend/stores/tableDisplayStore.ts b/frontend/stores/tableDisplayStore.ts index 3ef704ab..2581beb4 100644 --- a/frontend/stores/tableDisplayStore.ts +++ b/frontend/stores/tableDisplayStore.ts @@ -1,6 +1,15 @@ /** * 테이블 화면 표시 데이터 전역 저장소 * 엑셀 다운로드 등에서 현재 화면에 표시된 데이터에 접근하기 위함 + * + * Key 컨벤션 (Phase B.4 2026-05-20, F.5 docstring 갱신): + * - canonical `lib/registry/components/table/TableComponent` : `table-${component.id}` prefix + * (`setTableDataForComponent` / `getTableDataForComponent` helper 사용) + * - 외부 데이터가 옛 prefix (`table-list-${component.id}`) 로 들어오는 fallback 경로도 유지. + * (옛 본체는 Phase F.2/F.8 에서 삭제됐지만 historical key 가 store 에 들어올 수 있는 + * 경로를 위해 read 측만 fallback 으로 살려둔다.) + * - 외부 consumer (Excel/Copy/Toolbar) 가 component.id 만 알 때는 + * `getTableDataForComponent(componentId)` 가 canonical 우선 + historical fallback 으로 안전 조회. */ interface TableDisplayState { @@ -24,6 +33,16 @@ class TableDisplayStore { private state: Map = new Map(); private listeners: Set<() => void> = new Set(); + /** canonical TableComponent 가 쓰는 키 (`table-${componentId}`) */ + private static canonicalKeyForComponent(componentId: string): string { + return `table-${componentId}`; + } + + /** historical fallback 키 — 옛 본체가 직접 set 하던 prefix. 본체는 Phase F.2/F.8 에서 삭제됨. */ + private static legacyKeyForComponent(componentId: string): string { + return `table-list-${componentId}`; + } + /** * 테이블 표시 데이터 저장 * @param tableName 테이블명 @@ -85,6 +104,59 @@ class TableDisplayStore { this.notifyListeners(); } + /** + * canonical TableComponent 용 set — component.id 기반 자동 key (Phase B.4) + */ + setTableDataForComponent( + componentId: string, + data: any[], + columnOrder: string[], + sortBy: string | null, + sortOrder: "asc" | "desc", + options?: { + filter_conditions?: Record; + search_term?: string; + visible_columns?: string[]; + column_labels?: Record; + current_page?: number; + page_size?: number; + total_items?: number; + }, + ) { + this.setTableData( + TableDisplayStore.canonicalKeyForComponent(componentId), + data, + columnOrder, + sortBy, + sortOrder, + options, + ); + } + + /** + * component.id 기반 조회 — canonical 우선 + historical fallback (Phase B.4) + * 외부 consumer (Excel export, copy, toolbar) 는 prefix 를 몰라도 component.id 만으로 안전 조회. + */ + getTableDataForComponent(componentId: string): TableDisplayState | undefined { + return ( + this.state.get(TableDisplayStore.canonicalKeyForComponent(componentId)) ?? + this.state.get(TableDisplayStore.legacyKeyForComponent(componentId)) + ); + } + + /** + * component.id 기반 삭제 — canonical + historical fallback 둘 다 제거 (Phase B.4) + */ + clearTableDataForComponent(componentId: string) { + const canonical = TableDisplayStore.canonicalKeyForComponent(componentId); + const legacy = TableDisplayStore.legacyKeyForComponent(componentId); + const hadCanonical = this.state.delete(canonical); + const hadLegacy = this.state.delete(legacy); + if (hadCanonical || hadLegacy) { + this.notifyListeners(); + } + } + /** * 모든 데이터 삭제 */ diff --git a/frontend/types/component-events.ts b/frontend/types/component-events.ts index b230812e..fbdb42d0 100644 --- a/frontend/types/component-events.ts +++ b/frontend/types/component-events.ts @@ -10,8 +10,8 @@ // ============================================================ /** - * 테이블 리스트 데이터 변경 이벤트 - * 발행: v2-table-list + * 테이블 데이터 변경 이벤트 + * 발행: canonical table 등 데이터 테이블 컴포넌트 * 구독: stats, v2-repeat-container */ export interface TableListDataChangeDetail { @@ -69,7 +69,7 @@ export interface RepeaterSaveDetail { /** * 테이블 새로고침 이벤트 * 발행: v2-button-primary, buttonActions - * 구독: v2-table-list, v2-split-panel-layout + * 구독: canonical table, v2-split-panel-layout */ export interface RefreshTableDetail { table_name?: string; @@ -113,7 +113,7 @@ export interface SplitPanelDataTransferDetail { /** * 연관 데이터 버튼 선택 이벤트 * 발행: related-data-buttons - * 구독: v2-table-list + * 구독: canonical table */ export interface RelatedButtonSelectDetail { target_table: string; diff --git a/frontend/types/invyone-component.ts b/frontend/types/invyone-component.ts index f8b605fe..c126acf9 100644 --- a/frontend/types/invyone-component.ts +++ b/frontend/types/invyone-component.ts @@ -826,7 +826,7 @@ export interface TemplateComponent { /** * 컴포넌트 종류 — ComponentRegistry 의 ID 참조. * canonical 예: 'table', 'container', 'stats', 'button', 'input', 'search' - * legacy 예 (alias 라우팅으로 호환): 'v2-table-list', 'v2-button-primary', 'v2-bom-tree' + * legacy 예 (alias 라우팅으로 호환): 'v2-button-primary', 'v2-bom-tree' */ componentId: string; diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index ac373462..347ba9a3 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -220,7 +220,7 @@ export interface ComponentComponent extends BaseComponent { */ export interface TabInlineComponent { id: string; - component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — legacy v2-text-display / v2-table-list 도 alias 라우팅으로 호환) + component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — 일부 legacy id 도 alias 라우팅으로 호환) label?: string; position: Position; // 탭 내부에서의 위치 size: Size; // 컴포넌트 크기 diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index 6068ab3b..1f54cb2c 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -50,7 +50,7 @@ export interface TableColumn { * 테이블 등록 정보 */ export interface TableRegistration { - table_id: string; // 고유 ID (예: "table-list-123") + table_id: string; // 고유 ID (예: "table-123") label: string; // 사용자에게 보이는 이름 (예: "품목 관리") table_name: string; // 실제 DB 테이블명 (예: "item_info") columns: TableColumn[]; diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index 3843e058..10b866ed 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -1,10 +1,7 @@ /** - * V2 컴포넌트 타입 정의 (잔존 7종) + * V2 컴포넌트 타입 정의 (잔존) * * - V2Text - - - * - V2List * - V2Layout * - V2Group * - V2Biz @@ -13,6 +10,7 @@ * 옛 입력/선택 두 종은 Phase D.3 (2026-05-12) 에서 폐기 — canonical `input` * (InputComponent + InvFieldConfigPanel) 로 흡수됨. * V2Date 도 폐기됨 — InvField triple type=date 의 4 format(date/datetime/time/range) 으로 통일. + * V2List 는 Phase F.8 (2026-05-21) 에서 폐기 — canonical TableComponent 로 흡수. * 런타임은 InputComponent + lib/registry/components/input/pickers.tsx. */ @@ -25,7 +23,6 @@ import { Position, Size, CommonStyle, ValidationRule } from "./v2-core"; */ export type V2ComponentType = | "V2Text" - | "V2List" | "V2Layout" | "V2Group" | "V2Biz" @@ -122,59 +119,7 @@ export interface V2TextProps extends V2BaseProps { } // V2Media 타입 정의는 Phase D.5 (2026-05-12) 에서 제거됨 — canonical input 의 file 분기로 흡수. - -// ===== V2List ===== - -export type V2ListViewMode = "table" | "card" | "kanban" | "list"; - -export interface ListColumn { - field: string; - header: string; - width?: number; - sortable?: boolean; - filterable?: boolean; - editable?: boolean; - format?: string; -} - -export interface V2ListCardConfig { - title_column?: string; - subtitle_column?: string; - description_column?: string; - image_column?: string; - cards_per_row?: number; - card_spacing?: number; - show_actions?: boolean; -} - -export interface V2ListConfig { - view_mode: V2ListViewMode; - editable?: boolean; - searchable?: boolean; - pageable?: boolean; - page_size?: number; - sortable?: boolean; - pagination?: boolean; - source?: "static" | "db" | "api"; // 데이터 소스 타입 - columns?: ListColumn[]; - modal?: boolean; - card_config?: V2ListCardConfig; - // 데이터 소스 - data_source?: { - table?: string; - api?: string; - filters?: Array<{ column: string; operator: string; value: unknown }>; - }; -} - -export interface V2ListProps extends V2BaseProps { - v2Type: "V2List"; - config: V2ListConfig; - data?: Record[]; - selected_rows?: Record[]; - onRowSelect?: (rows: Record[]) => void; - onRowClick?: (row: Record) => void; -} +// V2List 타입 정의는 Phase F.8 (2026-05-21) 에서 제거됨 — canonical TableComponent + TableConfig 로 흡수. // ===== V2Layout ===== @@ -291,7 +236,6 @@ export interface V2HierarchyProps extends V2BaseProps { export type V2ComponentProps = | V2TextProps - | V2ListProps | V2LayoutProps | V2GroupProps | V2BizProps @@ -304,10 +248,7 @@ export function isV2Text(props: V2ComponentProps): props is V2TextProps { } // isV2Media 는 Phase D.5 에서 제거됨 (canonical input 의 file 분기로 흡수) - -export function isV2List(props: V2ComponentProps): props is V2ListProps { - return props.v2Type === "V2List"; -} +// isV2List 는 Phase F.8 에서 제거됨 (canonical table 로 흡수) export function isV2Layout(props: V2ComponentProps): props is V2LayoutProps { return props.v2Type === "V2Layout"; @@ -353,12 +294,8 @@ export const LEGACY_TO_V2_MAP: Record = { // Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거. - // List 계열 - // ★ 2026-05-19 table-list 는 canonical table alias 로 라우팅 — 매핑 제거 - "table-search-widget": "V2List", - "modal-repeater-table": "V2List", - "repeater-field-group": "V2List", - "card-display": "V2List", + // List 계열 — Phase F.8 (2026-05-21) 에서 매핑 제거. canonical table 사용. + // (table-search-widget / modal-repeater-table / repeater-field-group / card-display 매핑 폐기) // Layout 계열 // ★ split-panel-layout 보존 (master-detail UX 다름)