From 676ec16879817552766a918df6d66d0396cb47e3 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 18:28:43 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B6=84=ED=95=A0?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/entityJoinService.ts | 13 ++++++++++--- .../numbering-rule/NumberingRuleDesigner.tsx | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index a8f6c482..280051d0 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -403,18 +403,25 @@ export class EntityJoinService { const fromClause = `FROM ${tableName} main`; // LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN) + // 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지) const joinClauses = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) if (config.referenceTable === "table_column_category_values") { - // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + // user_info는 전역 테이블이므로 company_code 조건 없이 조인 + if (config.referenceTable === "user_info") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + } + + // 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시) + // supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블 + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`; }) .join("\n"); diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index bfdb69c2..21b9c749 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -149,11 +149,12 @@ export const NumberingRuleDesigner: React.FC = ({ const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); // 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정 + // 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링) const ruleToSave = { ...currentRule, - scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지 - tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null) - menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용) + scopeType: "menu" as const, // 메뉴 기반 채번규칙 + tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) + menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준) }; console.log("💾 채번 규칙 저장:", { From cb0bbd1ff34434a94d365ecd99da50f07f76cb0f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 18:48:23 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B2=80=EC=83=89=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../card-display/CardDisplayComponent.tsx | 214 +++++++++++++++++- .../table-search-widget/TableSearchWidget.tsx | 100 +++++++- .../TableSearchWidgetConfigPanel.tsx | 38 ++++ 3 files changed, 340 insertions(+), 12 deletions(-) diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index a84ee354..f8bf39c7 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; @@ -13,6 +13,8 @@ import { Badge } from "@/components/ui/badge"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useModalDataStore } from "@/stores/modalDataStore"; +import { useTableOptions } from "@/contexts/TableOptionsContext"; +import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options"; export interface CardDisplayComponentProps extends ComponentRendererProps { config?: CardDisplayConfig; @@ -48,11 +50,32 @@ export const CardDisplayComponent: React.FC = ({ const splitPanelContext = useSplitPanelContext(); const splitPanelPosition = screenContext?.splitPanelPosition; + // TableOptions Context (검색 필터 위젯 연동용) + let tableOptionsContext: ReturnType | null = null; + try { + tableOptionsContext = useTableOptions(); + } catch (e) { + // Context가 없으면 (디자이너 모드) 무시 + } + // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(false); + // 필터 상태 (검색 필터 위젯에서 전달받은 필터) + const [filters, setFiltersInternal] = useState([]); + + // 필터 상태 변경 래퍼 (로깅용) + const setFilters = useCallback((newFilters: TableFilter[]) => { + console.log("🎴 [CardDisplay] setFilters 호출됨:", { + componentId: component.id, + filtersCount: newFilters.length, + filters: newFilters, + }); + setFiltersInternal(newFilters); + }, [component.id]); + // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) const [columnMeta, setColumnMeta] = useState< Record @@ -380,6 +403,195 @@ export const CardDisplayComponent: React.FC = ({ } }, [screenContext, component.id, dataProvider]); + // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) + const tableId = `card-display-${component.id}`; + const tableNameToUse = tableName || component.componentConfig?.tableName || ''; + const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; + + // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) + const loadedTableDataRef = useRef(loadedTableData); + const categoryMappingsRef = useRef(categoryMappings); + + useEffect(() => { + loadedTableDataRef.current = loadedTableData; + }, [loadedTableData]); + + useEffect(() => { + categoryMappingsRef.current = categoryMappings; + }, [categoryMappings]); + + // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) + // 초기 로드 여부 추적 + const isInitialLoadRef = useRef(true); + + useEffect(() => { + if (!tableNameToUse || isDesignMode) return; + + // 초기 로드는 별도 useEffect에서 처리하므로 스킵 + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + const loadFilteredData = async () => { + try { + setLoading(true); + + // 필터 값을 검색 파라미터로 변환 + const searchParams: Record = {}; + filters.forEach(filter => { + if (filter.value !== undefined && filter.value !== null && filter.value !== '') { + searchParams[filter.columnName] = filter.value; + } + }); + + console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", { + tableName: tableNameToUse, + filtersCount: filters.length, + searchParams, + }); + + // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) + const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { + page: 1, + size: 50, + search: searchParams, + }); + + setLoadedTableData(dataResponse.data); + + // 데이터 건수 업데이트 + if (tableOptionsContext) { + tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); + } + } catch (error) { + console.error("❌ [CardDisplay] 필터 적용 실패:", error); + } finally { + setLoading(false); + } + }; + + // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) + loadFilteredData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, tableNameToUse, isDesignMode, tableId]); + + // 컬럼 고유 값 조회 함수 (select 타입 필터용) + const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { + if (!tableNameToUse) return []; + + try { + // 현재 로드된 데이터에서 고유 값 추출 + const uniqueValues = new Set(); + loadedTableDataRef.current.forEach(row => { + const value = row[columnName]; + if (value !== null && value !== undefined && value !== '') { + uniqueValues.add(String(value)); + } + }); + + // 카테고리 매핑이 있으면 라벨 적용 + const mapping = categoryMappingsRef.current[columnName]; + return Array.from(uniqueValues).map(value => ({ + value, + label: mapping?.[value]?.label || value, + })); + } catch (error) { + console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error); + return []; + } + }, [tableNameToUse]); + + // TableOptionsContext에 등록 + // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) + const registerTableRef = useRef(tableOptionsContext?.registerTable); + const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable); + + // setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용) + const setFiltersRef = useRef(setFiltersInternal); + const getColumnUniqueValuesRef = useRef(getColumnUniqueValues); + + useEffect(() => { + registerTableRef.current = tableOptionsContext?.registerTable; + unregisterTableRef.current = tableOptionsContext?.unregisterTable; + }, [tableOptionsContext]); + + useEffect(() => { + setFiltersRef.current = setFiltersInternal; + }, [setFiltersInternal]); + + useEffect(() => { + getColumnUniqueValuesRef.current = getColumnUniqueValues; + }, [getColumnUniqueValues]); + + // 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록) + const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name)); + + useEffect(() => { + if (!registerTableRef.current || !unregisterTableRef.current) return; + if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return; + + // 컬럼 정보를 TableColumn 형식으로 변환 + const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text', + visible: true, + width: 200, + sortable: true, + filterable: true, + })); + + // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 + const onFilterChangeWrapper = (newFilters: TableFilter[]) => { + console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", { + tableId, + filtersCount: newFilters.length, + }); + setFiltersRef.current(newFilters); + }; + + const getColumnUniqueValuesWrapper = async (columnName: string) => { + return getColumnUniqueValuesRef.current(columnName); + }; + + const registration = { + tableId, + label: tableLabel, + tableName: tableNameToUse, + columns, + dataCount: loadedTableData.length, + onFilterChange: onFilterChangeWrapper, + onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원 + onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원 + getColumnUniqueValues: getColumnUniqueValuesWrapper, + }; + + console.log("📋 [CardDisplay] TableOptionsContext에 등록:", { + tableId, + tableName: tableNameToUse, + columnsCount: columns.length, + dataCount: loadedTableData.length, + }); + + registerTableRef.current(registration); + + const unregister = unregisterTableRef.current; + const currentTableId = tableId; + + return () => { + console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId); + unregister(currentTableId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isDesignMode, + tableId, + tableNameToUse, + tableLabel, + columnsKey, // 컬럼 변경 시에만 재등록 + ]); + // 로딩 중인 경우 로딩 표시 if (loading) { return ( diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index 6d513976..af16bcea 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react"; @@ -41,6 +41,7 @@ interface TableSearchWidgetProps { showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부 filterMode?: "dynamic" | "preset"; // 필터 모드 presetFilters?: PresetFilter[]; // 고정 필터 목록 + targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left") }; }; screenId?: number; // 화면 ID @@ -82,19 +83,90 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table const showTableSelector = component.componentConfig?.showTableSelector ?? true; const filterMode = component.componentConfig?.filterMode ?? "dynamic"; const presetFilters = component.componentConfig?.presetFilters ?? []; + const targetPanelPosition = component.componentConfig?.targetPanelPosition ?? "left"; // 기본값: 좌측 패널 // Map을 배열로 변환 - const tableList = Array.from(registeredTables.values()); - const currentTable = selectedTableId ? getTable(selectedTableId) : undefined; - - // 첫 번째 테이블 자동 선택 - useEffect(() => { - const tables = Array.from(registeredTables.values()); - - if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) { - setSelectedTableId(tables[0].tableId); + const allTableList = Array.from(registeredTables.values()); + + // 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반) + const tableList = useMemo(() => { + // "auto"면 모든 테이블 반환 + if (targetPanelPosition === "auto") { + return allTableList; } - }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); + + // 테이블 ID 패턴으로 필터링 + // card-display-XXX: 좌측 패널 (카드 디스플레이) + // datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트) + const filteredTables = allTableList.filter(table => { + const tableId = table.tableId.toLowerCase(); + + if (targetPanelPosition === "left") { + // 좌측 패널 대상: card-display만 + return tableId.includes("card-display") || tableId.includes("card"); + } else if (targetPanelPosition === "right") { + // 우측 패널 대상: datatable, table-list 등 (card-display 제외) + const isCardDisplay = tableId.includes("card-display") || tableId.includes("card"); + return !isCardDisplay; + } + + return true; + }); + + // 필터링된 결과가 없으면 모든 테이블 반환 (폴백) + if (filteredTables.length === 0) { + console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", { + targetPanelPosition, + allTablesCount: allTableList.length, + allTableIds: allTableList.map(t => t.tableId), + }); + return allTableList; + } + + console.log("🔍 [TableSearchWidget] 테이블 필터링:", { + targetPanelPosition, + allTablesCount: allTableList.length, + filteredCount: filteredTables.length, + filteredTableIds: filteredTables.map(t => t.tableId), + }); + + return filteredTables; + }, [allTableList, targetPanelPosition]); + + // currentTable은 tableList(필터링된 목록)에서 가져와야 함 + const currentTable = useMemo(() => { + if (!selectedTableId) return undefined; + + // 먼저 tableList(필터링된 목록)에서 찾기 + const tableFromList = tableList.find(t => t.tableId === selectedTableId); + if (tableFromList) { + return tableFromList; + } + + // tableList에 없으면 전체에서 찾기 (폴백) + return getTable(selectedTableId); + }, [selectedTableId, tableList, getTable]); + + // 대상 패널의 첫 번째 테이블 자동 선택 + useEffect(() => { + if (!autoSelectFirstTable || tableList.length === 0) { + return; + } + + // 현재 선택된 테이블이 대상 패널에 있는지 확인 + const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId); + + // 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택 + if (!selectedTableId || !isCurrentTableInTarget) { + const targetTable = tableList[0]; + console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", { + targetPanelPosition, + selectedTableId: targetTable.tableId, + tableName: targetTable.tableName, + }); + setSelectedTableId(targetTable.tableId); + } + }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) useEffect(() => { @@ -302,6 +374,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return true; }); + console.log("🔍 [TableSearchWidget] 필터 적용:", { + currentTableId: currentTable?.tableId, + currentTableName: currentTable?.tableName, + filtersCount: filtersWithValues.length, + filtersWithValues, + }); currentTable?.onFilterChange(filtersWithValues); }; diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx index 3424abb9..00a9766f 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx @@ -76,12 +76,16 @@ export function TableSearchWidgetConfigPanel({ const [localPresetFilters, setLocalPresetFilters] = useState( currentConfig.presetFilters ?? [] ); + const [localTargetPanelPosition, setLocalTargetPanelPosition] = useState<"left" | "right" | "auto">( + currentConfig.targetPanelPosition ?? "left" + ); useEffect(() => { setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true); setLocalShowSelector(currentConfig.showTableSelector ?? true); setLocalFilterMode(currentConfig.filterMode ?? "dynamic"); setLocalPresetFilters(currentConfig.presetFilters ?? []); + setLocalTargetPanelPosition(currentConfig.targetPanelPosition ?? "left"); }, [currentConfig]); // 설정 업데이트 헬퍼 @@ -164,6 +168,40 @@ export function TableSearchWidgetConfigPanel({ + {/* 대상 패널 위치 (분할 패널용) */} +
+ +

+ 분할 패널이 있는 화면에서 검색 필터가 어떤 패널의 컴포넌트를 대상으로 할지 선택합니다. +

+ { + setLocalTargetPanelPosition(value); + handleUpdate("targetPanelPosition", value); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ {/* 필터 모드 선택 */}
From ca3d6bf8fb1af1a09be862c3193f6e5e77ea97a6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 15:17:43 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(split-panel-layout):=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=ED=91=9C=EC=8B=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leftPanel.columns 설정을 우선 적용하도록 로직 변경 - 조인 키(leftColumn) 대신 사용자 설정 컬럼이 표시되도록 수정 - 컬럼 라벨 변환 로직 개선 --- .../SplitPanelLayoutComponent.tsx | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 21a5bb0f..fdaddfc3 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1684,53 +1684,43 @@ export const SplitPanelLayoutComponent: React.FC const isExpanded = expandedItems.has(itemId); const level = item.level || 0; - // 조인에 사용하는 leftColumn을 필수로 표시 - const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + // 🔧 수정: "표시할 컬럼 선택"에서 설정한 컬럼을 우선 사용 + const configuredColumns = componentConfig.leftPanel?.columns || []; let displayFields: { label: string; value: any }[] = []; // 디버그 로그 if (index === 0) { console.log("🔍 좌측 패널 표시 로직:"); - console.log(" - leftColumn (조인 키):", leftColumn); + console.log(" - 설정된 표시 컬럼:", configuredColumns); console.log(" - item keys:", Object.keys(item)); } - if (leftColumn) { - // 조인 모드: leftColumn 값을 첫 번째로 표시 (필수) - displayFields.push({ - label: leftColumn, - value: item[leftColumn], + if (configuredColumns.length > 0) { + // 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용 + displayFields = configuredColumns.slice(0, 2).map((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName; + return { + label: colLabel, + value: item[colName], + }; }); - // 추가로 다른 의미있는 필드 1-2개 표시 (동적) - const additionalKeys = Object.keys(item).filter( - (k) => - k !== "id" && - k !== "ID" && - k !== leftColumn && - shouldShowField(k), - ); - - if (additionalKeys.length > 0) { - displayFields.push({ - label: additionalKeys[0], - value: item[additionalKeys[0]], - }); - } - if (index === 0) { - console.log(" ✅ 조인 키 기반 표시:", displayFields); + console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields); } } else { - // 상세 모드 또는 설정 없음: 자동으로 첫 2개 필드 표시 - const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID"); + // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 + const keys = Object.keys(item).filter( + (k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k) + ); displayFields = keys.slice(0, 2).map((key) => ({ - label: key, + label: leftColumnLabels[key] || key, value: item[key], })); if (index === 0) { - console.log(" ⚠️ 조인 키 없음, 자동 선택:", displayFields); + console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); } } From 52ad67d44a64f526ddfd1cc3f333cb91e5a54ad4 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 17:45:22 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20SplitPanelLayout2=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0-=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EB=A7=88=EC=8A=A4=ED=84=B0)-?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EB=94=94=ED=85=8C?= =?UTF-8?q?=EC=9D=BC)=20=EB=B6=84=ED=95=A0=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20EditModal=EC=97=90=20isCreateMode=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20INS?= =?UTF-8?q?ERT/UPDATE=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20dataFilt?= =?UTF-8?q?er=20=EA=B8=B0=EB=B0=98=20=EC=A0=95=ED=99=95=ED=95=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=9D=B8=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20dataTransferFields=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20ConfigPanel=EC=97=90=EC=84=9C=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94,=20=EC=BB=AC=EB=9F=BC,=20=EC=A1=B0=EC=9D=B8?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/EditModal.tsx | 111 ++- frontend/lib/registry/components/index.ts | 1 + .../components/split-panel-layout2/README.md | 102 +++ .../SplitPanelLayout2Component.tsx | 774 ++++++++++++++++++ .../SplitPanelLayout2ConfigPanel.tsx | 684 ++++++++++++++++ .../SplitPanelLayout2Renderer.tsx | 42 + .../components/split-panel-layout2/config.ts | 57 ++ .../components/split-panel-layout2/index.ts | 41 + .../components/split-panel-layout2/types.ts | 102 +++ .../lib/utils/getComponentConfigPanel.tsx | 1 + 10 files changed, 1878 insertions(+), 37 deletions(-) create mode 100644 frontend/lib/registry/components/split-panel-layout2/README.md create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/config.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/index.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/types.ts diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 9945a19c..cde9086c 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -118,7 +118,7 @@ export const EditModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; setModalState({ isOpen: true, @@ -134,7 +134,13 @@ export const EditModal: React.FC = ({ className }) => { // 편집 데이터로 폼 데이터 초기화 setFormData(editData || {}); - setOriginalData(editData || {}); + // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) + // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 + setOriginalData(isCreateMode ? {} : (editData || {})); + + if (isCreateMode) { + console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); + } }; const handleCloseEditModal = () => { @@ -567,46 +573,77 @@ export const EditModal: React.FC = ({ className }) => { return; } - // 기존 로직: 단일 레코드 수정 - const changedData: Record = {}; - Object.keys(formData).forEach((key) => { - if (formData[key] !== originalData[key]) { - changedData[key] = formData[key]; - } - }); + // originalData가 비어있으면 INSERT, 있으면 UPDATE + const isCreateMode = Object.keys(originalData).length === 0; + + if (isCreateMode) { + // INSERT 모드 + console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); + + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId!, + tableName: screenData.screenInfo.tableName, + data: formData, + }); - if (Object.keys(changedData).length === 0) { - toast.info("변경된 내용이 없습니다."); - handleClose(); - return; - } + if (response.success) { + toast.success("데이터가 생성되었습니다."); - // 기본키 확인 (id 또는 첫 번째 키) - const recordId = originalData.id || Object.values(originalData)[0]; - - // UPDATE 액션 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - toast.success("데이터가 수정되었습니다."); - - // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) - if (modalState.onSave) { - try { - modalState.onSave(); - } catch (callbackError) { - console.error("⚠️ onSave 콜백 에러:", callbackError); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } } + + handleClose(); + } else { + throw new Error(response.message || "생성에 실패했습니다."); + } + } else { + // UPDATE 모드 - 기존 로직 + const changedData: Record = {}; + Object.keys(formData).forEach((key) => { + if (formData[key] !== originalData[key]) { + changedData[key] = formData[key]; + } + }); + + if (Object.keys(changedData).length === 0) { + toast.info("변경된 내용이 없습니다."); + handleClose(); + return; } - handleClose(); - } else { - throw new Error(response.message || "수정에 실패했습니다."); + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + + // UPDATE 액션 실행 + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + toast.success("데이터가 수정되었습니다."); + + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } + } + + handleClose(); + } else { + throw new Error(response.message || "수정에 실패했습니다."); + } } } catch (error: any) { console.error("❌ 수정 실패:", error); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index fb7cd30b..746e2c2d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -37,6 +37,7 @@ import "./accordion-basic/AccordionBasicRenderer"; import "./table-list/TableListRenderer"; import "./card-display/CardDisplayRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; +import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2 import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md new file mode 100644 index 00000000..f1d8544b --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -0,0 +1,102 @@ +# SplitPanelLayout2 컴포넌트 + +마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트 (개선 버전) + +## 개요 + +- **ID**: `split-panel-layout2` +- **카테고리**: layout +- **웹타입**: container +- **버전**: 2.0.0 + +## 주요 기능 + +- 좌측 패널: 마스터 데이터 목록 (예: 부서 목록) +- 우측 패널: 디테일 데이터 목록 (예: 부서원 목록) +- 조인 기반 데이터 연결 +- 검색 기능 (좌측/우측 모두) +- 계층 구조 지원 (트리 형태) +- 데이터 전달 기능 (모달로 선택된 데이터 전달) +- 리사이즈 가능한 분할 바 + +## 사용 예시 + +### 부서-사원 관리 + +1. 좌측 패널: `dept_info` 테이블 (부서 목록) +2. 우측 패널: `user_info` 테이블 (사원 목록) +3. 조인 조건: `dept_code` = `dept_code` +4. 데이터 전달: `dept_code`, `dept_name`, `company_code` + +## 설정 옵션 + +### 좌측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| hierarchyConfig | object | 계층 구조 설정 | + +### 우측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| showAddButton | boolean | 추가 버튼 표시 | +| showEditButton | boolean | 수정 버튼 표시 | +| showDeleteButton | boolean | 삭제 버튼 표시 | + +### 조인 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| leftColumn | string | 좌측 테이블의 조인 컬럼 | +| rightColumn | string | 우측 테이블의 조인 컬럼 | + +### 데이터 전달 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| sourceColumn | string | 좌측 패널의 소스 컬럼 | +| targetColumn | string | 모달로 전달할 타겟 컬럼명 | + +## 데이터 흐름 + +``` +[좌측 패널 항목 클릭] + ↓ +[selectedLeftItem 상태 저장] + ↓ +[modalDataStore에 테이블명으로 저장] + ↓ +[ScreenContext DataProvider 등록] + ↓ +[우측 패널 데이터 로드 (조인 조건 적용)] +``` + +## 버튼과 연동 + +버튼 컴포넌트에서 이 컴포넌트의 선택된 데이터에 접근하려면: + +1. 버튼의 액션 타입을 `openModalWithData`로 설정 +2. 데이터 소스 ID를 좌측 패널의 테이블명으로 설정 (예: `dept_info`) +3. `modalDataStore`에서 자동으로 데이터를 가져옴 + +## 개발자 정보 + +- **생성일**: 2024 +- **경로**: `lib/registry/components/split-panel-layout2/` + +## 관련 문서 + +- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) +- [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx new file mode 100644 index 00000000..be14038f --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -0,0 +1,774 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { + SplitPanelLayout2Config, + ColumnConfig, + DataTransferField, +} from "./types"; +import { defaultConfig } from "./config"; +import { cn } from "@/lib/utils"; +import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { apiClient } from "@/lib/api/client"; + +export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { + // 추가 props +} + +/** + * SplitPanelLayout2 컴포넌트 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Component: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + isPreview = false, + onClick, + ...props +}) => { + const config = useMemo(() => { + return { + ...defaultConfig, + ...component.componentConfig, + } as SplitPanelLayout2Config; + }, [component.componentConfig]); + + // ScreenContext (데이터 전달용) + const screenContext = useScreenContextOptional(); + + // 상태 관리 + const [leftData, setLeftData] = useState([]); + const [rightData, setRightData] = useState([]); + const [selectedLeftItem, setSelectedLeftItem] = useState(null); + const [leftSearchTerm, setLeftSearchTerm] = useState(""); + const [rightSearchTerm, setRightSearchTerm] = useState(""); + const [leftLoading, setLeftLoading] = useState(false); + const [rightLoading, setRightLoading] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30); + const [isResizing, setIsResizing] = useState(false); + + // 좌측 패널 컬럼 라벨 매핑 + const [leftColumnLabels, setLeftColumnLabels] = useState>({}); + const [rightColumnLabels, setRightColumnLabels] = useState>({}); + + + // 좌측 데이터 로드 + const loadLeftData = useCallback(async () => { + if (!config.leftPanel?.tableName || isDesignMode) return; + + setLeftLoading(true); + try { + const response = await apiClient.post(`/table-management/tables/${config.leftPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + let data = response.data.data?.data || []; + + // 계층 구조 처리 + if (config.leftPanel.hierarchyConfig?.enabled) { + data = buildHierarchy( + data, + config.leftPanel.hierarchyConfig.idColumn, + config.leftPanel.hierarchyConfig.parentColumn + ); + } + + setLeftData(data); + console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); + } + } catch (error) { + console.error("[SplitPanelLayout2] 좌측 데이터 로드 실패:", error); + toast.error("좌측 패널 데이터를 불러오는데 실패했습니다."); + } finally { + setLeftLoading(false); + } + }, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]); + + // 우측 데이터 로드 (좌측 선택 항목 기반) + const loadRightData = useCallback(async (selectedItem: any) => { + if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { + setRightData([]); + return; + } + + const joinValue = selectedItem[config.joinConfig.leftColumn]; + if (joinValue === undefined || joinValue === null) { + console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`); + setRightData([]); + return; + } + + setRightLoading(true); + try { + console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`); + + const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) + dataFilter: { + enabled: true, + matchType: "all", + filters: [ + { + id: "join_filter", + columnName: config.joinConfig.rightColumn, + operator: "equals", + value: String(joinValue), + valueType: "static", + } + ], + }, + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + const data = response.data.data?.data || []; + setRightData(data); + console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); + } else { + console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); + setRightData([]); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { + message: error?.message, + status: error?.response?.status, + statusText: error?.response?.statusText, + data: error?.response?.data, + config: { + url: error?.config?.url, + method: error?.config?.method, + data: error?.config?.data, + } + }); + setRightData([]); + } finally { + setRightLoading(false); + } + }, [config.rightPanel?.tableName, config.joinConfig]); + + // 좌측 패널 추가 버튼 클릭 + const handleLeftAddClick = useCallback(() => { + if (!config.leftPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.leftPanel.addModalScreenId, + title: config.leftPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: {}, + isCreateMode: true, // 생성 모드 + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 추가 모달 열기:", config.leftPanel.addModalScreenId); + }, [config.leftPanel?.addModalScreenId, config.leftPanel?.addButtonLabel, loadLeftData]); + + // 우측 패널 추가 버튼 클릭 + const handleRightAddClick = useCallback(() => { + if (!config.rightPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } + } + } + + console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData); + console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId); + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.rightPanel.addModalScreenId, + title: config.rightPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, // 생성 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 우측 추가 모달 열기"); + }, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]); + + // 컬럼 라벨 로드 + const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { + if (!tableName) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + const labels: Record = {}; + // API 응답 구조: { success: true, data: { columns: [...] } } + const columns = response.data.data?.columns || []; + columns.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const colLabel = col.column_label || col.columnLabel || colName; + if (colName) { + labels[colName] = colLabel; + } + }); + setLabels(labels); + } + } catch (error) { + console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); + } + }, []); + + // 계층 구조 빌드 + const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => { + const itemMap = new Map(); + const roots: any[] = []; + + // 모든 항목을 맵에 저장 + data.forEach((item) => { + itemMap.set(item[idColumn], { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + data.forEach((item) => { + const current = itemMap.get(item[idColumn]); + const parentId = item[parentColumn]; + + if (parentId && itemMap.has(parentId)) { + itemMap.get(parentId).children.push(current); + } else { + roots.push(current); + } + }); + + return roots; + }; + + // 좌측 항목 선택 핸들러 + const handleLeftItemSelect = useCallback((item: any) => { + setSelectedLeftItem(item); + loadRightData(item); + + // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) + if (screenContext && !isDesignMode) { + screenContext.registerDataProvider(component.id, { + componentId: component.id, + componentType: "split-panel-layout2", + getSelectedData: () => [item], + getAllData: () => leftData, + clearSelection: () => setSelectedLeftItem(null), + }); + console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); + } + }, [isDesignMode, screenContext, component.id, leftData, loadRightData]); + + // 항목 확장/축소 토글 + const toggleExpand = useCallback((itemId: string) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); + + // 검색 필터링 + const filteredLeftData = useMemo(() => { + if (!leftSearchTerm) return leftData; + + const searchColumn = config.leftPanel?.searchColumn; + if (!searchColumn) return leftData; + + const filterRecursive = (items: any[]): any[] => { + return items.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + const matches = value.includes(leftSearchTerm.toLowerCase()); + + if (item.children?.length > 0) { + const filteredChildren = filterRecursive(item.children); + if (filteredChildren.length > 0) { + item.children = filteredChildren; + return true; + } + } + + return matches; + }); + }; + + return filterRecursive([...leftData]); + }, [leftData, leftSearchTerm, config.leftPanel?.searchColumn]); + + const filteredRightData = useMemo(() => { + if (!rightSearchTerm) return rightData; + + const searchColumn = config.rightPanel?.searchColumn; + if (!searchColumn) return rightData; + + return rightData.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + return value.includes(rightSearchTerm.toLowerCase()); + }); + }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); + + // 리사이즈 핸들러 + const handleResizeStart = useCallback((e: React.MouseEvent) => { + if (!config.resizable) return; + e.preventDefault(); + setIsResizing(true); + }, [config.resizable]); + + const handleResizeMove = useCallback((e: MouseEvent) => { + if (!isResizing) return; + + const container = document.getElementById(`split-panel-${component.id}`); + if (!container) return; + + const rect = container.getBoundingClientRect(); + const newPosition = ((e.clientX - rect.left) / rect.width) * 100; + const minLeft = (config.minLeftWidth || 200) / rect.width * 100; + const minRight = (config.minRightWidth || 300) / rect.width * 100; + + setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); + }, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + }, []); + + // 리사이즈 이벤트 리스너 + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + } + return () => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }; + }, [isResizing, handleResizeMove, handleResizeEnd]); + + // 초기 데이터 로드 + useEffect(() => { + if (config.autoLoad && !isDesignMode) { + loadLeftData(); + loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels); + loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels); + } + }, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]); + + // 컴포넌트 언마운트 시 DataProvider 해제 + useEffect(() => { + return () => { + if (screenContext) { + screenContext.unregisterDataProvider(component.id); + } + }; + }, [screenContext, component.id]); + + // 값 포맷팅 + const formatValue = (value: any, format?: ColumnConfig["format"]): string => { + if (value === null || value === undefined) return "-"; + if (!format) return String(value); + + switch (format.type) { + case "number": + const num = Number(value); + if (isNaN(num)) return String(value); + let formatted = format.decimalPlaces !== undefined + ? num.toFixed(format.decimalPlaces) + : String(num); + if (format.thousandSeparator) { + formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + return `${format.prefix || ""}${formatted}${format.suffix || ""}`; + + case "currency": + const currency = Number(value); + if (isNaN(currency)) return String(value); + const currencyFormatted = currency.toLocaleString("ko-KR"); + return `${format.prefix || ""}${currencyFormatted}${format.suffix || "원"}`; + + case "date": + try { + const date = new Date(value); + return date.toLocaleDateString("ko-KR"); + } catch { + return String(value); + } + + default: + return String(value); + } + }; + + // 좌측 패널 항목 렌더링 + const renderLeftItem = (item: any, level: number = 0, index: number = 0) => { + const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; + const itemId = item[idColumn] ?? `item-${level}-${index}`; + const hasChildren = item.children?.length > 0; + const isExpanded = expandedItems.has(String(itemId)); + const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; + + // 표시할 컬럼 결정 + const displayColumns = config.leftPanel?.displayColumns || []; + const primaryColumn = displayColumns[0]; + const secondaryColumn = displayColumns[1]; + + const primaryValue = primaryColumn + ? item[primaryColumn.name] + : Object.values(item).find((v) => typeof v === "string" && v.length > 0); + const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; + + return ( +
+
handleLeftItemSelect(item)} + > + {/* 확장/축소 버튼 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 아이콘 */} + + + {/* 내용 */} +
+
+ {primaryValue || "이름 없음"} +
+ {secondaryValue && ( +
+ {secondaryValue} +
+ )} +
+
+ + {/* 자식 항목 */} + {hasChildren && isExpanded && ( +
+ {item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))} +
+ )} +
+ ); + }; + + // 우측 패널 카드 렌더링 + const renderRightCard = (item: any, index: number) => { + const displayColumns = config.rightPanel?.displayColumns || []; + + // 첫 번째 컬럼을 이름으로 사용 + const nameColumn = displayColumns[0]; + const name = nameColumn ? item[nameColumn.name] : "이름 없음"; + + // 나머지 컬럼들 + const otherColumns = displayColumns.slice(1); + + return ( + + +
+
+ {/* 이름 */} +
+ {name} + {otherColumns[0] && ( + + {item[otherColumns[0].name]} + + )} +
+ + {/* 상세 정보 */} +
+ {otherColumns.slice(1).map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + + // 아이콘 결정 + let icon = null; + const colName = col.name.toLowerCase(); + if (colName.includes("tel") || colName.includes("phone")) { + icon = tel; + } else if (colName.includes("email")) { + icon = @; + } else if (colName.includes("sabun") || colName.includes("id")) { + icon = ID; + } + + return ( + + {icon} + {formatValue(value, col.format)} + + ); + })} +
+
+ + {/* 액션 버튼 */} +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+
+
+ ); + }; + + // 디자인 모드 렌더링 + if (isDesignMode) { + return ( +
+ {/* 좌측 패널 미리보기 */} +
+
+ {config.leftPanel?.title || "좌측 패널"} +
+
+ 테이블: {config.leftPanel?.tableName || "미설정"} +
+
+ 좌측 목록 영역 +
+
+ + {/* 우측 패널 미리보기 */} +
+
+ {config.rightPanel?.title || "우측 패널"} +
+
+ 테이블: {config.rightPanel?.tableName || "미설정"} +
+
+ 우측 상세 영역 +
+
+
+ ); + } + + return ( +
+ {/* 좌측 패널 */} +
+ {/* 헤더 */} +
+
+

{config.leftPanel?.title || "목록"}

+ {config.leftPanel?.showAddButton && ( + + )} +
+ + {/* 검색 */} + {config.leftPanel?.showSearch && ( +
+ + setLeftSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 목록 */} +
+ {leftLoading ? ( +
+ 로딩 중... +
+ ) : filteredLeftData.length === 0 ? ( +
+ 데이터가 없습니다 +
+ ) : ( +
+ {filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))} +
+ )} +
+
+ + {/* 리사이저 */} + {config.resizable && ( +
+ )} + + {/* 우측 패널 */} +
+ {/* 헤더 */} +
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

+
+ {selectedLeftItem && ( + + {rightData.length}명 + + )} + {config.rightPanel?.showAddButton && selectedLeftItem && ( + + )} +
+
+ + {/* 검색 */} + {config.rightPanel?.showSearch && selectedLeftItem && ( +
+ + setRightSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 내용 */} +
+ {!selectedLeftItem ? ( +
+ + {config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"} +
+ ) : rightLoading ? ( +
+ 로딩 중... +
+ ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} +
+
+
+ ); +}; + +/** + * SplitPanelLayout2 래퍼 컴포넌트 + */ +export const SplitPanelLayout2Wrapper: React.FC = (props) => { + return ; +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx new file mode 100644 index 00000000..878ddb12 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -0,0 +1,684 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; + +// lodash set 대체 함수 +const setPath = (obj: any, path: string, value: any): any => { + const keys = path.split("."); + const result = { ...obj }; + let current = result; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + current[key] = current[key] ? { ...current[key] } : {}; + current = current[key]; + } + + current[keys[keys.length - 1]] = value; + return result; +}; + +interface SplitPanelLayout2ConfigPanelProps { + config: SplitPanelLayout2Config; + onChange: (config: SplitPanelLayout2Config) => void; +} + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface ScreenInfo { + screen_id: number; + screen_name: string; + screen_code: string; +} + +export const SplitPanelLayout2ConfigPanel: React.FC = ({ + config, + onChange, +}) => { + // updateConfig 헬퍼 함수: 경로 기반으로 config를 업데이트 + const updateConfig = useCallback((path: string, value: any) => { + console.log(`[SplitPanelLayout2ConfigPanel] updateConfig: ${path} =`, value); + const newConfig = setPath(config, path, value); + console.log("[SplitPanelLayout2ConfigPanel] newConfig:", newConfig); + onChange(newConfig); + }, [config, onChange]); + + // 상태 + const [tables, setTables] = useState([]); + const [leftColumns, setLeftColumns] = useState([]); + const [rightColumns, setRightColumns] = useState([]); + const [screens, setScreens] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [screensLoading, setScreensLoading] = useState(false); + + // Popover 상태 + const [leftTableOpen, setLeftTableOpen] = useState(false); + const [rightTableOpen, setRightTableOpen] = useState(false); + const [leftModalOpen, setLeftModalOpen] = useState(false); + const [rightModalOpen, setRightModalOpen] = useState(false); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + setTablesLoading(true); + try { + const response = await apiClient.get("/table/list?userLang=KR"); + const tableList = response.data?.data || response.data || []; + if (Array.isArray(tableList)) { + setTables(tableList); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setTablesLoading(false); + } + }, []); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen/list"); + console.log("[loadScreens] API 응답:", response.data); + const screenList = response.data?.data || response.data || []; + if (Array.isArray(screenList)) { + const transformedScreens = screenList.map((s: any) => ({ + screen_id: s.screen_id || s.id, + screen_name: s.screen_name || s.name, + screen_code: s.screen_code || s.code || "", + })); + console.log("[loadScreens] 변환된 화면 목록:", transformedScreens); + setScreens(transformedScreens); + } + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setScreensLoading(false); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, side: "left" | "right") => { + if (!tableName) return; + try { + const response = await apiClient.get(`/table/${tableName}/columns`); + const columnList = response.data?.data || response.data || []; + if (Array.isArray(columnList)) { + if (side === "left") { + setLeftColumns(columnList); + } else { + setRightColumns(columnList); + } + } + } catch (error) { + console.error(`${side} 컬럼 목록 로드 실패:`, error); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadTables(); + loadScreens(); + }, [loadTables, loadScreens]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (config.leftPanel?.tableName) { + loadColumns(config.leftPanel.tableName, "left"); + } + }, [config.leftPanel?.tableName, loadColumns]); + + useEffect(() => { + if (config.rightPanel?.tableName) { + loadColumns(config.rightPanel.tableName, "right"); + } + }, [config.rightPanel?.tableName, loadColumns]); + + // 테이블 선택 컴포넌트 + const TableSelect: React.FC<{ + value: string; + onValueChange: (value: string) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 테이블이 없습니다 + + {tables.map((table) => ( + { + onValueChange(selectedValue); + onOpenChange(false); + }} + > + + + {table.table_comment || table.table_name} + {table.table_name} + + + ))} + + + + + + ); + + // 화면 선택 컴포넌트 + const ScreenSelect: React.FC<{ + value: number | undefined; + onValueChange: (value: number | undefined) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 화면이 없습니다 + + {screens.map((screen, index) => ( + { + const screenId = parseInt(selectedValue.split("-")[0]); + console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); + onValueChange(screenId); + onOpenChange(false); + }} + className="flex items-center" + > +
+ + + {screen.screen_name} + {screen.screen_code} + +
+
+ ))} +
+
+
+
+
+ ); + + // 컬럼 선택 컴포넌트 + const ColumnSelect: React.FC<{ + columns: ColumnInfo[]; + value: string; + onValueChange: (value: string) => void; + placeholder: string; + }> = ({ columns, value, onValueChange, placeholder }) => ( + + ); + + // 표시 컬럼 추가 + const addDisplayColumn = (side: "left" | "right") => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, [...currentColumns, { name: "", label: "" }]); + }; + + // 표시 컬럼 삭제 + const removeDisplayColumn = (side: "left" | "right", index: number) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, currentColumns.filter((_, i) => i !== index)); + }; + + // 표시 컬럼 업데이트 + const updateDisplayColumn = (side: "left" | "right", index: number, field: keyof ColumnConfig, value: any) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? [...(config.leftPanel?.displayColumns || [])] + : [...(config.rightPanel?.displayColumns || [])]; + + if (currentColumns[index]) { + currentColumns[index] = { ...currentColumns[index], [field]: value }; + updateConfig(path, currentColumns); + } + }; + + // 데이터 전달 필드 추가 + const addDataTransferField = () => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", [...currentFields, { sourceColumn: "", targetColumn: "" }]); + }; + + // 데이터 전달 필드 삭제 + const removeDataTransferField = (index: number) => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", currentFields.filter((_, i) => i !== index)); + }; + + // 데이터 전달 필드 업데이트 + const updateDataTransferField = (index: number, field: keyof DataTransferField, value: string) => { + const currentFields = [...(config.dataTransferFields || [])]; + if (currentFields[index]) { + currentFields[index] = { ...currentFields[index], [field]: value }; + updateConfig("dataTransferFields", currentFields); + } + }; + + return ( +
+ {/* 좌측 패널 설정 */} +
+

좌측 패널 설정 (마스터)

+ +
+
+ + updateConfig("leftPanel.title", e.target.value)} + placeholder="부서" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("leftPanel.tableName", value)} + placeholder="테이블 선택" + open={leftTableOpen} + onOpenChange={setLeftTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.leftPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("left", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("leftPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("leftPanel.showAddButton", checked)} + /> +
+ + {config.leftPanel?.showAddButton && ( + <> +
+ + updateConfig("leftPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("leftPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={leftModalOpen} + onOpenChange={setLeftModalOpen} + /> +
+ + )} +
+
+ + {/* 우측 패널 설정 */} +
+

우측 패널 설정 (상세)

+ +
+
+ + updateConfig("rightPanel.title", e.target.value)} + placeholder="사원" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("rightPanel.tableName", value)} + placeholder="테이블 선택" + open={rightTableOpen} + onOpenChange={setRightTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.rightPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("right", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("rightPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("rightPanel.showAddButton", checked)} + /> +
+ + {config.rightPanel?.showAddButton && ( + <> +
+ + updateConfig("rightPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("rightPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={rightModalOpen} + onOpenChange={setRightModalOpen} + /> +
+ + )} +
+
+ + {/* 연결 설정 */} +
+

연결 설정 (조인)

+ +
+
+ + updateConfig("joinConfig.leftColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+ +
+ + updateConfig("joinConfig.rightColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+
+
+ + {/* 데이터 전달 설정 */} +
+
+

데이터 전달 설정

+ +
+ +
+ {(config.dataTransferFields || []).map((field, index) => ( +
+
+ 필드 {index + 1} + +
+
+ + updateDataTransferField(index, "sourceColumn", value)} + placeholder="소스 컬럼" + /> +
+
+ + updateDataTransferField(index, "targetColumn", e.target.value)} + placeholder="모달에서 사용할 필드명" + className="h-9 text-sm" + /> +
+
+ ))} +
+
+ + {/* 레이아웃 설정 */} +
+

레이아웃 설정

+ +
+
+ + updateConfig("splitRatio", parseInt(e.target.value) || 30)} + min={10} + max={90} + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("resizable", checked)} + /> +
+ +
+ + updateConfig("autoLoad", checked)} + /> +
+
+
+
+ ); +}; + +export default SplitPanelLayout2ConfigPanel; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx new file mode 100644 index 00000000..f582646e --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { SplitPanelLayout2Definition } from "./index"; +import { SplitPanelLayout2Component } from "./SplitPanelLayout2Component"; + +/** + * SplitPanelLayout2 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer { + static componentDefinition = SplitPanelLayout2Definition; + + render(): React.ReactElement { + return ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 좌측 패널 데이터 새로고침 + public refreshLeftPanel() { + // 컴포넌트 내부에서 처리 + } + + // 우측 패널 데이터 새로고침 + public refreshRightPanel() { + // 컴포넌트 내부에서 처리 + } + + // 선택된 좌측 항목 가져오기 + public getSelectedLeftItem(): any { + // 컴포넌트 내부 상태에서 가져옴 + return null; + } +} + +// 자동 등록 실행 +SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/split-panel-layout2/config.ts b/frontend/lib/registry/components/split-panel-layout2/config.ts new file mode 100644 index 00000000..493ddf2c --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/config.ts @@ -0,0 +1,57 @@ +/** + * SplitPanelLayout2 기본 설정 + */ + +import { SplitPanelLayout2Config } from "./types"; + +/** + * 기본 설정값 + */ +export const defaultConfig: Partial = { + leftPanel: { + title: "목록", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: false, + }, + rightPanel: { + title: "상세", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: true, + addButtonLabel: "추가", + showEditButton: true, + showDeleteButton: true, + displayMode: "card", + emptyMessage: "좌측에서 항목을 선택해주세요", + }, + joinConfig: { + leftColumn: "", + rightColumn: "", + }, + dataTransferFields: [], + splitRatio: 30, + resizable: true, + minLeftWidth: 250, + minRightWidth: 400, + autoLoad: true, +}; + +/** + * 컴포넌트 메타데이터 + */ +export const componentMeta = { + id: "split-panel-layout2", + name: "분할 패널 레이아웃 v2", + nameEng: "Split Panel Layout v2", + description: "마스터-디테일 패턴의 좌우 분할 레이아웃 (데이터 전달 기능 포함)", + category: "layout", + webType: "container", + icon: "LayoutPanelLeft", + tags: ["레이아웃", "분할", "마스터", "디테일", "패널", "부서", "사원"], + version: "2.0.0", + author: "개발팀", +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/index.ts b/frontend/lib/registry/components/split-panel-layout2/index.ts new file mode 100644 index 00000000..64a88b11 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/index.ts @@ -0,0 +1,41 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import type { WebType } from "@/types/screen"; +import { SplitPanelLayout2Wrapper } from "./SplitPanelLayout2Component"; +import { SplitPanelLayout2ConfigPanel } from "./SplitPanelLayout2ConfigPanel"; +import { defaultConfig, componentMeta } from "./config"; + +/** + * SplitPanelLayout2 컴포넌트 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Definition = createComponentDefinition({ + id: componentMeta.id, + name: componentMeta.name, + nameEng: componentMeta.nameEng, + description: componentMeta.description, + category: ComponentCategory.LAYOUT, + webType: componentMeta.webType as WebType, + component: SplitPanelLayout2Wrapper, + defaultConfig: defaultConfig, + defaultSize: { width: 1200, height: 600 }, + configPanel: SplitPanelLayout2ConfigPanel, + icon: componentMeta.icon, + tags: componentMeta.tags, + version: componentMeta.version, + author: componentMeta.author, + documentation: "https://docs.example.com/components/split-panel-layout2", +}); + +// 타입 내보내기 +export type { + SplitPanelLayout2Config, + LeftPanelConfig, + RightPanelConfig, + JoinConfig, + DataTransferField, + ColumnConfig, +} from "./types"; + diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts new file mode 100644 index 00000000..ec0f61b5 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -0,0 +1,102 @@ +/** + * SplitPanelLayout2 컴포넌트 타입 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ + +/** + * 컬럼 설정 + */ +export interface ColumnConfig { + name: string; // 컬럼명 + label: string; // 표시 라벨 + width?: number; // 너비 (px) + bold?: boolean; // 굵게 표시 + format?: { + type?: "text" | "number" | "currency" | "date"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }; +} + +/** + * 데이터 전달 필드 설정 + */ +export interface DataTransferField { + sourceColumn: string; // 좌측 패널의 컬럼명 + targetColumn: string; // 모달로 전달할 컬럼명 + label?: string; // 표시용 라벨 +} + +/** + * 좌측 패널 설정 + */ +export interface LeftPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + // 계층 구조 설정 + hierarchyConfig?: { + enabled: boolean; + parentColumn: string; // 부모 참조 컬럼 (예: parent_dept_code) + idColumn: string; // ID 컬럼 (예: dept_code) + }; +} + +/** + * 우측 패널 설정 + */ +export interface RightPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + showEditButton?: boolean; // 수정 버튼 표시 + showDeleteButton?: boolean; // 삭제 버튼 표시 + displayMode?: "card" | "list"; // 표시 모드 + emptyMessage?: string; // 데이터 없을 때 메시지 +} + +/** + * 조인 설정 + */ +export interface JoinConfig { + leftColumn: string; // 좌측 테이블의 조인 컬럼 + rightColumn: string; // 우측 테이블의 조인 컬럼 +} + +/** + * 메인 설정 + */ +export interface SplitPanelLayout2Config { + // 패널 설정 + leftPanel: LeftPanelConfig; + rightPanel: RightPanelConfig; + + // 조인 설정 + joinConfig: JoinConfig; + + // 데이터 전달 설정 (모달로 전달할 필드) + dataTransferFields?: DataTransferField[]; + + // 레이아웃 설정 + splitRatio?: number; // 좌우 비율 (0-100, 기본 30) + resizable?: boolean; // 크기 조절 가능 여부 + minLeftWidth?: number; // 좌측 최소 너비 (px) + minRightWidth?: number; // 우측 최소 너비 (px) + + // 동작 설정 + autoLoad?: boolean; // 자동 데이터 로드 +} + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index f921016c..cb2d3f52 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -24,6 +24,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), "card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"), "split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"), + "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"), "flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"), // 🆕 수주 등록 관련 컴포넌트들 From 669717f656b8daa0050a4cf0b85c2bb33a8f5d6b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 18:43:01 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(split-panel-layout2):=20=EB=B3=B5?= =?UTF-8?q?=EC=88=98=20=EA=B2=80=EC=83=89=20=EC=BB=AC=EB=9F=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchColumnConfig 타입 추가 (types.ts) - 좌측/우측 패널 모두 여러 검색 컬럼 설정 가능 - ConfigPanel에 검색 컬럼 추가/삭제 UI 구현 - 검색 시 OR 조건으로 여러 컬럼 동시 검색 - 기존 searchColumn 단일 설정과 하위 호환성 유지 --- .../SplitPanelLayout2Component.tsx | 185 ++++-- .../SplitPanelLayout2ConfigPanel.tsx | 621 +++++++++++++----- .../components/split-panel-layout2/types.ts | 15 +- 3 files changed, 587 insertions(+), 234 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index be14038f..8a9d73a7 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -317,13 +317,20 @@ export const SplitPanelLayout2Component: React.FC { if (!leftSearchTerm) return leftData; - const searchColumn = config.leftPanel?.searchColumn; - if (!searchColumn) return leftData; + // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) + const searchColumns = config.leftPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; + const legacyColumn = config.leftPanel?.searchColumn; + const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; + + if (columnsToSearch.length === 0) return leftData; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { - const value = String(item[searchColumn] || "").toLowerCase(); - const matches = value.includes(leftSearchTerm.toLowerCase()); + // 여러 컬럼 중 하나라도 매칭되면 포함 + const matches = columnsToSearch.some((col) => { + const value = String(item[col] || "").toLowerCase(); + return value.includes(leftSearchTerm.toLowerCase()); + }); if (item.children?.length > 0) { const filteredChildren = filterRecursive(item.children); @@ -338,19 +345,26 @@ export const SplitPanelLayout2Component: React.FC { if (!rightSearchTerm) return rightData; - const searchColumn = config.rightPanel?.searchColumn; - if (!searchColumn) return rightData; + // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) + const searchColumns = config.rightPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; + const legacyColumn = config.rightPanel?.searchColumn; + const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; + + if (columnsToSearch.length === 0) return rightData; return rightData.filter((item) => { - const value = String(item[searchColumn] || "").toLowerCase(); - return value.includes(rightSearchTerm.toLowerCase()); + // 여러 컬럼 중 하나라도 매칭되면 포함 + return columnsToSearch.some((col) => { + const value = String(item[col] || "").toLowerCase(); + return value.includes(rightSearchTerm.toLowerCase()); + }); }); - }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); + }, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); // 리사이즈 핸들러 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -451,15 +465,19 @@ export const SplitPanelLayout2Component: React.FC + col.displayRow === "name" || (!col.displayRow && idx === 0) + ); + const infoRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "info" || (!col.displayRow && idx > 0) + ); - const primaryValue = primaryColumn - ? item[primaryColumn.name] + // 이름 행의 첫 번째 값 (주요 표시 값) + const primaryValue = nameRowColumns[0] + ? item[nameRowColumns[0].name] : Object.values(item).find((v) => typeof v === "string" && v.length > 0); - const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; return (
@@ -496,12 +514,38 @@ export const SplitPanelLayout2Component: React.FC -
- {primaryValue || "이름 없음"} + {/* 이름 행 (Name Row) */} +
+ + {primaryValue || "이름 없음"} + + {/* 이름 행의 추가 컬럼들 (배지 스타일) */} + {nameRowColumns.slice(1).map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + return ( + + {formatValue(value, col.format)} + + ); + })}
- {secondaryValue && ( -
- {secondaryValue} + {/* 정보 행 (Info Row) */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + return ( + + {formatValue(value, col.format)} + + ); + }).filter(Boolean).reduce((acc: React.ReactNode[], curr, idx) => { + if (idx > 0) acc.push(|); + acc.push(curr); + return acc; + }, [])}
)}
@@ -521,53 +565,72 @@ export const SplitPanelLayout2Component: React.FC { const displayColumns = config.rightPanel?.displayColumns || []; - // 첫 번째 컬럼을 이름으로 사용 - const nameColumn = displayColumns[0]; - const name = nameColumn ? item[nameColumn.name] : "이름 없음"; - - // 나머지 컬럼들 - const otherColumns = displayColumns.slice(1); + // displayRow 설정에 따라 컬럼 분류 + // displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info) + const nameRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "name" || (!col.displayRow && idx === 0) + ); + const infoRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "info" || (!col.displayRow && idx > 0) + ); return ( - - + +
- {/* 이름 */} -
- {name} - {otherColumns[0] && ( - - {item[otherColumns[0].name]} - - )} -
+ {/* 이름 행 (Name Row) */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value && idx > 0) return null; + + // 첫 번째 컬럼은 굵게 표시 + if (idx === 0) { + return ( + + {formatValue(value, col.format) || "이름 없음"} + + ); + } + // 나머지는 배지 스타일 + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )} - {/* 상세 정보 */} -
- {otherColumns.slice(1).map((col, idx) => { - const value = item[col.name]; - if (!value) return null; + {/* 정보 행 (Info Row) */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value) return null; - // 아이콘 결정 - let icon = null; - const colName = col.name.toLowerCase(); - if (colName.includes("tel") || colName.includes("phone")) { - icon = tel; - } else if (colName.includes("email")) { - icon = @; - } else if (colName.includes("sabun") || colName.includes("id")) { - icon = ID; - } + // 아이콘 결정 + let icon = null; + const colName = col.name.toLowerCase(); + if (colName.includes("tel") || colName.includes("phone")) { + icon = tel; + } else if (colName.includes("email")) { + icon = @; + } else if (colName.includes("sabun") || colName.includes("id")) { + icon = ID; + } - return ( - - {icon} - {formatValue(value, col.format)} - - ); - })} -
+ return ( + + {icon} + {formatValue(value, col.format)} + + ); + })} +
+ )}
{/* 액션 버튼 */} diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 878ddb12..db3638cb 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -98,13 +98,35 @@ export const SplitPanelLayout2ConfigPanel: React.FC { setTablesLoading(true); try { - const response = await apiClient.get("/table/list?userLang=KR"); - const tableList = response.data?.data || response.data || []; - if (Array.isArray(tableList)) { - setTables(tableList); + const response = await apiClient.get("/table-management/tables"); + console.log("[loadTables] API 응답:", response.data); + + let tableList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + tableList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + tableList = response.data.data; + } else if (Array.isArray(response.data)) { + tableList = response.data; + } + + console.log("[loadTables] 추출된 테이블 목록:", tableList); + + if (tableList.length > 0) { + // 백엔드에서 카멜케이스(tableName)로 반환하므로 둘 다 처리 + const transformedTables = tableList.map((t: any) => ({ + table_name: t.tableName ?? t.table_name ?? t.name ?? "", + table_comment: t.displayName ?? t.table_comment ?? t.description ?? "", + })); + console.log("[loadTables] 변환된 테이블 목록:", transformedTables); + setTables(transformedTables); + } else { + console.warn("[loadTables] 테이블 목록이 비어있습니다"); + setTables([]); } } catch (error) { console.error("테이블 목록 로드 실패:", error); + setTables([]); } finally { setTablesLoading(false); } @@ -114,20 +136,38 @@ export const SplitPanelLayout2ConfigPanel: React.FC { setScreensLoading(true); try { - const response = await apiClient.get("/screen/list"); + // size를 크게 설정하여 모든 화면 가져오기 + const response = await apiClient.get("/screen-management/screens?size=1000"); console.log("[loadScreens] API 응답:", response.data); - const screenList = response.data?.data || response.data || []; - if (Array.isArray(screenList)) { + + // API 응답 구조: { success, data: [...], total, page, size } + let screenList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data)) { + screenList = response.data; + } + + console.log("[loadScreens] 추출된 화면 목록:", screenList); + + if (screenList.length > 0) { + // 백엔드에서 카멜케이스(screenId, screenName)로 반환하므로 둘 다 처리 const transformedScreens = screenList.map((s: any) => ({ - screen_id: s.screen_id || s.id, - screen_name: s.screen_name || s.name, - screen_code: s.screen_code || s.code || "", + screen_id: s.screenId ?? s.screen_id ?? s.id, + screen_name: s.screenName ?? s.screen_name ?? s.name ?? `화면 ${s.screenId || s.screen_id || s.id}`, + screen_code: s.screenCode ?? s.screen_code ?? s.code ?? "", })); console.log("[loadScreens] 변환된 화면 목록:", transformedScreens); setScreens(transformedScreens); + } else { + console.warn("[loadScreens] 화면 목록이 비어있습니다"); + setScreens([]); } } catch (error) { console.error("화면 목록 로드 실패:", error); + setScreens([]); } finally { setScreensLoading(false); } @@ -137,17 +177,52 @@ export const SplitPanelLayout2ConfigPanel: React.FC { if (!tableName) return; try { - const response = await apiClient.get(`/table/${tableName}/columns`); - const columnList = response.data?.data || response.data || []; - if (Array.isArray(columnList)) { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=200`); + console.log(`[loadColumns] ${side} API 응답:`, response.data); + + // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } + let columnList: any[] = []; + if (response.data?.success && response.data?.data?.columns) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data?.columns)) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data)) { + columnList = response.data.data; + } else if (Array.isArray(response.data)) { + columnList = response.data; + } + + console.log(`[loadColumns] ${side} 추출된 컬럼 목록:`, columnList); + + if (columnList.length > 0) { + // 백엔드에서 카멜케이스(columnName)로 반환하므로 둘 다 처리 + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + console.log(`[loadColumns] ${side} 변환된 컬럼 목록:`, transformedColumns); + if (side === "left") { - setLeftColumns(columnList); + setLeftColumns(transformedColumns); } else { - setRightColumns(columnList); + setRightColumns(transformedColumns); + } + } else { + console.warn(`[loadColumns] ${side} 컬럼 목록이 비어있습니다`); + if (side === "left") { + setLeftColumns([]); + } else { + setRightColumns([]); } } } catch (error) { console.error(`${side} 컬럼 목록 로드 실패:`, error); + if (side === "left") { + setLeftColumns([]); + } else { + setRightColumns([]); + } } }, []); @@ -177,59 +252,63 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; - }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( - - - - - - - - - 테이블이 없습니다 - - {tables.map((table) => ( - { - onValueChange(selectedValue); - onOpenChange(false); - }} - > - - - {table.table_comment || table.table_name} - {table.table_name} - - - ))} - - - - - - ); + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => { + const selectedTable = tables.find((t) => t.table_name === value); + + return ( + + + + + + + + + + {tables.length === 0 ? "테이블 목록을 불러오는 중..." : "검색 결과가 없습니다"} + + + {tables.map((table, index) => ( + { + onValueChange(selectedValue); + onOpenChange(false); + }} + > + + + {table.table_comment || table.table_name} + {table.table_name} + + + ))} + + + + + + ); + }; // 화면 선택 컴포넌트 const ScreenSelect: React.FC<{ @@ -238,64 +317,70 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; - }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( - - - - - - - - - 화면이 없습니다 - - {screens.map((screen, index) => ( - { - const screenId = parseInt(selectedValue.split("-")[0]); - console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); - onValueChange(screenId); - onOpenChange(false); - }} - className="flex items-center" - > -
- - - {screen.screen_name} - {screen.screen_code} - -
-
- ))} -
-
-
-
-
- ); + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => { + const selectedScreen = screens.find((s) => s.screen_id === value); + + return ( + + + + + + + + + + {screens.length === 0 ? "화면 목록을 불러오는 중..." : "검색 결과가 없습니다"} + + + {screens.map((screen, index) => ( + { + const screenId = parseInt(selectedValue.split("-")[0]); + console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); + onValueChange(isNaN(screenId) ? undefined : screenId); + onOpenChange(false); + }} + className="flex items-center" + > +
+ + + {screen.screen_name} + {screen.screen_code} + +
+
+ ))} +
+
+
+
+
+ ); + }; // 컬럼 선택 컴포넌트 const ColumnSelect: React.FC<{ @@ -303,20 +388,36 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; placeholder: string; - }> = ({ columns, value, onValueChange, placeholder }) => ( - - ); + }> = ({ columns, value, onValueChange, placeholder }) => { + // 현재 선택된 값의 라벨 찾기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? selectedColumn.column_comment || selectedColumn.column_name + : value || ""; + + return ( + + ); + }; // 표시 컬럼 추가 const addDisplayColumn = (side: "left" | "right") => { @@ -405,30 +506,52 @@ export const SplitPanelLayout2ConfigPanel: React.FC
-
+
{(config.leftPanel?.displayColumns || []).map((col, index) => ( -
+
+
+ 컬럼 {index + 1} + +
updateDisplayColumn("left", index, "name", value)} - placeholder="컬럼" + placeholder="컬럼 선택" /> - updateDisplayColumn("left", index, "label", e.target.value)} - placeholder="라벨" - className="h-9 text-sm flex-1" - /> - +
+ + +
))} + {(config.leftPanel?.displayColumns || []).length === 0 && ( +
+ 표시할 컬럼을 추가하세요 +
+ )}
@@ -440,6 +563,61 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {config.leftPanel?.showSearch && ( +
+
+ + +
+
+ {(config.leftPanel?.searchColumns || []).map((searchCol, index) => ( +
+ { + const current = [...(config.leftPanel?.searchColumns || [])]; + current[index] = { ...current[index], columnName: value }; + updateConfig("leftPanel.searchColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(config.leftPanel?.searchColumns || []).length === 0 && ( +
+ 검색할 컬럼을 추가하세요 +
+ )} +
+
+ )} +
-
+
{(config.rightPanel?.displayColumns || []).map((col, index) => ( -
+
+
+ 컬럼 {index + 1} + +
updateDisplayColumn("right", index, "name", value)} - placeholder="컬럼" + placeholder="컬럼 선택" /> - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨" - className="h-9 text-sm flex-1" - /> - +
+ + +
))} + {(config.rightPanel?.displayColumns || []).length === 0 && ( +
+ 표시할 컬럼을 추가하세요 +
+ )}
@@ -540,6 +740,61 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {config.rightPanel?.showSearch && ( +
+
+ + +
+
+ {(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( +
+ { + const current = [...(config.rightPanel?.searchColumns || [])]; + current[index] = { ...current[index], columnName: value }; + updateConfig("rightPanel.searchColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(config.rightPanel?.searchColumns || []).length === 0 && ( +
+ 검색할 컬럼을 추가하세요 +
+ )} +
+
+ )} +
-

연결 설정 (조인)

+

연결 설정 (조인)

+ + {/* 설명 */} +
+

좌측 패널 선택 시 우측 패널 데이터 필터링

+

좌측에서 항목을 선택하면 좌측 조인 컬럼의 값으로 우측 테이블을 필터링합니다.

+

예: 부서(dept_code) 선택 시 해당 부서의 사원만 표시

+
@@ -604,19 +866,31 @@ export const SplitPanelLayout2ConfigPanel: React.FC
-

데이터 전달 설정

+

데이터 전달 설정

+ {/* 설명 */} +
+

우측 패널 추가 버튼 클릭 시 모달로 데이터 전달

+

좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.

+

예: dept_code를 모달의 dept_code 필드에 자동 입력

+
+
{(config.dataTransferFields || []).map((field, index) => ( -
+
필드 {index + 1} -
@@ -640,6 +914,11 @@ export const SplitPanelLayout2ConfigPanel: React.FC
))} + {(config.dataTransferFields || []).length === 0 && ( +
+ 전달할 필드를 추가하세요 +
+ )}
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index ec0f61b5..a5813600 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -9,6 +9,7 @@ export interface ColumnConfig { name: string; // 컬럼명 label: string; // 표시 라벨 + displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 format?: { @@ -30,6 +31,14 @@ export interface DataTransferField { label?: string; // 표시용 라벨 } +/** + * 검색 컬럼 설정 + */ +export interface SearchColumnConfig { + columnName: string; // 검색 대상 컬럼명 + label?: string; // 표시 라벨 (없으면 컬럼명 사용) +} + /** * 좌측 패널 설정 */ @@ -37,7 +46,8 @@ export interface LeftPanelConfig { title?: string; // 패널 제목 tableName: string; // 테이블명 displayColumns: ColumnConfig[]; // 표시할 컬럼들 - searchColumn?: string; // 검색 대상 컬럼 + searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) + searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 showAddButton?: boolean; // 추가 버튼 표시 addButtonLabel?: string; // 추가 버튼 라벨 @@ -57,7 +67,8 @@ export interface RightPanelConfig { title?: string; // 패널 제목 tableName: string; // 테이블명 displayColumns: ColumnConfig[]; // 표시할 컬럼들 - searchColumn?: string; // 검색 대상 컬럼 + searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) + searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 showAddButton?: boolean; // 추가 버튼 표시 addButtonLabel?: string; // 추가 버튼 라벨 From e9738ce67f4c682d336b2bb23a5414710d0d3ab8 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 18:56:14 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=ED=83=80=EC=9E=85=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/driverController.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts index a448d9c0..dda101e5 100644 --- a/backend-node/src/controllers/driverController.ts +++ b/backend-node/src/controllers/driverController.ts @@ -1,14 +1,15 @@ // 공차중계 운전자 컨트롤러 -import { Request, Response } from "express"; +import { Response } from "express"; import { query } from "../database/db"; import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; export class DriverController { /** * GET /api/driver/profile * 운전자 프로필 조회 */ - static async getProfile(req: Request, res: Response): Promise { + static async getProfile(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; @@ -85,7 +86,7 @@ export class DriverController { * PUT /api/driver/profile * 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종) */ - static async updateProfile(req: Request, res: Response): Promise { + static async updateProfile(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; @@ -183,7 +184,7 @@ export class DriverController { * PUT /api/driver/status * 차량 상태 변경 (대기/정비만 가능) */ - static async updateStatus(req: Request, res: Response): Promise { + static async updateStatus(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; @@ -246,7 +247,7 @@ export class DriverController { * DELETE /api/driver/vehicle * 차량 삭제 (user_id = NULL 처리, 기록 보존) */ - static async deleteVehicle(req: Request, res: Response): Promise { + static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; @@ -303,7 +304,7 @@ export class DriverController { * POST /api/driver/vehicle * 새 차량 등록 */ - static async registerVehicle(req: Request, res: Response): Promise { + static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; const companyCode = req.user?.companyCode; @@ -400,7 +401,7 @@ export class DriverController { * DELETE /api/driver/account * 회원 탈퇴 (차량 정보 포함 삭제) */ - static async deleteAccount(req: Request, res: Response): Promise { + static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; From 4569defecfc2225c720435424372b2342a8c7d7f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 19:05:10 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=ED=97=A4=EB=8D=94=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 101 +++++++++++++++++------ 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 0d23a88e..449a9c49 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -423,28 +423,80 @@ function AppLayoutInner({ children }: AppLayoutProps) { const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); return ( -
- {/* 모바일 사이드바 오버레이 */} - {sidebarOpen && isMobile && ( -
setSidebarOpen(false)} /> +
+ {/* 모바일 헤더 - 모바일에서만 표시 */} + {isMobile && ( +
+
+ {/* 햄버거 메뉴 버튼 */} + setSidebarOpen(!sidebarOpen)} /> + +
+ {/* 사용자 드롭다운 */} + + + + + + +
+

+ {user.userName || "사용자"} +

+

+ {user.deptName || user.email || user.userId} +

+
+
+ + + + 프로필 + + + + 로그아웃 + +
+
+
)} - {/* 왼쪽 사이드바 */} - - {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} -
- {children} -
+ {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} +
+ {children} +
+
{/* 프로필 수정 모달 */} Date: Wed, 3 Dec 2025 19:11:17 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=ED=99=98?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/screens/[screenId]/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 5ce253cb..86362ec8 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -221,6 +221,7 @@ function ScreenViewPage() { // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) if (isMobile) { setScale(1); + setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시 return; } From dbf6cfc995ae7dd7e7c45f141a55e80b6ae8a9c3 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 4 Dec 2025 10:30:15 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=ED=99=95=EC=9D=B8=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 145 +++++++++++++++++- .../widgets/VehicleMapOnlyWidget.tsx | 135 ++++++++++++++++ 2 files changed, 279 insertions(+), 1 deletion(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 9cb2aa39..8170aa11 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false }); const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false }); +const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false }); // 브이월드 API 키 const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; @@ -78,6 +79,13 @@ interface PolygonData { opacity?: number; // 투명도 (0.0 ~ 1.0) } +// 이동경로 타입 +interface RoutePoint { + lat: number; + lng: number; + recordedAt: string; +} + export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); const prevMarkersRef = useRef([]); // 이전 마커 위치 저장 (useRef 사용) @@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); + + // 이동경로 상태 + const [routePoints, setRoutePoints] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [routeLoading, setRouteLoading] = useState(false); + const [routeDate, setRouteDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식 // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { @@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return heading; }, []); + // 이동경로 로드 함수 + const loadRoute = useCallback(async (userId: string, date?: string) => { + if (!userId) { + console.log("🛣️ 이동경로 조회 불가: userId 없음"); + return; + } + + setRouteLoading(true); + setSelectedUserId(userId); + + try { + // 선택한 날짜 기준으로 이동경로 조회 + const targetDate = date || routeDate; + const startOfDay = `${targetDate}T00:00:00.000Z`; + const endOfDay = `${targetDate}T23:59:59.999Z`; + + const query = `SELECT latitude, longitude, recorded_at + FROM vehicle_location_history + WHERE user_id = '${userId}' + AND recorded_at >= '${startOfDay}' + AND recorded_at <= '${endOfDay}' + ORDER BY recorded_at ASC`; + + console.log("🛣️ 이동경로 쿼리:", query); + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const points: RoutePoint[] = result.data.rows.map((row: any) => ({ + lat: parseFloat(row.latitude), + lng: parseFloat(row.longitude), + recordedAt: row.recorded_at, + })); + + console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); + setRoutePoints(points); + } else { + console.log("🛣️ 이동경로 데이터 없음"); + setRoutePoints([]); + } + } + } catch (error) { + console.error("이동경로 로드 실패:", error); + setRoutePoints([]); + } + + setRouteLoading(false); + }, [routeDate]); + + // 이동경로 숨기기 + const clearRoute = useCallback(() => { + setSelectedUserId(null); + setRoutePoints([]); + }, []); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { status: row.status || row.level, description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, - color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 + color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑 }); } else { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) @@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

+ {/* 이동경로 날짜 선택 */} + {selectedUserId && ( +
+ 🛣️ + { + setRouteDate(e.target.value); + if (selectedUserId) { + loadRoute(selectedUserId, e.target.value); + } + }} + className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none" + /> + + ({routePoints.length}개) + + +
+ )}
+ + {/* 이동경로 버튼 */} + {(() => { + try { + const parsed = JSON.parse(marker.description || "{}"); + const userId = parsed.user_id; + if (userId) { + return ( +
+ +
+ ); + } + return null; + } catch { + return null; + } + })()}
); })} + + {/* 이동경로 Polyline */} + {routePoints.length > 1 && ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ + color: "#3b82f6", + weight: 4, + opacity: 0.8, + dashArray: "10, 5", + }} + /> + )} )}
diff --git a/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx index 6e41fdaf..6234c984 100644 --- a/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx +++ b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx @@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false }); const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false }); +const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false }); // 브이월드 API 키 const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; @@ -37,6 +38,16 @@ interface Vehicle { status: "active" | "inactive" | "maintenance" | "warning" | "off"; speed: number; destination: string; + userId?: string; // 이동경로 조회용 + tripId?: string; // 현재 운행 ID +} + +// 이동경로 좌표 +interface RoutePoint { + lat: number; + lng: number; + recordedAt: string; + speed?: number; } interface VehicleMapOnlyWidgetProps { @@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 const [vehicles, setVehicles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [lastUpdate, setLastUpdate] = useState(new Date()); + + // 이동경로 상태 + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [routePoints, setRoutePoints] = useState([]); + const [isRouteLoading, setIsRouteLoading] = useState(false); const loadVehicles = async () => { setIsLoading(true); @@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 : "inactive", speed: parseFloat(row.speed) || 0, destination: row.destination || "대기 중", + userId: row.user_id || row.userId || undefined, + tripId: row.trip_id || row.tripId || undefined, }; }) // 유효한 위도/경도가 있는 차량만 필터링 @@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 setIsLoading(false); }; + // 이동경로 로드 함수 + const loadRoute = async (vehicle: Vehicle) => { + if (!vehicle.userId && !vehicle.tripId) { + console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음"); + return; + } + + setIsRouteLoading(true); + setSelectedVehicle(vehicle); + + try { + // 오늘 날짜 기준으로 최근 이동경로 조회 + const today = new Date(); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString(); + + // trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회 + let query = ""; + if (vehicle.tripId) { + query = `SELECT latitude, longitude, speed, recorded_at + FROM vehicle_location_history + WHERE trip_id = '${vehicle.tripId}' + ORDER BY recorded_at ASC`; + } else if (vehicle.userId) { + query = `SELECT latitude, longitude, speed, recorded_at + FROM vehicle_location_history + WHERE user_id = '${vehicle.userId}' + AND recorded_at >= '${startOfDay}' + ORDER BY recorded_at ASC`; + } + + console.log("🛣️ 이동경로 쿼리:", query); + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const points: RoutePoint[] = result.data.rows.map((row: any) => ({ + lat: parseFloat(row.latitude), + lng: parseFloat(row.longitude), + recordedAt: row.recorded_at, + speed: row.speed ? parseFloat(row.speed) : undefined, + })); + + console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); + setRoutePoints(points); + } else { + console.log("🛣️ 이동경로 데이터 없음"); + setRoutePoints([]); + } + } + } catch (error) { + console.error("이동경로 로드 실패:", error); + setRoutePoints([]); + } + + setIsRouteLoading(false); + }; + + // 이동경로 숨기기 + const clearRoute = () => { + setSelectedVehicle(null); + setRoutePoints([]); + }; + // useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치) useEffect(() => { loadVehicles(); @@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 keepBuffer={2} /> + {/* 이동경로 Polyline */} + {routePoints.length > 1 && ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ + color: "#3b82f6", + weight: 4, + opacity: 0.8, + dashArray: "10, 5", + }} + /> + )} + {/* 차량 마커 */} {vehicles.map((vehicle) => ( @@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
목적지: {vehicle.destination}
+ {/* 이동경로 버튼 */} + {(vehicle.userId || vehicle.tripId) && ( +
+ +
+ )}
@@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
데이터를 연결하세요
)}
+ + {/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */} + {/* {selectedVehicle && routePoints.length > 0 && ( +
+
+
+
🛣️ {selectedVehicle.name} 이동경로
+
{routePoints.length}개 포인트
+
+ +
+
+ )} */}
From 3ab32820e991bcebba12ba815893c5dc002d9d0e Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 10:39:07 +0900 Subject: [PATCH 10/15] =?UTF-8?q?next.js=20=EB=B2=84=EC=A0=84=2015.4.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/registry/DynamicComponentRenderer.tsx | 6 +- .../select-basic/SelectBasicComponent.tsx | 71 +++++++++------- frontend/package-lock.json | 80 +++++++++---------- frontend/package.json | 2 +- 4 files changed, 88 insertions(+), 71 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index c0e0c87e..8609623b 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC = } }; - // 🆕 disabledFields 체크 - const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly; + // 🆕 disabledFields 체크 + readonly 체크 + const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled; + const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly; return ( = placeholder={component.componentConfig?.placeholder || "선택하세요"} required={(component as any).required} disabled={isFieldDisabled} + readonly={isFieldReadonly} className="w-full" /> ); diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index f19fafdc..30eef51b 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -52,6 +52,10 @@ const SelectBasicComponent: React.FC = ({ menuObjid, // 🆕 메뉴 OBJID ...props }) => { + // 🆕 읽기전용/비활성화 상태 확인 + const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false; + const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false; + const isFieldDisabled = isDesignMode || isReadonly || isDisabled; // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); @@ -327,7 +331,7 @@ const SelectBasicComponent: React.FC = ({ // 클릭 이벤트 핸들러 (React Query로 간소화) const handleToggle = () => { - if (isDesignMode) return; + if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가 // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요 setIsOpen(!isOpen); @@ -425,7 +429,7 @@ const SelectBasicComponent: React.FC = ({ value={option.value} checked={selectedValue === option.value} onChange={() => handleOptionSelect(option.value, option.label)} - disabled={isDesignMode} + disabled={isFieldDisabled} className="border-input text-primary focus:ring-ring h-4 w-4" /> {option.label} @@ -456,12 +460,14 @@ const SelectBasicComponent: React.FC = ({ placeholder="코드 또는 코드명 입력..." className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", - !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", + !isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", isSelected && "ring-2 ring-orange-500", + isFieldDisabled && "bg-gray-100 cursor-not-allowed", )} - readOnly={isDesignMode} + readOnly={isFieldDisabled} + disabled={isFieldDisabled} /> - {isOpen && !isDesignMode && filteredOptions.length > 0 && ( + {isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
= ({
{selectedLabel || placeholder} = ({
- {isOpen && !isDesignMode && ( + {isOpen && !isFieldDisabled && (
{isLoadingCodes ? (
로딩 중...
@@ -538,8 +545,9 @@ const SelectBasicComponent: React.FC = ({
{selectedValues.map((val, idx) => { @@ -567,8 +575,9 @@ const SelectBasicComponent: React.FC = ({ type="text" placeholder={selectedValues.length > 0 ? "" : placeholder} className="min-w-[100px] flex-1 border-none bg-transparent outline-none" - onClick={() => setIsOpen(true)} - readOnly={isDesignMode} + onClick={() => !isFieldDisabled && setIsOpen(true)} + readOnly={isFieldDisabled} + disabled={isFieldDisabled} />
@@ -589,19 +598,22 @@ const SelectBasicComponent: React.FC = ({ type="text" value={searchQuery} onChange={(e) => { + if (isFieldDisabled) return; setSearchQuery(e.target.value); setIsOpen(true); }} - onFocus={() => setIsOpen(true)} + onFocus={() => !isFieldDisabled && setIsOpen(true)} placeholder={placeholder} className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", - !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", + !isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200", isSelected && "ring-2 ring-orange-500", + isFieldDisabled && "bg-gray-100 cursor-not-allowed", )} - readOnly={isDesignMode} + readOnly={isFieldDisabled} + disabled={isFieldDisabled} /> - {isOpen && !isDesignMode && filteredOptions.length > 0 && ( + {isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
= ({
{selectedLabel || placeholder} = ({
- {isOpen && !isDesignMode && ( + {isOpen && !isFieldDisabled && (
= ({
!isDesignMode && setIsOpen(true)} + onClick={() => !isFieldDisabled && setIsOpen(true)} style={{ - pointerEvents: isDesignMode ? "none" : "auto", + pointerEvents: isFieldDisabled ? "none" : "auto", height: "100%" }} > @@ -726,7 +740,7 @@ const SelectBasicComponent: React.FC = ({ {placeholder} )}
- {isOpen && !isDesignMode && ( + {isOpen && !isFieldDisabled && (
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
@@ -789,13 +803,14 @@ const SelectBasicComponent: React.FC = ({
{selectedLabel || placeholder} = ({
- {isOpen && !isDesignMode && ( + {isOpen && !isFieldDisabled && (
{isLoadingCodes ? (
로딩 중...
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08f030e2..78d65fbb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -60,7 +60,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", - "next": "15.4.4", + "next": "^15.4.8", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", @@ -1145,9 +1145,9 @@ } }, "node_modules/@next/env": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.4.tgz", - "integrity": "sha512-SJKOOkULKENyHSYXE5+KiFU6itcIb6wSBjgM92meK0HVKpo94dNOLZVdLLuS7/BxImROkGoPsjR4EnuDucqiiA==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz", + "integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1161,9 +1161,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.4.tgz", - "integrity": "sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.8.tgz", + "integrity": "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==", "cpu": [ "arm64" ], @@ -1177,9 +1177,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.4.tgz", - "integrity": "sha512-zqG+/8apsu49CltEj4NAmCGZvHcZbOOOsNoTVeIXphYWIbE4l6A/vuQHyqll0flU2o3dmYCXsBW5FmbrGDgljQ==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.8.tgz", + "integrity": "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==", "cpu": [ "x64" ], @@ -1193,9 +1193,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.4.tgz", - "integrity": "sha512-LRD4l2lq4R+2QCHBQVC0wjxxkLlALGJCwigaJ5FSRSqnje+MRKHljQNZgDCaKUZQzO/TXxlmUdkZP/X3KNGZaw==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.8.tgz", + "integrity": "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==", "cpu": [ "arm64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.4.tgz", - "integrity": "sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.8.tgz", + "integrity": "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==", "cpu": [ "arm64" ], @@ -1225,9 +1225,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.4.tgz", - "integrity": "sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.8.tgz", + "integrity": "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==", "cpu": [ "x64" ], @@ -1241,9 +1241,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.4.tgz", - "integrity": "sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.8.tgz", + "integrity": "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==", "cpu": [ "x64" ], @@ -1257,9 +1257,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.4.tgz", - "integrity": "sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.8.tgz", + "integrity": "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==", "cpu": [ "arm64" ], @@ -1273,9 +1273,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.4.tgz", - "integrity": "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.8.tgz", + "integrity": "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==", "cpu": [ "x64" ], @@ -10876,12 +10876,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.4.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.4.4.tgz", - "integrity": "sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==", + "version": "15.4.8", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz", + "integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==", "license": "MIT", "dependencies": { - "@next/env": "15.4.4", + "@next/env": "15.4.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -10894,14 +10894,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.4.4", - "@next/swc-darwin-x64": "15.4.4", - "@next/swc-linux-arm64-gnu": "15.4.4", - "@next/swc-linux-arm64-musl": "15.4.4", - "@next/swc-linux-x64-gnu": "15.4.4", - "@next/swc-linux-x64-musl": "15.4.4", - "@next/swc-win32-arm64-msvc": "15.4.4", - "@next/swc-win32-x64-msvc": "15.4.4", + "@next/swc-darwin-arm64": "15.4.8", + "@next/swc-darwin-x64": "15.4.8", + "@next/swc-linux-arm64-gnu": "15.4.8", + "@next/swc-linux-arm64-musl": "15.4.8", + "@next/swc-linux-x64-gnu": "15.4.8", + "@next/swc-linux-x64-musl": "15.4.8", + "@next/swc-win32-arm64-msvc": "15.4.8", + "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/frontend/package.json b/frontend/package.json index 6d4f3369..8241df53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,7 +68,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", - "next": "15.4.4", + "next": "^15.4.8", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", From 532c56f9977af2176b3206eca5626ea6c440b504 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 4 Dec 2025 10:46:37 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=EC=B0=A8=EB=9F=89=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=95=88=EB=92=A4=EC=A7=91=ED=9E=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/widgets/MapTestWidgetV2.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 8170aa11..02cafe2b 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -1409,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 트럭 마커 // 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요 const rotation = heading - 90; + + // 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로) + // 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함 + const normalizedRotation = ((rotation % 360) + 360) % 360; + const isFlipped = normalizedRotation > 90 && normalizedRotation < 270; + const transformStyle = isFlipped + ? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)` + : `translate(-50%, -50%) rotate(${rotation}deg)`; markerIcon = L.divIcon({ className: "custom-truck-marker", @@ -1419,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { display: flex; align-items: center; justify-content: center; - transform: translate(-50%, -50%) rotate(${rotation}deg); + transform: ${transformStyle}; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); "> From 127f4dc783d035dc94285c80289235fb54325e72 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 13:37:17 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=EC=88=AB=EC=9E=90=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B2=9C=EB=8B=A8=EC=9C=84=20=EA=B5=AC=EB=B6=84=EC=9E=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 25 ++- .../table-list/TableListConfigPanel.tsx | 179 ++++++++++-------- .../registry/components/table-list/types.ts | 3 + 3 files changed, 124 insertions(+), 83 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 261fa108..7f6fded5 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1906,12 +1906,16 @@ export const TableListComponent: React.FC = ({ return "-"; } - // 숫자 타입 포맷팅 + // 숫자 타입 포맷팅 (천단위 구분자 설정 확인) if (inputType === "number" || inputType === "decimal") { if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { - return numValue.toLocaleString("ko-KR"); + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return numValue.toLocaleString("ko-KR"); + } + return String(numValue); } } return String(value); @@ -1922,7 +1926,11 @@ export const TableListComponent: React.FC = ({ if (value !== null && value !== undefined && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { - return numValue.toLocaleString("ko-KR"); + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return numValue.toLocaleString("ko-KR"); + } + return String(numValue); } } return String(value); @@ -1939,10 +1947,15 @@ export const TableListComponent: React.FC = ({ } } return "-"; - case "number": - return typeof value === "number" ? value.toLocaleString() : value; case "currency": - return typeof value === "number" ? `₩${value.toLocaleString()}` : value; + if (typeof value === "number") { + // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용 + if (column.thousandSeparator !== false) { + return `₩${value.toLocaleString()}`; + } + return `₩${value}`; + } + return value; case "boolean": return value ? "예" : "아니오"; default: diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 9de2f6d8..6e6c414f 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -1074,86 +1074,111 @@ export const TableListConfigPanel: React.FC = ({ {/* 간결한 리스트 형식 컬럼 설정 */}
- {config.columns?.map((column, index) => ( -
- {/* 컬럼명 */} - - {availableColumns.find((col) => col.columnName === column.columnName)?.label || - column.displayName || - column.columnName} - + {config.columns?.map((column, index) => { + // 해당 컬럼의 input_type 확인 + const columnInfo = availableColumns.find((col) => col.columnName === column.columnName); + const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal"; + + return ( +
+
+ {/* 컬럼명 */} + + {columnInfo?.label || column.displayName || column.columnName} + + + {/* 숫자 타입인 경우 천단위 구분자 설정 */} + {isNumberType && ( +
+ { + updateColumn(column.columnName, { thousandSeparator: checked as boolean }); + }} + className="h-3 w-3" + /> + +
+ )} +
- {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} -
- f.columnName === column.columnName) || false} - onCheckedChange={(checked) => { - const currentFilters = config.filter?.filters || []; - const columnLabel = - availableColumns.find((col) => col.columnName === column.columnName)?.label || - column.displayName || - column.columnName; + {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */} +
+ f.columnName === column.columnName) || false} + onCheckedChange={(checked) => { + const currentFilters = config.filter?.filters || []; + const columnLabel = + columnInfo?.label || column.displayName || column.columnName; - if (checked) { - // 필터 추가 - handleChange("filter", { - ...config.filter, - enabled: true, - filters: [ - ...currentFilters, - { - columnName: column.columnName, - label: columnLabel, - type: "text", - }, - ], - }); - } else { - // 필터 제거 - handleChange("filter", { - ...config.filter, - filters: currentFilters.filter((f) => f.columnName !== column.columnName), - }); - } - }} - className="h-3 w-3" - /> + if (checked) { + // 필터 추가 + handleChange("filter", { + ...config.filter, + enabled: true, + filters: [ + ...currentFilters, + { + columnName: column.columnName, + label: columnLabel, + type: "text", + }, + ], + }); + } else { + // 필터 제거 + handleChange("filter", { + ...config.filter, + filters: currentFilters.filter((f) => f.columnName !== column.columnName), + }); + } + }} + className="h-3 w-3" + /> +
+ + {/* 순서 변경 + 삭제 버튼 */} +
+ + + +
- - {/* 순서 변경 + 삭제 버튼 */} -
- - - -
-
- ))} + ); + })}
)} diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 0322926b..b69b9238 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -59,6 +59,9 @@ export interface ColumnConfig { isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부 entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보 + // 숫자 포맷팅 설정 + thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true) + // 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정) entityDisplayConfig?: { displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블) From 2cddb4225520e99f999eb0ca7950f00d2b667408 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 14:30:52 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 63 ++-- .../table-list/TableListConfigPanel.tsx | 326 +++++++++--------- 2 files changed, 198 insertions(+), 191 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 7f6fded5..6a1f01fe 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1183,13 +1183,20 @@ export const TableListComponent: React.FC = ({ referenceTable: col.additionalJoinInfo!.referenceTable, })); - // console.log("🔍 [TableList] API 호출 시작", { - // tableName: tableConfig.selectedTable, - // page, - // pageSize, - // sortBy, - // sortOrder, - // }); + // 🎯 화면별 엔티티 표시 설정 수집 + 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, + }; + }); + + console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { @@ -1200,6 +1207,7 @@ export const TableListComponent: React.FC = ({ search: hasFilters ? filters : undefined, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 }); @@ -1756,33 +1764,46 @@ export const TableListComponent: React.FC = ({ const formatCellValue = useCallback( (value: any, column: ColumnConfig, rowData?: Record) => { - if (value === null || value === undefined) return "-"; - - // 🎯 writer 컬럼 자동 변환: user_id -> user_name - if (column.columnName === "writer" && rowData && rowData.writer_name) { - return rowData.writer_name; - } - - // 🎯 엔티티 컬럼 표시 설정이 있는 경우 + // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 + // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) if (column.entityDisplayConfig && rowData) { - // displayColumns 또는 selectedColumns 둘 다 체크 - const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns; + 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) => { - const cellValue = rowData[colName]; + .map((colName: string) => { + // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우) + let cellValue = rowData[colName]; + + // 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우) + if (cellValue === null || cellValue === undefined) { + const joinedKey = `${column.columnName}_${colName}`; + cellValue = rowData[joinedKey]; + } + if (cellValue === null || cellValue === undefined) return ""; return String(cellValue); }) - .filter((v) => v !== ""); // 빈 값 제외 + .filter((v: string) => v !== ""); // 빈 값 제외 - return values.join(separator || " - "); + 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]; // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 6e6c414f..823424cc 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -467,42 +467,22 @@ export const TableListConfigPanel: React.FC = ({ // 🎯 엔티티 컬럼의 표시 컬럼 정보 로드 const loadEntityDisplayConfig = async (column: ColumnConfig) => { - if (!column.isEntityJoin || !column.entityJoinInfo) { - return; - } + const configKey = `${column.columnName}`; + + // 이미 로드된 경우 스킵 + if (entityDisplayConfigs[configKey]) return; - // entityDisplayConfig가 없으면 초기화 - if (!column.entityDisplayConfig) { - // sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서 - const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName; - - if (!initialSourceTable) { - return; - } - - const updatedColumns = config.columns?.map((col) => { - if (col.columnName === column.columnName) { - return { - ...col, - entityDisplayConfig: { - displayColumns: [], - separator: " - ", - sourceTable: initialSourceTable, - joinTable: "", - }, - }; - } - return col; - }); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - // 업데이트된 컬럼으로 다시 시도 - const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName); - if (updatedColumn) { - return loadEntityDisplayConfig(updatedColumn); - } - } + if (!column.isEntityJoin) { + // 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제 + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: [], + separator: " - ", + }, + })); return; } @@ -512,32 +492,56 @@ export const TableListConfigPanel: React.FC = ({ // 3. config.selectedTable // 4. screenTableName const sourceTable = - column.entityDisplayConfig.sourceTable || + column.entityDisplayConfig?.sourceTable || column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName; - let joinTable = column.entityDisplayConfig.joinTable; - - // sourceTable이 여전히 비어있으면 에러 + // sourceTable이 비어있으면 빈 상태로 설정 if (!sourceTable) { + console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName); + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: column.entityDisplayConfig?.displayColumns || [], + separator: column.entityDisplayConfig?.separator || " - ", + }, + })); return; } - if (!joinTable && sourceTable) { - // joinTable이 없으면 tableTypeApi로 조회해서 설정 + 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 || col.columnName) === 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 || " - ", }; // 컬럼 설정 업데이트 @@ -553,74 +557,27 @@ export const TableListConfigPanel: React.FC = ({ } } catch (error) { console.error("tableTypeApi 컬럼 정보 조회 실패:", error); - console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName }); } - } else if (!joinTable) { - console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName); } console.log("🔍 최종 추출한 값:", { sourceTable, joinTable }); - const configKey = `${column.columnName}`; - - // 이미 로드된 경우 스킵 - if (entityDisplayConfigs[configKey]) return; - - // joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기 - let actualJoinTable = joinTable; - if (!actualJoinTable && sourceTable) { - try { - console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", { - tableName: sourceTable, - columnName: column.columnName, - }); - - const columnList = await tableTypeApi.getColumns(sourceTable); - const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName); - - console.log("🔍 컬럼 정보 조회 결과:", { - columnInfo: columnInfo, - referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable, - referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn, - }); - - if (columnInfo?.reference_table || columnInfo?.referenceTable) { - actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable; - console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable); - - // entityDisplayConfig 업데이트 - const updatedConfig = { - ...column.entityDisplayConfig, - joinTable: actualJoinTable, - }; - - // 컬럼 설정 업데이트 - const updatedColumns = config.columns?.map((col) => - col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col, - ); - - if (updatedColumns) { - handleChange("columns", updatedColumns); - } - } - } catch (error) { - console.error("tableTypeApi 컬럼 정보 조회 실패:", error); - } - } - - // sourceTable과 joinTable이 모두 있어야 로드 - if (!sourceTable || !actualJoinTable) { - return; - } try { - // 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드 - const [sourceResult, joinResult] = await Promise.all([ - entityJoinApi.getReferenceTableColumns(sourceTable), - entityJoinApi.getReferenceTableColumns(actualJoinTable), - ]); - + // 기본 테이블 컬럼 정보는 항상 로드 + const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable); const sourceColumns = sourceResult.columns || []; - const joinColumns = joinResult.columns || []; + + // joinTable이 있으면 조인 테이블 컬럼도 로드 + let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = []; + if (joinTable) { + try { + const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable); + joinColumns = joinResult.columns || []; + } catch (joinError) { + console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError); + // 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시 + } + } setEntityDisplayConfigs((prev) => ({ ...prev, @@ -633,6 +590,16 @@ export const TableListConfigPanel: React.FC = ({ })); } catch (error) { console.error("엔티티 표시 컬럼 정보 로드 실패:", error); + // 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제 + setEntityDisplayConfigs((prev) => ({ + ...prev, + [configKey]: { + sourceColumns: [], + joinColumns: [], + selectedColumns: column.entityDisplayConfig?.displayColumns || [], + separator: column.entityDisplayConfig?.separator || " - ", + }, + })); } }; @@ -873,76 +840,95 @@ export const TableListConfigPanel: React.FC = ({ {/* 표시 컬럼 선택 (다중 선택) */}
- - - - - - - - - 컬럼을 찾을 수 없습니다. - {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.columnName)} - className="text-xs" - > - - {col.displayName} - - ))} - - )} - {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( - - {entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( - toggleEntityDisplayColumn(column.columnName, col.columnName)} - className="text-xs" - > - - {col.displayName} - - ))} - - )} - - - - + {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.columnName)} + className="text-xs" + > + + {col.displayName} + + ))} + + )} + {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( + + {entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( + toggleEntityDisplayColumn(column.columnName, col.columnName)} + className="text-xs" + > + + {col.displayName} + + ))} + + )} + + + + + )}
+ {/* 참조 테이블 미설정 안내 */} + {!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( +
+ 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다. +
+ )} + {/* 선택된 컬럼 미리보기 */} {entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
From 93d99373434f3244214c3f2b214e5184f12f2749 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 16:02:00 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=8B=A4=EC=A4=91=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AutocompleteSearchInputComponent.tsx | 22 ++- .../AutocompleteSearchInputConfigPanel.tsx | 156 +++++++++++++----- .../autocomplete-search-input/types.ts | 3 + 3 files changed, 134 insertions(+), 47 deletions(-) diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 1c5920f0..7a115ea3 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({ // config prop 우선, 없으면 개별 prop 사용 const tableName = config?.tableName || propTableName || ""; const displayField = config?.displayField || propDisplayField || ""; + const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드 + const displaySeparator = config?.displaySeparator || " → "; // 구분자 const valueField = config?.valueField || propValueField || ""; - const searchFields = config?.searchFields || propSearchFields || [displayField]; + const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용 const placeholder = config?.placeholder || propPlaceholder || "검색..."; + // 다중 필드 값을 조합하여 표시 문자열 생성 + const getDisplayValue = (item: EntitySearchResult): string => { + if (displayFields.length > 1) { + // 여러 필드를 구분자로 조합 + const values = displayFields + .map((field) => item[field]) + .filter((v) => v !== null && v !== undefined && v !== "") + .map((v) => String(v)); + return values.join(displaySeparator); + } + // 단일 필드 + return item[displayField] || ""; + }; + const [inputValue, setInputValue] = useState(""); const [isOpen, setIsOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); @@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({ const handleSelect = (item: EntitySearchResult) => { setSelectedData(item); - setInputValue(item[displayField] || ""); + setInputValue(getDisplayValue(item)); console.log("🔍 AutocompleteSearchInput handleSelect:", { item, @@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({ onClick={() => handleSelect(item)} className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm" > -
{item[displayField]}
+
{getDisplayValue(item)}
))}
diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index d2290c2f..bb0b8175 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({
- {/* 2. 표시 필드 선택 */} + {/* 2. 표시 필드 선택 (다중 선택 가능) */}
- - - - - - - - - - 필드를 찾을 수 없습니다. - - {sourceTableColumns.map((column) => ( - { - updateConfig({ displayField: column.columnName }); - setOpenDisplayFieldCombo(false); + +
+ {/* 선택된 필드 표시 */} + {(localConfig.displayFields && localConfig.displayFields.length > 0) ? ( +
+ {localConfig.displayFields.map((fieldName) => { + const col = sourceTableColumns.find((c) => c.columnName === fieldName); + return ( + + {col?.displayName || fieldName} + + + ); + })} +
+ ) : ( +
+ 아래에서 표시할 필드를 선택하세요 +
+ )} + + {/* 필드 선택 드롭다운 */} + + + + + + + + + 필드를 찾을 수 없습니다. + + {sourceTableColumns.map((column) => { + const isSelected = localConfig.displayFields?.includes(column.columnName); + return ( + { + const currentFields = localConfig.displayFields || []; + let newFields: string[]; + if (isSelected) { + newFields = currentFields.filter((f) => f !== column.columnName); + } else { + newFields = [...currentFields, column.columnName]; + } + updateConfig({ + displayFields: newFields, + displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로 + }); + }} + className="text-xs sm:text-sm" + > + +
+ {column.displayName || column.columnName} + {column.displayName && {column.columnName}} +
+
+ ); + })} +
+
+
+
+
+ + {/* 구분자 설정 */} + {localConfig.displayFields && localConfig.displayFields.length > 1 && ( +
+ + updateConfig({ displaySeparator: e.target.value })} + placeholder=" → " + className="h-7 w-20 text-xs text-center" + /> + + 미리보기: {localConfig.displayFields.map((f) => { + const col = sourceTableColumns.find((c) => c.columnName === f); + return col?.displayName || f; + }).join(localConfig.displaySeparator || " → ")} + +
+ )} +
{/* 3. 저장 대상 테이블 선택 */} @@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({ 외부 테이블: {localConfig.tableName}

- 표시 필드: {localConfig.displayField} + 표시 필드: {localConfig.displayFields?.length + ? localConfig.displayFields.join(localConfig.displaySeparator || " → ") + : localConfig.displayField}

저장 테이블: {localConfig.targetTable} diff --git a/frontend/lib/registry/components/autocomplete-search-input/types.ts b/frontend/lib/registry/components/autocomplete-search-input/types.ts index 85101e89..ea1c3734 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/types.ts +++ b/frontend/lib/registry/components/autocomplete-search-input/types.ts @@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig { fieldMappings?: FieldMapping[]; // 매핑할 필드 목록 // 저장 대상 테이블 (간소화 버전) targetTable?: string; + // 🆕 다중 표시 필드 설정 (여러 컬럼 조합) + displayFields?: string[]; // 여러 컬럼을 조합하여 표시 + displaySeparator?: string; // 구분자 (기본값: " - ") } From bc66f3bba1b92c62e5de44f46cee48547ca58dd7 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 4 Dec 2025 18:26:35 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/entityJoinController.ts | 15 + .../src/services/tableManagementService.ts | 46 +++ frontend/lib/api/entityJoin.ts | 9 + .../button-primary/ButtonPrimaryComponent.tsx | 26 +- .../SplitPanelLayoutComponent.tsx | 112 ++++-- .../table-list/TableListComponent.tsx | 56 +++ .../table-list/TableListConfigPanel.tsx | 335 ++++++++++++++++++ .../registry/components/table-list/types.ts | 18 + frontend/lib/utils/buttonActions.ts | 19 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 12 files changed, 600 insertions(+), 39 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 66e20ccd..00727f1d 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -29,6 +29,7 @@ export class EntityJoinController { screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) + excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -125,6 +126,19 @@ export class EntityJoinController { } } + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + let parsedExcludeFilter: any = undefined; + if (excludeFilter) { + try { + parsedExcludeFilter = + typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter; + logger.info("제외 필터 파싱 완료:", parsedExcludeFilter); + } catch (error) { + logger.warn("제외 필터 파싱 오류:", error); + parsedExcludeFilter = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -141,6 +155,7 @@ export class EntityJoinController { additionalJoinColumns: parsedAdditionalJoinColumns, screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 + excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 } ); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 8e01903b..781a9498 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2462,6 +2462,14 @@ export class TableManagementService { }>; screenEntityConfigs?: Record; // 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } ): Promise { const startTime = Date.now(); @@ -2716,6 +2724,44 @@ export class TableManagementService { } } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) + if (options.excludeFilter && options.excludeFilter.enabled) { + const { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + } = options.excludeFilter; + + if (referenceTable && referenceColumn && sourceColumn) { + // 서브쿼리로 이미 존재하는 데이터 제외 + let excludeSubquery = `main."${sourceColumn}" NOT IN ( + SELECT "${referenceColumn}" FROM "${referenceTable}" + WHERE "${referenceColumn}" IS NOT NULL`; + + // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) + if (filterColumn && filterValue !== undefined && filterValue !== null) { + excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; + } + + excludeSubquery += ")"; + + whereClause = whereClause + ? `${whereClause} AND ${excludeSubquery}` + : excludeSubquery; + + logger.info(`🚫 제외 필터 적용 (Entity 조인):`, { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + excludeSubquery, + }); + } + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index a84f3355..a3206df9 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -69,6 +69,14 @@ export const entityJoinApi = { }>; screenEntityConfigs?: Record; // 🎯 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } = {}, ): Promise => { // 🔒 멀티테넌시: company_code 자동 필터링 활성화 @@ -90,6 +98,7 @@ export const entityJoinApi = { screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 + excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 }, }); return response.data.data; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 0bf8bea2..5816940a 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) + let effectiveSelectedRowsData = selectedRowsData; + if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) { + try { + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + const modalData = dataRegistry[effectiveTableName]; + if (modalData && modalData.length > 0) { + effectiveSelectedRowsData = modalData; + console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { + tableName: effectiveTableName, + count: modalData.length, + data: modalData, + }); + } + } catch (error) { + console.warn("modalDataStore 접근 실패:", error); + } + } + // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = - (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); + (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { toast.warning("삭제할 항목을 먼저 선택해주세요."); @@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC = ({ onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) - // 테이블 선택된 행 정보 추가 + // 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선) selectedRows, - selectedRowsData, + selectedRowsData: effectiveSelectedRowsData, // 테이블 정렬 정보 추가 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index fdaddfc3..ac44eded 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC ) => { if (value === null || value === undefined) return "-"; - // 카테고리 매핑이 있는지 확인 - const mapping = categoryMappings[columnName]; + // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도) + // 1. 전체 컬럼명 (예: "item_info.material") + // 2. 컬럼명만 (예: "material") + let mapping = categoryMappings[columnName]; + + if (!mapping && columnName.includes(".")) { + // 조인된 컬럼의 경우 컬럼명만으로 다시 시도 + const simpleColumnName = columnName.split(".").pop() || columnName; + mapping = categoryMappings[simpleColumnName]; + } + if (mapping && mapping[String(value)]) { const categoryData = mapping[String(value)]; const displayLabel = categoryData.label || String(value); @@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC loadLeftCategoryMappings(); }, [componentConfig.leftPanel?.tableName, isDesignMode]); - // 우측 테이블 카테고리 매핑 로드 + // 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함) useEffect(() => { const loadRightCategoryMappings = async () => { const rightTableName = componentConfig.rightPanel?.tableName; if (!rightTableName || isDesignMode) return; try { - // 1. 컬럼 메타 정보 조회 - const columnsResponse = await tableTypeApi.getColumns(rightTableName); - const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); - - if (categoryColumns.length === 0) { - setRightCategoryMappings({}); - return; - } - - // 2. 각 카테고리 컬럼에 대한 값 조회 const mappings: Record> = {}; - for (const col of categoryColumns) { - const columnName = col.columnName || col.column_name; - try { - const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`); + // 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출 + const rightColumns = componentConfig.rightPanel?.columns || []; + const tablesToLoad = new Set([rightTableName]); + + // 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info") + rightColumns.forEach((col: any) => { + const colName = col.name || col.columnName; + if (colName && colName.includes(".")) { + const joinTableName = colName.split(".")[0]; + tablesToLoad.add(joinTableName); + } + }); - if (response.data.success && response.data.data) { - const valueMap: Record = {}; - response.data.data.forEach((item: any) => { - valueMap[item.value_code || item.valueCode] = { - label: item.value_label || item.valueLabel, - color: item.color, - }; - }); - mappings[columnName] = valueMap; - console.log(`✅ 우측 카테고리 매핑 로드 [${columnName}]:`, valueMap); + console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); + + // 각 테이블에 대해 카테고리 매핑 로드 + for (const tableName of tablesToLoad) { + try { + // 1. 컬럼 메타 정보 조회 + const columnsResponse = await tableTypeApi.getColumns(tableName); + const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category"); + + // 2. 각 카테고리 컬럼에 대한 값 조회 + for (const col of categoryColumns) { + const columnName = col.columnName || col.column_name; + try { + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + + if (response.data.success && response.data.data) { + const valueMap: Record = {}; + response.data.data.forEach((item: any) => { + valueMap[item.value_code || item.valueCode] = { + label: item.value_label || item.valueLabel, + color: item.color, + }; + }); + + // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장 + const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`; + mappings[mappingKey] = valueMap; + + // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) + // 기존 매핑이 있으면 병합, 없으면 새로 생성 + mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; + + console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); + console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); + } + } catch (error) { + console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); + } } } catch (error) { - console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error); + console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error); } } @@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC }; loadRightCategoryMappings(); - }, [componentConfig.rightPanel?.tableName, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]); // 항목 펼치기/접기 토글 const toggleExpand = useCallback((itemId: any) => { @@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC const format = colConfig?.format; const boldValue = colConfig?.bold ?? false; - // 숫자 포맷 적용 - let displayValue = String(value || "-"); - if (value !== null && value !== undefined && value !== "" && format) { + // 🆕 카테고리 매핑 적용 + const formattedValue = formatCellValue(key, value, rightCategoryMappings); + + // 숫자 포맷 적용 (카테고리가 아닌 경우만) + let displayValue: React.ReactNode = formattedValue; + if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) { const numValue = typeof value === 'number' ? value : parseFloat(String(value)); if (!isNaN(numValue)) { displayValue = numValue.toLocaleString('ko-KR', { @@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC )} {displayValue} @@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC const colConfig = rightColumns?.find(c => c.name === key); const format = colConfig?.format; - // 숫자 포맷 적용 - let displayValue = String(value); - if (value !== null && value !== undefined && value !== "" && format) { + // 🆕 카테고리 매핑 적용 + const formattedValue = formatCellValue(key, value, rightCategoryMappings); + + // 숫자 포맷 적용 (카테고리가 아닌 경우만) + let displayValue: React.ReactNode = formattedValue; + if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) { const numValue = typeof value === 'number' ? value : parseFloat(String(value)); if (!isNaN(numValue)) { displayValue = numValue.toLocaleString('ko-KR', { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 6a1f01fe..4f78ed23 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -179,6 +179,7 @@ export const TableListComponent: React.FC = ({ config, className, style, + formData: propFormData, // 🆕 부모에서 전달받은 formData onFormDataChange, componentConfig, onSelectedRowsChange, @@ -1198,6 +1199,60 @@ export const TableListComponent: React.FC = ({ console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + 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?.selectedLeftData) { + filterValue = splitPanelContext.selectedLeftData[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, @@ -1209,6 +1264,7 @@ export const TableListComponent: React.FC = ({ additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 }); // 실제 데이터의 item_number만 추출하여 중복 확인 diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 823424cc..209b3d2d 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { TableListConfig, ColumnConfig } from "./types"; 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 } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC = ({ 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 변경 감지:", { @@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC = ({ fetchEntityJoinColumns(); }, [config.selectedTable, screenTableName]); + // 🆕 제외 필터용 참조 테이블 컬럼 가져오기 + 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.columnName || col.column_name, + dataType: col.dataType || col.data_type || "text", + label: col.displayName || col.columnLabel || col.column_label || col.columnName || 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); @@ -1333,6 +1376,298 @@ export const TableListConfigPanel: React.FC = ({

+ + {/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */} +
+
+

제외 필터

+

+ 다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다 +

+
+
+ + {/* 제외 필터 활성화 */} +
+ { + handleChange("excludeFilter", { + ...config.excludeFilter, + enabled: checked as boolean, + }); + }} + /> + +
+ + {config.excludeFilter?.enabled && ( +
+ {/* 참조 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceTable: table.tableName, + referenceColumn: undefined, + sourceColumn: undefined, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +
+ + {config.excludeFilter?.referenceTable && ( + <> + {/* 비교 컬럼 설정 - 한 줄에 두 개 */} +
+ {/* 참조 컬럼 (매핑 테이블) */} +
+ + + + + + + + + + 없음 + + {referenceTableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + referenceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+ + {/* 소스 컬럼 (현재 테이블) */} +
+ + + + + + + + + + 없음 + + {availableColumns.map((col) => ( + { + handleChange("excludeFilter", { + ...config.excludeFilter, + sourceColumn: col.columnName, + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + +
+
+ + {/* 조건 필터 - 특정 조건의 데이터만 제외 */} +
+ +

+ 특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만) +

+
+ {/* 필터 컬럼 (매핑 테이블) */} + + + + + + + + + 없음 + + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: undefined, + filterValueField: undefined, + }); + }} + className="text-xs text-muted-foreground" + > + + 사용 안함 + + {referenceTableColumns.map((col) => ( + { + // 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정 + handleChange("excludeFilter", { + ...config.excludeFilter, + filterColumn: col.columnName, + filterValueField: col.columnName, // 같은 이름으로 자동 설정 + filterValueSource: "url", + }); + }} + className="text-xs" + > + + {col.label || col.columnName} + + ))} + + + + + + + {/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */} + { + handleChange("excludeFilter", { + ...config.excludeFilter, + filterValueField: e.target.value, + }); + }} + disabled={!config.excludeFilter?.filterColumn} + className="h-8 text-xs" + /> +
+
+ + )} + + {/* 설정 요약 */} + {config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && ( +
+ 설정 요약: {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가 + {" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에 + {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && ( + <> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때) + )} + {" "}이미 있으면 제외 +
+ )} +
+ )} +
); diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index b69b9238..2475f58f 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -185,6 +185,21 @@ export interface LinkedFilterConfig { 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 컴포넌트 설정 타입 */ @@ -249,6 +264,9 @@ export interface TableListConfig extends ComponentConfig { // 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링) linkedFilters?: LinkedFilterConfig[]; + // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + excludeFilter?: ExcludeFilterConfig; + // 이벤트 핸들러 onRowClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 522f8651..22e491f6 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1236,8 +1236,13 @@ export class ButtonActionExecutor { } else { console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출"); context.onRefresh?.(); // 테이블 새로고침 + + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log("🔄 refreshTable 전역 이벤트 발생"); } + toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`); return true; } @@ -1258,6 +1263,12 @@ export class ButtonActionExecutor { } context.onRefresh?.(); + + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)"); + + toast.success(config.successMessage || "삭제되었습니다."); return true; } catch (error) { console.error("삭제 오류:", error); @@ -1536,6 +1547,13 @@ export class ButtonActionExecutor { } } + // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) + const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; + console.log("📦 [openModalWithData] 부모 데이터 전달:", { + dataSourceId, + parentData, + }); + // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) const modalEvent = new CustomEvent("openScreenModal", { detail: { @@ -1544,6 +1562,7 @@ export class ButtonActionExecutor { description: description, size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) + splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용) }, }); diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index f8f84f1f..74d9d0ed 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1679,3 +1679,4 @@ const 출고등록_설정: ScreenSplitPanel = { 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 2a3e16de..47526bb1 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -526,3 +526,4 @@ const { data: config } = await getScreenSplitPanel(screenId); 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 7334e8b3..135d36d8 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -513,3 +513,4 @@ function ScreenViewPage() { 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. +