From a55115ac486b5dba1b2cf873ad03ea6a958059b8 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 09:25:21 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EC=97=91=EC=85=80=20=EC=97=B4=EC=A7=A4?= =?UTF-8?q?=EB=A0=A4=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=EA=B1=B0=EB=9E=91=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=8B=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=91=90=EA=B0=9C=EC=9D=B4=EC=83=81=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=82=98=EC=98=A4=EB=8D=98?= =?UTF-8?q?=EA=B1=B0=20=EC=88=98=EC=A0=95=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 40 ++++++++++++++----- frontend/lib/utils/buttonActions.ts | 30 ++++++++++++-- frontend/lib/utils/excelExport.ts | 6 ++- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 366aa05b..9793acd8 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -2688,19 +2688,41 @@ export const TableListComponent: React.FC = ({ const value = row[mappedColumnName]; // 카테고리 매핑된 값 처리 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) { - return mapping.label; + if (value !== null && value !== undefined) { + const valueStr = String(value); + + // 디버그 로그 (카테고리 값인 경우만) + if (valueStr.startsWith("CATEGORY_")) { + console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", { + columnName: col.columnName, + value: valueStr, + hasMappings: !!categoryMappings[col.columnName], + mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [], + }); } + + if (categoryMappings[col.columnName]) { + // 쉼표로 구분된 중복 값 처리 + if (valueStr.includes(",")) { + const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v); + const labels = values.map((v) => { + const mapping = categoryMappings[col.columnName][v]; + return mapping ? mapping.label : v; + }); + return labels.join(", "); + } + // 단일 값 처리 + const mapping = categoryMappings[col.columnName][valueStr]; + if (mapping) { + return mapping.label; + } + } + + return value; } // null/undefined 처리 - if (value === null || value === undefined) { - return ""; - } - - return value; + return ""; }); }); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index debf58b2..6c69ad10 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4737,7 +4737,24 @@ export class ButtonActionExecutor { const filteredRow: Record = {}; visibleColumns!.forEach((columnName: string) => { const label = columnLabels?.[columnName] || columnName; - filteredRow[label] = row[columnName]; + let value = row[columnName]; + + // 카테고리 코드를 라벨로 변환 (CATEGORY_로 시작하는 값) + if (value && typeof value === "string" && value.includes("CATEGORY_")) { + // 먼저 _label 필드 확인 (API에서 제공하는 경우) + const labelFieldName = `${columnName}_label`; + if (row[labelFieldName]) { + value = row[labelFieldName]; + } else { + // _value_label 필드 확인 + const valueLabelFieldName = `${columnName}_value_label`; + if (row[valueLabelFieldName]) { + value = row[valueLabelFieldName]; + } + } + } + + filteredRow[label] = value; }); return filteredRow; }); @@ -5010,8 +5027,15 @@ export class ButtonActionExecutor { value = row[`${columnName}_name`]; } // 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만) - else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) { - value = categoryMap[columnName][value]; + else if (categoryMap[columnName] && typeof value === "string") { + // 쉼표로 구분된 다중 값 처리 + if (value.includes(",")) { + const values = value.split(",").map((v) => v.trim()).filter((v) => v); + const labels = values.map((v) => categoryMap[columnName][v] || v); + value = labels.join(", "); + } else if (categoryMap[columnName][value]) { + value = categoryMap[columnName][value]; + } } filteredRow[label] = value; diff --git a/frontend/lib/utils/excelExport.ts b/frontend/lib/utils/excelExport.ts index 52c22f5a..6bd97624 100644 --- a/frontend/lib/utils/excelExport.ts +++ b/frontend/lib/utils/excelExport.ts @@ -116,8 +116,10 @@ export async function importFromExcel( return; } - // JSON으로 변환 - const jsonData = XLSX.utils.sheet_to_json(worksheet); + // JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지) + const jsonData = XLSX.utils.sheet_to_json(worksheet, { + defval: "", // 빈 셀에 빈 문자열 할당 + }); console.log("✅ 엑셀 가져오기 완료:", { sheetName: targetSheetName, From 6a0aa87d3b7c6eeb356ae63453ecdbaf0c00c1a1 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 09:25:51 +0900 Subject: [PATCH 2/9] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj From e8fc6643520dbd733fb20e18634169f964fb345f Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 10:32:37 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=20=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=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 - Primary Key 컬럼명을 프론트엔드에서 백엔드로 전달하도록 개선 - 백엔드 자동 감지 실패 시에도 클라이언트 제공 값 우선 사용 - Primary Key 찾기 로직 개선 (설정값 > id > ID > non-null 필드) --- backend-node/src/routes/dataRoutes.ts | 11 ++++-- backend-node/src/services/dataService.ts | 35 ++++++++++++------- frontend/components/common/ScreenModal.tsx | 8 ++++- .../SplitPanelLayoutComponent.tsx | 35 +++++++++++++++---- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 574f1cf8..a7757397 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -606,7 +606,7 @@ router.get( }); } - const { enableEntityJoin, groupByColumns } = req.query; + const { enableEntityJoin, groupByColumns, primaryKeyColumn } = req.query; const enableEntityJoinFlag = enableEntityJoin === "true" || (typeof enableEntityJoin === "boolean" && enableEntityJoin); @@ -626,17 +626,22 @@ router.get( } } + // 🆕 primaryKeyColumn 파싱 + const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined; + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { enableEntityJoin: enableEntityJoinFlag, groupByColumns: groupByColumnsArray, + primaryKeyColumn: primaryKeyColumnStr, }); - // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) + // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 + Primary Key 컬럼 포함) const result = await dataService.getRecordDetail( tableName, id, enableEntityJoinFlag, - groupByColumnsArray + groupByColumnsArray, + primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달 ); if (!result.success) { diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 8c6e63f0..60de20db 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -490,7 +490,8 @@ class DataService { tableName: string, id: string | number, enableEntityJoin: boolean = false, - groupByColumns: string[] = [] + groupByColumns: string[] = [], + primaryKeyColumn?: string // 🆕 클라이언트에서 전달한 Primary Key 컬럼명 ): Promise> { try { // 테이블 접근 검증 @@ -499,20 +500,30 @@ class DataService { return validation.error!; } - // Primary Key 컬럼 찾기 - const pkResult = await query<{ attname: string }>( - `SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = $1::regclass AND i.indisprimary`, - [tableName] - ); + // 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용 + let pkColumn = primaryKeyColumn || ""; + + // Primary Key 컬럼이 없으면 자동 감지 + if (!pkColumn) { + const pkResult = await query<{ attname: string }>( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [tableName] + ); - let pkColumn = "id"; // 기본값 - if (pkResult.length > 0) { - pkColumn = pkResult[0].attname; + pkColumn = "id"; // 기본값 + if (pkResult.length > 0) { + pkColumn = pkResult[0].attname; + } + console.log(`🔑 [getRecordDetail] 자동 감지된 Primary Key:`, pkResult); + } else { + console.log(`🔑 [getRecordDetail] 클라이언트 제공 Primary Key: ${pkColumn}`); } + console.log(`🔑 [getRecordDetail] 테이블: ${tableName}, Primary Key 컬럼: ${pkColumn}, 조회 ID: ${id}`); + // 🆕 Entity Join이 활성화된 경우 if (enableEntityJoin) { const { EntityJoinService } = await import("./entityJoinService"); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 44685dc0..fdd104df 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -374,8 +374,9 @@ export const ScreenModal: React.FC = ({ className }) => { const editId = urlParams.get("editId"); const tableName = urlParams.get("tableName") || screenInfo.tableName; const groupByColumnsParam = urlParams.get("groupByColumns"); + const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명 - console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam }); + console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn }); // 수정 모드이고 editId가 있으면 해당 레코드 조회 if (mode === "edit" && editId && tableName) { @@ -414,6 +415,11 @@ export const ScreenModal: React.FC = ({ className }) => { params.groupByColumns = JSON.stringify(groupByColumns); console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns); } + // 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용) + if (primaryKeyColumn) { + params.primaryKeyColumn = primaryKeyColumn; + console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn); + } console.log("📡 [ScreenModal] 실제 API 요청:", { url: `/data/${tableName}/${editId}`, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ab387348..9b8e7cf0 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1590,21 +1590,40 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모달 화면 열기 const rightTableName = componentConfig.rightPanel?.tableName || ""; - // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) + // Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드) + // 🔧 설정에서 primaryKeyColumn 지정 가능 + const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn; + let primaryKeyName = "id"; let primaryKeyValue: any; - if (item.id !== undefined && item.id !== null) { + if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) { + // 설정된 Primary Key 사용 + primaryKeyName = configuredPrimaryKey; + primaryKeyValue = item[configuredPrimaryKey]; + } else if (item.id !== undefined && item.id !== null) { primaryKeyName = "id"; primaryKeyValue = item.id; } else if (item.ID !== undefined && item.ID !== null) { primaryKeyName = "ID"; primaryKeyValue = item.ID; } else { - // 첫 번째 필드를 Primary Key로 간주 - const firstKey = Object.keys(item)[0]; - primaryKeyName = firstKey; - primaryKeyValue = item[firstKey]; + // 🔧 첫 번째 non-null 필드를 Primary Key로 간주 + const keys = Object.keys(item); + let found = false; + for (const key of keys) { + if (item[key] !== undefined && item[key] !== null) { + primaryKeyName = key; + primaryKeyValue = item[key]; + found = true; + break; + } + } + // 모든 필드가 null이면 첫 번째 필드 사용 + if (!found && keys.length > 0) { + primaryKeyName = keys[0]; + primaryKeyValue = item[keys[0]]; + } } console.log("✅ 수정 모달 열기:", { @@ -1629,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC hasGroupByColumns: groupByColumns.length > 0, }); - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달) window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { @@ -1638,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC mode: "edit", editId: primaryKeyValue, tableName: rightTableName, + primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달 ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), @@ -1650,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC screenId: modalScreenId, editId: primaryKeyValue, tableName: rightTableName, + primaryKeyColumn: primaryKeyName, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); From 29a4ab7b9df0bfaf7ea03bdfa9ef3d3adc1dc1f2 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 11:40:47 +0900 Subject: [PATCH 4/9] =?UTF-8?q?entity-search-iniput=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EntitySearchInputComponent.tsx | 37 +++++- .../EntitySearchInputConfigPanel.tsx | 122 ++++++++++++++++-- .../EntitySearchInputWrapper.tsx | 5 + .../entity-search-input/EntitySearchModal.tsx | 11 +- .../components/entity-search-input/config.ts | 9 ++ 5 files changed, 166 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 5045a43b..f1604337 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { cascadingRelationApi } from "@/lib/api/cascadingRelation"; +import { AutoFillMapping } from "./config"; export function EntitySearchInputComponent({ tableName, @@ -37,6 +38,8 @@ export function EntitySearchInputComponent({ formData, // 다중선택 props multiple: multipleProp, + // 자동 채움 매핑 props + autoFillMappings: autoFillMappingsProp, // 추가 props component, isInteractive, @@ -47,6 +50,7 @@ export function EntitySearchInputComponent({ isInteractive?: boolean; onFormDataChange?: (fieldName: string, value: any) => void; webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등) + autoFillMappings?: AutoFillMapping[]; // 자동 채움 매핑 }) { // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; @@ -54,6 +58,18 @@ export function EntitySearchInputComponent({ // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서) const config = component?.componentConfig || component?.webTypeConfig || {}; const isMultiple = multipleProp ?? config.multiple ?? false; + + // 자동 채움 매핑 설정 (props > config) + const autoFillMappings: AutoFillMapping[] = autoFillMappingsProp ?? config.autoFillMappings ?? []; + + // 디버그: 자동 채움 매핑 설정 확인 + console.log("🔧 [EntitySearchInput] 자동 채움 매핑 설정:", { + autoFillMappingsProp, + configAutoFillMappings: config.autoFillMappings, + effectiveAutoFillMappings: autoFillMappings, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); // 연쇄관계 설정 추출 const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; @@ -309,6 +325,23 @@ export function EntitySearchInputComponent({ console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); } } + + // 🆕 자동 채움 매핑 적용 + if (autoFillMappings.length > 0 && isInteractive && onFormDataChange && fullData) { + console.log("🔄 자동 채움 매핑 적용:", { mappings: autoFillMappings, fullData }); + + for (const mapping of autoFillMappings) { + if (mapping.sourceField && mapping.targetField) { + const sourceValue = fullData[mapping.sourceField]; + if (sourceValue !== undefined) { + onFormDataChange(mapping.targetField, sourceValue); + console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}:`, sourceValue); + } else { + console.log(` ⚠️ ${mapping.sourceField} 값이 없음`); + } + } + } + } }; // 다중선택 모드에서 개별 항목 제거 @@ -436,7 +469,7 @@ export function EntitySearchInputComponent({ const isSelected = selectedValues.includes(String(option[valueField])); return ( handleSelectOption(option)} className="text-xs sm:text-sm" @@ -509,7 +542,7 @@ export function EntitySearchInputComponent({ {effectiveOptions.map((option, index) => ( handleSelectOption(option)} className="text-xs sm:text-sm" diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx index fb75daa4..22a52aab 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx @@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react"; // allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지 -import { EntitySearchInputConfig } from "./config"; +import { EntitySearchInputConfig, AutoFillMapping } from "./config"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableTypeApi } from "@/lib/api/screen"; import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation"; @@ -236,6 +236,7 @@ export function EntitySearchInputConfigPanel({ const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); onConfigChange(newConfig); + console.log("📝 [EntitySearchInput] 설정 업데이트:", { updates, newConfig }); }; // 연쇄 드롭다운 활성화/비활성화 @@ -636,9 +637,9 @@ export function EntitySearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {tableColumns.map((column, idx) => ( { updateConfig({ displayField: column.columnName }); @@ -690,9 +691,9 @@ export function EntitySearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {tableColumns.map((column, idx) => ( { updateConfig({ valueField: column.columnName }); @@ -812,8 +813,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -860,8 +861,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -919,8 +920,8 @@ export function EntitySearchInputConfigPanel({ - {tableColumns.map((col) => ( - + {tableColumns.map((col, colIdx) => ( + {col.displayName || col.columnName} ))} @@ -939,6 +940,105 @@ export function EntitySearchInputConfigPanel({ )} + + {/* 자동 채움 매핑 설정 */} +
+
+
+ +

자동 채움 매핑

+
+ +
+

+ 엔티티를 선택하면 소스 필드의 값이 대상 필드에 자동으로 채워집니다. +

+ + {(localConfig.autoFillMappings || []).length > 0 && ( +
+ {(localConfig.autoFillMappings || []).map((mapping, index) => ( +
+ {/* 소스 필드 (선택된 엔티티) */} +
+ + +
+ + {/* 화살표 */} +
+ +
+ + {/* 대상 필드 (폼) */} +
+ + { + const mappings = [...(localConfig.autoFillMappings || [])]; + mappings[index] = { ...mappings[index], targetField: e.target.value }; + updateConfig({ autoFillMappings: mappings }); + }} + placeholder="폼 필드명" + className="h-8 text-xs" + /> +
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )} + + {(localConfig.autoFillMappings || []).length === 0 && ( +
+ 매핑이 없습니다. + 추가 버튼을 클릭하여 매핑을 추가하세요. +
+ )} +
); } diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx index dd6ed5c4..f8a3a22e 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputWrapper.tsx @@ -37,6 +37,9 @@ export const EntitySearchInputWrapper: React.FC = ({ // placeholder const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요"; + + // 자동 채움 매핑 설정 + const autoFillMappings = config.autoFillMappings || []; console.log("🏢 EntitySearchInputWrapper 렌더링:", { tableName, @@ -44,6 +47,7 @@ export const EntitySearchInputWrapper: React.FC = ({ valueField, uiMode, multiple, + autoFillMappings, value, config, }); @@ -68,6 +72,7 @@ export const EntitySearchInputWrapper: React.FC = ({ value={value} onChange={onChange} multiple={multiple} + autoFillMappings={autoFillMappings} component={component} isInteractive={props.isInteractive} onFormDataChange={props.onFormDataChange} diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx index 555efe9b..422dfbfa 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx @@ -148,9 +148,9 @@ export function EntitySearchModal({ 선택 )} - {displayColumns.map((col) => ( + {displayColumns.map((col, colIdx) => ( {col} @@ -179,7 +179,8 @@ export function EntitySearchModal({ ) : ( results.map((item, index) => { - const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; + // null과 undefined 모두 체크하여 유니크 키 생성 + const uniqueKey = item[valueField] != null ? `${item[valueField]}` : `row-${index}`; const isSelected = isItemSelected(item); return ( )} - {displayColumns.map((col) => ( - + {displayColumns.map((col, colIdx) => ( + {item[col] || "-"} ))} diff --git a/frontend/lib/registry/components/entity-search-input/config.ts b/frontend/lib/registry/components/entity-search-input/config.ts index fab81c9f..3dae8779 100644 --- a/frontend/lib/registry/components/entity-search-input/config.ts +++ b/frontend/lib/registry/components/entity-search-input/config.ts @@ -1,3 +1,9 @@ +// 자동 채움 매핑 타입 +export interface AutoFillMapping { + sourceField: string; // 선택된 엔티티의 필드 (예: customer_name) + targetField: string; // 폼의 필드 (예: partner_name) +} + export interface EntitySearchInputConfig { tableName: string; displayField: string; @@ -18,5 +24,8 @@ export interface EntitySearchInputConfig { cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRole?: "parent" | "child"; // 역할 (부모/자식) cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용) + + // 자동 채움 매핑 설정 + autoFillMappings?: AutoFillMapping[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기 } From cb8b434434a957da3ec78f889712aed7671091c4 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:01:58 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=ED=94=BC=EB=B2=97=EC=97=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=95=88=EB=90=98=EB=8D=98=EA=B1=B0=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 --- .../lib/registry/components/pivot-grid/PivotGridComponent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 53ad204d..4b4465e1 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -500,9 +500,9 @@ export const PivotGridComponent: React.FC = ({ const filteredData = useMemo(() => { if (!data || data.length === 0) return data; - // 필터 영역의 필드들로 데이터 필터링 + // 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링 const activeFilters = fields.filter( - (f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0 + (f) => f.filterValues && f.filterValues.length > 0 ); if (activeFilters.length === 0) return data; From e6bb366ec7c04d7a88b44cb8fc856459bbbd5fa1 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:15:20 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=ED=94=BC=EB=B2=97=EC=97=90=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=EC=AA=BD=EC=97=90=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=B2=84=ED=8A=BC=20=EB=84=A3=EC=97=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 4 + .../pivot-grid/components/FieldPanel.tsx | 162 +++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 4b4465e1..b815ce06 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -299,6 +299,8 @@ export const PivotGridComponent: React.FC = ({ // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); + // 초기 필드 설정 저장 (초기화용) + const initialFieldsRef = useRef(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], @@ -1129,6 +1131,7 @@ export const PivotGridComponent: React.FC = ({ onFieldsChange={handleFieldsChange} collapsed={!showFieldPanel} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} + initialFields={initialFieldsRef.current} /> {/* 안내 메시지 */} @@ -1405,6 +1408,7 @@ export const PivotGridComponent: React.FC = ({ onFieldsChange={handleFieldsChange} collapsed={!showFieldPanel} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} + initialFields={initialFieldsRef.current} /> {/* 헤더 툴바 */} diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 967afd08..2ef1227e 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -37,6 +37,10 @@ import { BarChart3, GripVertical, ChevronDown, + RotateCcw, + FilterX, + LayoutGrid, + Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -56,6 +60,8 @@ interface FieldPanelProps { onFieldSettingsChange?: (field: PivotFieldConfig) => void; collapsed?: boolean; onToggleCollapse?: () => void; + /** 초기 필드 설정 (필드 배치 초기화용) */ + initialFields?: PivotFieldConfig[]; } interface FieldChipProps { @@ -123,15 +129,23 @@ const SortableFieldChip: React.FC = ({ transition, }; + // 필터 적용 여부 확인 + const hasFilter = field.filterValues && field.filterValues.length > 0; + const filterCount = field.filterValues?.length || 0; + return (
{/* 드래그 핸들 */} @@ -143,11 +157,24 @@ const SortableFieldChip: React.FC = ({ + {/* 필터 아이콘 (필터 적용 시) */} + {hasFilter && ( + + )} + {/* 필드 라벨 */}
- {/* 접기 버튼 */} - {onToggleCollapse && ( -
+ {/* 하단 버튼 영역 */} +
+ {/* 초기화 드롭다운 */} + + + + + + + + 필터만 초기화 + {filteredFieldCount > 0 && ( + + ({filteredFieldCount}개) + + )} + + + + 필드 배치 초기화 + + + + + 전체 초기화 + + + + + {/* 접기 버튼 */} + {onToggleCollapse && ( -
- )} + )} +
{/* 드래그 오버레이 */} From 62a82b3bcfb2020db793fd08b40e47fdd49cef3a Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 16:40:37 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=EB=B0=91=EA=BA=BD=EC=87=A0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=96=88=EC=9D=8C!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridConfigPanel.tsx | 26 ++++++- .../pivot-grid/components/FieldPanel.tsx | 71 +++++++++++++++++++ .../registry/components/pivot-grid/types.ts | 1 + .../pivot-grid/utils/pivotEngine.ts | 4 +- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index 37f0862b..448c92a5 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -16,6 +16,7 @@ import { PivotAreaType, AggregationType, FieldDataType, + DateGroupInterval, } from "./types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -202,6 +203,28 @@ const AreaDropZone: React.FC = ({ )} + {/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */} + {(area === "row" || area === "column") && field.dataType === "date" && ( + + )} + )} + + {/* 상태 유지 체크박스 */} +
+ setPersistState(checked === true)} + className="h-3.5 w-3.5" + /> + +
{/* 차트 토글 */} {chartConfig && ( @@ -1689,137 +1763,224 @@ export const PivotGridComponent: React.FC = ({ > - {/* 열 헤더 */} - - {/* 좌상단 코너 (행 필드 라벨 + 필터) */} - + {/* 좌상단 코너 (첫 번째 레벨에만 표시) */} + {levelIdx === 0 && ( + + )} + + {/* 열 헤더 셀 - 해당 레벨 */} + {levelCells.map((cell, cellIdx) => ( + ))} - {rowFields.length === 0 && 항목} - - - {/* 열 헤더 셀 */} - {flatColumns.map((col, idx) => ( - )} - colSpan={dataFields.length || 1} - style={{ width: columnWidths[idx] || "auto", minWidth: 50 }} - onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} - > -
- {col.caption || "(전체)"} - {dataFields.length === 1 && } -
- {/* 열 리사이즈 핸들 */} -
handleResizeStart(idx, e)} - /> - - ))} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( -
)} - colSpan={dataFields.length || 1} - rowSpan={dataFields.length > 1 ? 2 : 1} - > - 총계 - - )} - - {/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */} - {columnFields.length > 0 && ( + + )) + ) : ( + // 열 필드가 없는 경우: 단일 행 + - )} - + + {/* 열 헤더 셀 (열 필드 없을 때) */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index d55d6982..d4d8b1e5 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -331,8 +331,11 @@ export interface PivotResult { // 플랫 행 목록 (렌더링용) flatRows: PivotFlatRow[]; - // 플랫 열 목록 (렌더링용) + // 플랫 열 목록 (렌더링용) - 리프 노드만 flatColumns: PivotFlatColumn[]; + + // 열 헤더 레벨별 (다중 행 헤더용) + columnHeaderLevels: PivotColumnHeaderCell[][]; // 총합계 grandTotals: { @@ -361,6 +364,14 @@ export interface PivotFlatColumn { isTotal?: boolean; } +// 열 헤더 셀 (다중 행 헤더용) +export interface PivotColumnHeaderCell { + caption: string; // 표시 텍스트 + colSpan: number; // 병합할 열 수 + path: string[]; // 전체 경로 + level: number; // 레벨 (0부터 시작) +} + // ==================== 상태 관리 ==================== export interface PivotGridState { diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 9c3b5bc1..35893dea 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -10,6 +10,7 @@ import { PivotFlatRow, PivotFlatColumn, PivotCellValue, + PivotColumnHeaderCell, DateGroupInterval, AggregationType, SummaryDisplayMode, @@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string { return path.join("||"); } +/** + * 모든 가능한 경로 생성 (열 전체 확장용) + */ +function generateAllPaths( + data: Record[], + fields: PivotFieldConfig[] +): string[] { + const allPaths: string[] = []; + + // 각 레벨까지의 고유 경로 수집 + for (let depth = 1; depth <= fields.length; depth++) { + const fieldsAtDepth = fields.slice(0, depth); + const pathSet = new Set(); + + data.forEach((row) => { + const path = fieldsAtDepth.map((f) => getFieldValue(row, f)); + pathSet.add(pathToKey(path)); + }); + + pathSet.forEach((pathKey) => allPaths.push(pathKey)); + } + + return allPaths; +} + /** * 키를 경로로 변환 */ @@ -326,6 +352,66 @@ function getMaxColumnLevel( return Math.min(maxLevel, totalFields - 1); } +/** + * 다중 행 열 헤더 생성 + * 각 레벨별로 셀과 colSpan 정보를 반환 + */ +function buildColumnHeaderLevels( + nodes: PivotHeaderNode[], + totalLevels: number +): PivotColumnHeaderCell[][] { + if (totalLevels === 0 || nodes.length === 0) { + return []; + } + + const levels: PivotColumnHeaderCell[][] = Array.from( + { length: totalLevels }, + () => [] + ); + + // 리프 노드 수 계산 (colSpan 계산용) + function countLeaves(node: PivotHeaderNode): number { + if (!node.children || node.children.length === 0 || !node.isExpanded) { + return 1; + } + return node.children.reduce((sum, child) => sum + countLeaves(child), 0); + } + + // 트리 순회하며 각 레벨에 셀 추가 + function traverse(node: PivotHeaderNode, level: number) { + const colSpan = countLeaves(node); + + levels[level].push({ + caption: node.caption, + colSpan, + path: node.path, + level, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } else if (level < totalLevels - 1) { + // 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움 + for (let i = level + 1; i < totalLevels; i++) { + levels[i].push({ + caption: "", + colSpan, + path: node.path, + level: i, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + // ==================== 데이터 매트릭스 생성 ==================== /** @@ -735,12 +821,11 @@ export function processPivotData( uniqueValues.forEach((val) => expandedRowSet.add(val)); } - if (expandedColumnPaths.length === 0 && columnFields.length > 0) { - const firstField = columnFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedColSet.add(val)); + // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음) + // 모든 가능한 열 경로를 확장 상태로 설정 + if (columnFields.length > 0) { + const allColumnPaths = generateAllPaths(filteredData, columnFields); + allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey)); } // 헤더 트리 생성 @@ -788,6 +873,12 @@ export function processPivotData( grandTotals.grand ); + // 다중 행 열 헤더 생성 + const columnHeaderLevels = buildColumnHeaderLevels( + columnHeaders, + columnFields.length + ); + return { rowHeaders, columnHeaders, @@ -799,6 +890,7 @@ export function processPivotData( caption: path[path.length - 1] || "", span: 1, })), + columnHeaderLevels, grandTotals, }; } From 2327d6e97c0479a656ec30710f69c90743e8a845 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 21 Jan 2026 17:14:07 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9C=A0=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pivot-grid/PivotGridComponent.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 8db463c4..13cb1a68 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -447,8 +447,8 @@ export const PivotGridComponent: React.FC = ({ useEffect(() => { if (!isStateRestored) return; // 복원 완료 전에는 무시 - // persistState가 켜져있고 저장된 상태가 있으면 initialFields로 덮어쓰지 않음 - if (persistState && typeof window !== "undefined") { + // 저장된 상태가 있으면 initialFields로 덮어쓰지 않음 + if (typeof window !== "undefined") { const savedState = localStorage.getItem(stateStorageKey); if (savedState) return; // 이미 저장된 상태가 있으면 무시 } @@ -456,13 +456,32 @@ export const PivotGridComponent: React.FC = ({ if (initialFields.length > 0) { setFields(initialFields); } - }, [initialFields, isStateRestored, persistState, stateStorageKey]); + // persistState는 의존성에서 제외 - 체크박스 변경 시 현재 상태 유지 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialFields, isStateRestored, stateStorageKey]); - // 상태 유지 설정 저장 + // 상태 유지 설정 저장 + 켜질 때 현재 상태 즉시 저장 useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem(persistSettingKey, String(persistState)); - }, [persistState, persistSettingKey]); + + // 상태 유지를 켜면 현재 상태를 즉시 저장 + if (persistState && isStateRestored && fields.length > 0) { + const stateToSave = { + version: PIVOT_STATE_VERSION, + fields, + pivotState, + sortConfig, + columnWidths, + }; + localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); + } + + // 상태 유지를 끄면 저장된 상태 삭제 + if (!persistState) { + localStorage.removeItem(stateStorageKey); + } + }, [persistState, persistSettingKey, isStateRestored, fields, pivotState, sortConfig, columnWidths, stateStorageKey]); // 상태 저장 (localStorage) const saveStateToStorage = useCallback(() => {
0 ? 2 : 1} - > -
- {rowFields.map((f, idx) => ( -
- {f.caption} - { - const newFields = fields.map((fld) => - fld.field === field.field && fld.area === "row" - ? { ...fld, filterValues: values, filterType: type } - : fld - ); - handleFieldsChange(newFields); - }} - trigger={ - - } - /> - {idx < rowFields.length - 1 && /} -
+ {/* 다중 행 열 헤더 */} + {columnHeaderLevels.length > 0 ? ( + // 열 필드가 있는 경우: 각 레벨별로 행 생성 + columnHeaderLevels.map((levelCells, levelIdx) => ( +
1 ? 1 : 0)} + > +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && 항목} +
+
+
+ {cell.caption || "(전체)"} + {levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && ( + + )} +
+
1 ? 1 : 0)} + > + 총계 + 0 && ( + 1 ? 1 : 0)} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
1 ? 2 : 1} > -
- {columnFields.map((f) => ( - { - const newFields = fields.map((fld) => - fld.field === field.field && fld.area === "column" - ? { ...fld, filterValues: values, filterType: type } - : fld - ); - handleFieldsChange(newFields); - }} - trigger={ - - } - /> +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
))} + {rowFields.length === 0 && 항목}
handleSort(dataFields[0].field) : undefined} + > +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
+
handleResizeStart(idx, e)} + /> +
1 ? 2 : 1} + > + 총계 +