From b9ee860e710617faac3613dda5059170b8801767 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 11:41:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=EC=82=AD=EC=A0=9C=EB=B2=84=ED=8A=BC=20on,of?= =?UTF-8?q?f=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SplitPanelLayoutComponent.tsx | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ba911c3c..7753baa3 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2150,11 +2150,14 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} - {!isDesignMode && ( - - 작업 - - )} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + + 작업 + + )} @@ -2172,40 +2175,43 @@ export const SplitPanelLayoutComponent: React.FC {formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)} ))} - {!isDesignMode && ( - -
- {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} -
- - )} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + +
+ {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )} + {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( + + )} +
+ + )} ); })} From fc5ffb03b2b644f997b02f4b555eb874c8b0ec72 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 12:01:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=EC=BB=AC=EB=9F=BC=20=ED=91=9C=EC=8B=9C=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/entityJoinService.ts | 34 +++- .../SplitPanelLayoutComponent.tsx | 147 ++++++++++-------- 2 files changed, 107 insertions(+), 74 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 280051d0..5557d8b5 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -134,8 +134,8 @@ export class EntityJoinService { `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { - // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 - logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); + // display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지 + logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`); // 참조 테이블의 모든 컬럼 이름 가져오기 const tableColumnsResult = await query<{ column_name: string }>( @@ -148,10 +148,34 @@ export class EntityJoinService { ); if (tableColumnsResult.length > 0) { - displayColumns = tableColumnsResult.map((col) => col.column_name); + const allColumns = tableColumnsResult.map((col) => col.column_name); + + // 🆕 표시용 컬럼 자동 감지 (우선순위 순서) + // 1. *_name 컬럼 (item_name, customer_name 등) + // 2. name 컬럼 + // 3. label 컬럼 + // 4. title 컬럼 + // 5. 참조 컬럼 (referenceColumn) + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + const simpleNameColumn = allColumns.find((col) => col === "name"); + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + const titleColumn = allColumns.find((col) => col === "title"); + + // 우선순위에 따라 표시 컬럼 선택 + const displayColumn = + nameColumn || + simpleNameColumn || + labelColumn || + titleColumn || + referenceColumn; + displayColumns = [displayColumn]; + logger.info( - `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, - displayColumns.join(", ") + `✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)` ); } else { // 테이블 컬럼을 못 찾으면 기본값 사용 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 7753baa3..1df2a551 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -73,6 +73,67 @@ export const SplitPanelLayoutComponent: React.FC return true; }; + // 🆕 엔티티 조인 컬럼명 변환 헬퍼 + // "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근 + const getEntityJoinValue = useCallback( + (item: any, columnName: string, entityColumnMap?: Record): any => { + // 직접 매칭 시도 + if (item[columnName] !== undefined) { + return item[columnName]; + } + + // "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name) + if (columnName.includes(".")) { + const [tableName, fieldName] = columnName.split("."); + + // 🔍 디버깅: 첫 번째 아이템에서 키 목록 출력 + if (item && typeof item === "object") { + const keys = Object.keys(item); + const matchingKeys = keys.filter((k) => k.includes(fieldName)); + console.log(`🔍 getEntityJoinValue: columnName=${columnName}, fieldName=${fieldName}`); + console.log(" 전체 키 목록:", keys); + console.log(" 매칭 가능한 키들:", matchingKeys); + } + + // entityColumnMap에서 매핑 찾기 (예: item_info → item_code) + if (entityColumnMap && entityColumnMap[tableName]) { + const sourceColumn = entityColumnMap[tableName]; + const joinedColumnName = `${sourceColumn}_${fieldName}`; + if (item[joinedColumnName] !== undefined) { + return item[joinedColumnName]; + } + } + + // 모든 키에서 _fieldName으로 끝나는 것 찾기 + for (const key of Object.keys(item)) { + if (key.endsWith(`_${fieldName}`)) { + console.log(` ✅ 매칭됨: ${key} → ${item[key]}`); + return item[key]; + } + } + + // 🆕 엔티티 조인 기본 패턴: 테이블명.컬럼명 → 소스컬럼_name + // 예: item_info.item_name → item_code_name + // tableName에서 소스 컬럼 추론 (item_info → item_code, customer_mng → customer_id 등) + const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); + const defaultAliasKey = `${inferredSourceColumn}_name`; + if (item[defaultAliasKey] !== undefined) { + console.log(` ✅ 기본 별칭 매칭: ${defaultAliasKey} → ${item[defaultAliasKey]}`); + return item[defaultAliasKey]; + } + + // 테이블명_컬럼명 형식으로 시도 + const underscoreKey = `${tableName}_${fieldName}`; + if (item[underscoreKey] !== undefined) { + return item[underscoreKey]; + } + } + + return undefined; + }, + [], + ); + // TableOptions Context const { registerTable, unregisterTable } = useTableOptions(); const [leftFilters, setLeftFilters] = useState([]); @@ -1737,7 +1798,7 @@ export const SplitPanelLayoutComponent: React.FC > {formatCellValue( col.name, - item[col.name], + getEntityJoinValue(item, col.name), leftCategoryMappings, col.format, )} @@ -1796,7 +1857,12 @@ export const SplitPanelLayoutComponent: React.FC className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" style={{ textAlign: col.align || "left" }} > - {formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)} + {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + leftCategoryMappings, + col.format, + )} ))} @@ -2172,7 +2238,12 @@ export const SplitPanelLayoutComponent: React.FC className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" style={{ textAlign: col.align || "left" }} > - {formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)} + {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} @@ -2246,78 +2317,16 @@ export const SplitPanelLayoutComponent: React.FC firstValues = rightColumns .slice(0, summaryCount) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name) - let value = item[col.name]; - if (value === undefined && col.name.includes(".")) { - const columnName = col.name.split(".").pop(); - // 1차: 컬럼명 그대로 (예: item_number) - value = item[columnName || ""]; - // 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인 - if (value === undefined) { - const parts = col.name.split("."); - if (parts.length === 2) { - const refTable = parts[0]; // item_info - const refColumn = parts[1]; // item_number 또는 item_name - // FK 컬럼명 추론: item_info → item_id - const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id"; - - // 백엔드에서 반환하는 별칭 패턴: - // 1) item_id_name (기본 referenceColumn) - // 2) item_id_item_name (추가 컬럼) - if ( - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" || - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code" - ) { - // 기본 참조 컬럼 (item_number, customer_code 등) - const aliasKey = fkColumn + "_name"; - value = item[aliasKey]; - } else { - // 추가 컬럼 (item_name, customer_name 등) - const aliasKey = `${fkColumn}_${refColumn}`; - value = item[aliasKey]; - } - } - } - } + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); allValues = rightColumns .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 - let value = item[col.name]; - if (value === undefined && col.name.includes(".")) { - const columnName = col.name.split(".").pop(); - // 1차: 컬럼명 그대로 - value = item[columnName || ""]; - // 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인 - if (value === undefined) { - const parts = col.name.split("."); - if (parts.length === 2) { - const refTable = parts[0]; // item_info - const refColumn = parts[1]; // item_number 또는 item_name - // FK 컬럼명 추론: item_info → item_id - const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id"; - - // 백엔드에서 반환하는 별칭 패턴: - // 1) item_id_name (기본 referenceColumn) - // 2) item_id_item_name (추가 컬럼) - if ( - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" || - refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code" - ) { - // 기본 참조 컬럼 - const aliasKey = fkColumn + "_name"; - value = item[aliasKey]; - } else { - // 추가 컬럼 - const aliasKey = `${fkColumn}_${refColumn}`; - value = item[aliasKey]; - } - } - } - } + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; }) .filter(([_, value]) => value !== null && value !== undefined && value !== ""); From 215242b6763a6292942ee9c6d2731b91284964fd Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 14:25:28 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=95=A9=EC=82=B0=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableManagementService.ts | 302 +++++++++++--- .../screen/table-options/FilterPanel.tsx | 233 ++++++----- .../SplitPanelLayoutComponent.tsx | 387 +++++++++++++++--- frontend/types/table-options.ts | 24 +- 4 files changed, 729 insertions(+), 217 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 781a9498..9a8623a0 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -798,7 +798,12 @@ export class TableManagementService { ); // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 - await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + await this.syncScreenLayoutsInputType( + tableName, + columnName, + inputType, + companyCode + ); // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; @@ -928,7 +933,11 @@ export class TableManagementService { `UPDATE screen_layouts SET properties = $1, component_type = $2 WHERE layout_id = $3`, - [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + [ + JSON.stringify(updatedProperties), + newComponentType, + layout.layout_id, + ] ); logger.info( @@ -1299,18 +1308,30 @@ export class TableManagementService { try { // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + // 날짜 타입이면 날짜 범위로 처리 - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } - + // 그 외 타입이면 다중선택(IN 조건)으로 처리 - const multiValues = value.split("|").filter((v: string) => v.trim() !== ""); + const multiValues = value + .split("|") + .filter((v: string) => v.trim() !== ""); if (multiValues.length > 0) { - const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", "); - logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`); + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})` + ); return { whereClause: `${columnName}::text IN (${placeholders})`, values: multiValues, @@ -1320,10 +1341,20 @@ export class TableManagementService { } // 🔧 날짜 범위 객체 {from, to} 체크 - if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) { + if ( + typeof value === "object" && + value !== null && + ("from" in value || "to" in value) + ) { // 날짜 범위 객체는 그대로 전달 - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } } @@ -1356,9 +1387,10 @@ export class TableManagementService { // 컬럼 타입 정보 조회 const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, - `webType=${columnInfo?.webType || 'NULL'}`, - `inputType=${columnInfo?.inputType || 'NULL'}`, + logger.info( + `🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, + `webType=${columnInfo?.webType || "NULL"}`, + `inputType=${columnInfo?.inputType || "NULL"}`, `actualValue=${JSON.stringify(actualValue)}`, `operator=${operator}` ); @@ -1464,16 +1496,20 @@ export class TableManagementService { // 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD") if (typeof value === "string" && value.includes("|")) { const [fromStr, toStr] = value.split("|"); - + if (fromStr && fromStr.trim() !== "") { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(fromStr.trim()); paramCount++; } if (toStr && toStr.trim() !== "") { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(toStr.trim()); paramCount++; } @@ -1482,17 +1518,21 @@ export class TableManagementService { else if (typeof value === "object" && value !== null) { if (value.from) { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(value.from); paramCount++; } if (value.to) { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(value.to); paramCount++; } - } + } // 단일 날짜 검색 else if (typeof value === "string" && value.trim() !== "") { conditions.push(`${columnName}::date = $${paramIndex}::date`); @@ -1658,9 +1698,11 @@ export class TableManagementService { paramCount: 0, }; } - + // IN 절로 여러 값 검색 - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + const placeholders = value + .map((_, idx) => `$${paramIndex + idx}`) + .join(", "); return { whereClause: `${columnName} IN (${placeholders})`, values: value, @@ -1776,20 +1818,25 @@ export class TableManagementService { [tableName, columnName] ); - logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, { - found: !!result, - web_type: result?.web_type, - input_type: result?.input_type, - }); + logger.info( + `🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, + { + found: !!result, + web_type: result?.web_type, + input_type: result?.input_type, + } + ); if (!result) { - logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`); + logger.warn( + `⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}` + ); return null; } // web_type이 없으면 input_type을 사용 (레거시 호환) const webType = result.web_type || result.input_type || ""; - + const columnInfo = { webType: webType, inputType: result.input_type || "", @@ -1799,7 +1846,9 @@ export class TableManagementService { displayColumn: result.display_column || undefined, }; - logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`); + logger.info( + `✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}` + ); return columnInfo; } catch (error) { logger.error( @@ -1913,6 +1962,15 @@ export class TableManagementService { continue; } + // 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외 + // Entity 조인 조회에서만 처리됨 + if (column.includes(".")) { + logger.info( + `🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)` + ); + continue; + } + // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); @@ -2741,7 +2799,11 @@ export class TableManagementService { WHERE "${referenceColumn}" IS NOT NULL`; // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) - if (filterColumn && filterValue !== undefined && filterValue !== null) { + if ( + filterColumn && + filterValue !== undefined && + filterValue !== null + ) { excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; } @@ -2934,16 +2996,22 @@ export class TableManagementService { }), ]; + // 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식 + const hasJoinTableSearch = + options.search && + Object.keys(options.search).some((key) => key.includes(".")); + const hasEntitySearch = options.search && - Object.keys(options.search).some((key) => + (Object.keys(options.search).some((key) => allEntityColumns.includes(key) - ); + ) || + hasJoinTableSearch); if (hasEntitySearch) { const entitySearchKeys = options.search - ? Object.keys(options.search).filter((key) => - allEntityColumns.includes(key) + ? Object.keys(options.search).filter( + (key) => allEntityColumns.includes(key) || key.includes(".") ) : []; logger.info( @@ -2988,47 +3056,113 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { + // 검색값 추출 (객체 형태일 수 있음) + let searchValue = value; + if ( + typeof value === "object" && + value !== null && + "value" in value + ) { + searchValue = value.value; + } + + // 빈 값이면 스킵 + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null || + searchValue === undefined + ) { + continue; + } + + const safeValue = String(searchValue).replace(/'/g, "''"); + + // 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name) + if (key.includes(".")) { + const [refTable, refColumn] = key.split("."); + + // aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식) + let foundAlias: string | undefined; + for (const [aliasKey, alias] of aliasMap.entries()) { + if (aliasKey.startsWith(`${refTable}:`)) { + foundAlias = alias; + break; + } + } + + if (foundAlias) { + whereConditions.push( + `${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (${refTable}.${refColumn})`); + logger.info( + `🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})` + ); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음` + ); + } + continue; + } + const joinConfig = joinConfigs.find( (config) => config.aliasColumn === key ); if (joinConfig) { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 - const alias = aliasMap.get(joinConfig.referenceTable); + const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; + const alias = aliasMap.get(aliasKey); whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'` + `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); entitySearchColumns.push( `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` ); logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})` + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` ); } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 - const userAlias = aliasMap.get("user_info"); - whereConditions.push( - `${userAlias}.dept_code ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (user_info.dept_code)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})` + const userAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("user_info:") ); + const userAlias = userAliasKey + ? aliasMap.get(userAliasKey) + : undefined; + if (userAlias) { + whereConditions.push( + `${userAlias}.dept_code ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (user_info.dept_code)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})` + ); + } } else if (key === "company_code_status") { // company_code_status: company_info.status에서 검색 - const companyAlias = aliasMap.get("company_info"); - whereConditions.push( - `${companyAlias}.status ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (company_info.status)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})` + const companyAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("company_info:") ); + const companyAlias = companyAliasKey + ? aliasMap.get(companyAliasKey) + : undefined; + if (companyAlias) { + whereConditions.push( + `${companyAlias}.status ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (company_info.status)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})` + ); + } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${value}%'`); + whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'` + `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` ); } } @@ -3168,6 +3302,59 @@ export class TableManagementService { } try { + // 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name) + if (columnName.includes(".")) { + const [refTable, refColumn] = columnName.split("."); + + // 검색값 추출 + let searchValue = value; + if (typeof value === "object" && value !== null && "value" in value) { + searchValue = value.value; + } + + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null + ) { + continue; + } + + // 🔍 column_labels에서 해당 엔티티 설정 찾기 + // 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info) + const entityColumnResult = await query<{ + column_name: string; + reference_table: string; + reference_column: string; + }>( + `SELECT column_name, reference_table, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [tableName, refTable] + ); + + if (entityColumnResult.length > 0) { + // 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자) + const joinAlias = refTable.substring(0, 3); + + // 조인 테이블 컬럼으로 검색 조건 생성 + const safeValue = String(searchValue).replace(/'/g, "''"); + const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`; + + logger.info(`🔍 조인 테이블 검색 조건: ${condition}`); + conditions.push(condition); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음` + ); + } + + continue; + } + // 고급 검색 조건 구성 const searchCondition = await this.buildAdvancedSearchCondition( tableName, @@ -4282,7 +4469,10 @@ export class TableManagementService { ); return result.length > 0; } catch (error) { - logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error); + logger.error( + `컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, + error + ); return false; } } diff --git a/frontend/components/screen/table-options/FilterPanel.tsx b/frontend/components/screen/table-options/FilterPanel.tsx index 69395942..1b6104b6 100644 --- a/frontend/components/screen/table-options/FilterPanel.tsx +++ b/frontend/components/screen/table-options/FilterPanel.tsx @@ -10,17 +10,13 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Checkbox } from "@/components/ui/checkbox"; -import { Plus, X } from "lucide-react"; -import { TableFilter } from "@/types/table-options"; +import { Layers } from "lucide-react"; +import { TableFilter, GroupSumConfig } from "@/types/table-options"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; interface Props { isOpen: boolean; @@ -77,17 +73,37 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied const [columnFilters, setColumnFilters] = useState([]); const [selectAll, setSelectAll] = useState(false); + // 🆕 그룹별 합산 설정 + const [groupSumEnabled, setGroupSumEnabled] = useState(false); + const [groupByColumn, setGroupByColumn] = useState(""); + // localStorage에서 저장된 필터 설정 불러오기 useEffect(() => { if (table?.columns && table?.tableName) { // 화면별로 독립적인 필터 설정 저장 - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; const savedFilters = localStorage.getItem(storageKey); - + + // 🆕 그룹핑 설정도 불러오기 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + const savedGroupSum = localStorage.getItem(groupSumKey); + + if (savedGroupSum) { + try { + const parsed = JSON.parse(savedGroupSum) as GroupSumConfig; + setGroupSumEnabled(parsed.enabled); + setGroupByColumn(parsed.groupByColumn || ""); + } catch (error) { + console.error("그룹핑 설정 불러오기 실패:", error); + } + } + let filters: ColumnFilterConfig[]; - + if (savedFilters) { try { const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[]; @@ -96,13 +112,15 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied .filter((col) => col.filterable !== false) .map((col) => { const saved = parsed.find((f) => f.columnName === col.columnName); - return saved || { - columnName: col.columnName, - columnLabel: col.columnLabel, - inputType: col.inputType || "text", - enabled: false, - filterType: mapInputTypeToFilterType(col.inputType || "text"), - }; + return ( + saved || { + columnName: col.columnName, + columnLabel: col.columnLabel, + inputType: col.inputType || "text", + enabled: false, + filterType: mapInputTypeToFilterType(col.inputType || "text"), + } + ); }); } catch (error) { console.error("저장된 필터 설정 불러오기 실패:", error); @@ -127,26 +145,20 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied filterType: mapInputTypeToFilterType(col.inputType || "text"), })); } - + setColumnFilters(filters); } }, [table?.columns, table?.tableName]); // inputType을 filterType으로 매핑 - const mapInputTypeToFilterType = ( - inputType: string - ): "text" | "number" | "date" | "select" => { + const mapInputTypeToFilterType = (inputType: string): "text" | "number" | "date" | "select" => { if (inputType.includes("number") || inputType.includes("decimal")) { return "number"; } if (inputType.includes("date") || inputType.includes("time")) { return "date"; } - if ( - inputType.includes("select") || - inputType.includes("code") || - inputType.includes("category") - ) { + if (inputType.includes("select") || inputType.includes("code") || inputType.includes("category")) { return "select"; } return "text"; @@ -155,31 +167,20 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // 전체 선택/해제 const toggleSelectAll = (checked: boolean) => { setSelectAll(checked); - setColumnFilters((prev) => - prev.map((filter) => ({ ...filter, enabled: checked })) - ); + setColumnFilters((prev) => prev.map((filter) => ({ ...filter, enabled: checked }))); }; // 개별 필터 토글 const toggleFilter = (columnName: string) => { setColumnFilters((prev) => - prev.map((filter) => - filter.columnName === columnName - ? { ...filter, enabled: !filter.enabled } - : filter - ) + prev.map((filter) => (filter.columnName === columnName ? { ...filter, enabled: !filter.enabled } : filter)), ); }; // 필터 타입 변경 - const updateFilterType = ( - columnName: string, - filterType: "text" | "number" | "date" | "select" - ) => { + const updateFilterType = (columnName: string, filterType: "text" | "number" | "date" | "select") => { setColumnFilters((prev) => - prev.map((filter) => - filter.columnName === columnName ? { ...filter, filterType } : filter - ) + prev.map((filter) => (filter.columnName === columnName ? { ...filter, filterType } : filter)), ); }; @@ -198,44 +199,76 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied // localStorage에 저장 (화면별로 독립적) if (table?.tableName) { - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; localStorage.setItem(storageKey, JSON.stringify(columnFilters)); + + // 🆕 그룹핑 설정 저장 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + + if (groupSumEnabled && groupByColumn) { + const selectedColumn = columnFilters.find((f) => f.columnName === groupByColumn); + const groupSumConfig: GroupSumConfig = { + enabled: true, + groupByColumn: groupByColumn, + groupByColumnLabel: selectedColumn?.columnLabel, + }; + localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig)); + table?.onGroupSumChange?.(groupSumConfig); + } else { + localStorage.removeItem(groupSumKey); + table?.onGroupSumChange?.(null); + } } table?.onFilterChange(activeFilters); - + // 콜백으로 활성화된 필터 정보 전달 onFiltersApplied?.(activeFilters); - + onClose(); }; // 초기화 (즉시 저장 및 적용) const clearFilters = () => { - const clearedFilters = columnFilters.map((filter) => ({ - ...filter, - enabled: false + const clearedFilters = columnFilters.map((filter) => ({ + ...filter, + enabled: false, })); - + setColumnFilters(clearedFilters); setSelectAll(false); - + + // 🆕 그룹핑 설정 초기화 + setGroupSumEnabled(false); + setGroupByColumn(""); + // localStorage에서 제거 (화면별로 독립적) if (table?.tableName) { - const storageKey = screenId + const storageKey = screenId ? `table_filters_${table.tableName}_screen_${screenId}` : `table_filters_${table.tableName}`; localStorage.removeItem(storageKey); + + // 🆕 그룹핑 설정도 제거 + const groupSumKey = screenId + ? `table_groupsum_${table.tableName}_screen_${screenId}` + : `table_groupsum_${table.tableName}`; + localStorage.removeItem(groupSumKey); } - + // 빈 필터 배열로 적용 table?.onFilterChange([]); - + + // 🆕 그룹핑 해제 + table?.onGroupSumChange?.(null); + // 콜백으로 빈 필터 정보 전달 onFiltersApplied?.([]); - + // 즉시 닫기 onClose(); }; @@ -246,9 +279,7 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied - - 검색 필터 설정 - + 검색 필터 설정 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. @@ -256,17 +287,12 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied
{/* 전체 선택/해제 */} -
+
- - toggleSelectAll(checked as boolean) - } - /> + toggleSelectAll(checked as boolean)} /> 전체 선택/해제
-
+
{enabledCount} / {columnFilters.length}개
@@ -277,30 +303,21 @@ export const FilterPanel: React.FC = ({ isOpen, onClose, onFiltersApplied {columnFilters.map((filter) => (
{/* 체크박스 */} - toggleFilter(filter.columnName)} - /> + toggleFilter(filter.columnName)} /> {/* 컬럼 정보 */}
-
- {filter.columnLabel} -
-
- {filter.columnName} -
+
{filter.columnLabel}
+
{filter.columnName}
{/* 필터 타입 선택 */} + + + + + {columnFilters.map((filter) => ( + + {filter.columnLabel} + + ))} + + +
+ )} +
+ {/* 안내 메시지 */} -
+
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
- -
); }; - diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 1df2a551..e8014327 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -34,7 +34,7 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility } from "@/types/table-options"; +import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { @@ -86,16 +86,29 @@ export const SplitPanelLayoutComponent: React.FC if (columnName.includes(".")) { const [tableName, fieldName] = columnName.split("."); - // 🔍 디버깅: 첫 번째 아이템에서 키 목록 출력 - if (item && typeof item === "object") { - const keys = Object.keys(item); - const matchingKeys = keys.filter((k) => k.includes(fieldName)); - console.log(`🔍 getEntityJoinValue: columnName=${columnName}, fieldName=${fieldName}`); - console.log(" 전체 키 목록:", keys); - console.log(" 매칭 가능한 키들:", matchingKeys); + // 🔍 엔티티 조인 컬럼 값 추출 + // 예: item_info.item_name, item_info.standard, item_info.unit + + // 1️⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등) + const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); + + // 2️⃣ 정확한 키 매핑 시도: 소스컬럼_필드명 + // 예: item_code_item_name, item_code_standard, item_code_unit + const exactKey = `${inferredSourceColumn}_${fieldName}`; + if (item[exactKey] !== undefined) { + return item[exactKey]; } - // entityColumnMap에서 매핑 찾기 (예: item_info → item_code) + // 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용) + // 예: item_code_name (item_name의 별칭) + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + + // 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우) if (entityColumnMap && entityColumnMap[tableName]) { const sourceColumn = entityColumnMap[tableName]; const joinedColumnName = `${sourceColumn}_${fieldName}`; @@ -104,25 +117,7 @@ export const SplitPanelLayoutComponent: React.FC } } - // 모든 키에서 _fieldName으로 끝나는 것 찾기 - for (const key of Object.keys(item)) { - if (key.endsWith(`_${fieldName}`)) { - console.log(` ✅ 매칭됨: ${key} → ${item[key]}`); - return item[key]; - } - } - - // 🆕 엔티티 조인 기본 패턴: 테이블명.컬럼명 → 소스컬럼_name - // 예: item_info.item_name → item_code_name - // tableName에서 소스 컬럼 추론 (item_info → item_code, customer_mng → customer_id 등) - const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id"); - const defaultAliasKey = `${inferredSourceColumn}_name`; - if (item[defaultAliasKey] !== undefined) { - console.log(` ✅ 기본 별칭 매칭: ${defaultAliasKey} → ${item[defaultAliasKey]}`); - return item[defaultAliasKey]; - } - - // 테이블명_컬럼명 형식으로 시도 + // 5️⃣ 테이블명_컬럼명 형식으로 시도 const underscoreKey = `${tableName}_${fieldName}`; if (item[underscoreKey] !== undefined) { return item[underscoreKey]; @@ -140,6 +135,7 @@ export const SplitPanelLayoutComponent: React.FC const [leftGrouping, setLeftGrouping] = useState([]); const [leftColumnVisibility, setLeftColumnVisibility] = useState([]); const [leftColumnOrder, setLeftColumnOrder] = useState([]); // 🔧 컬럼 순서 + const [leftGroupSumConfig, setLeftGroupSumConfig] = useState(null); // 🆕 그룹별 합산 설정 const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); @@ -186,6 +182,88 @@ export const SplitPanelLayoutComponent: React.FC const [leftWidth, setLeftWidth] = useState(splitRatio); const containerRef = React.useRef(null); + // 🆕 그룹별 합산된 데이터 계산 + const summedLeftData = useMemo(() => { + console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); + + // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 + if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { + console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); + return leftData; + } + + const groupByColumn = leftGroupSumConfig.groupByColumn; + const groupMap = new Map(); + + // 조인 컬럼인지 확인하고 실제 키 추론 + const getActualKey = (columnName: string, item: any): string => { + if (columnName.includes(".")) { + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const exactKey = `${inferredSourceColumn}_${fieldName}`; + console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined }); + if (item[exactKey] !== undefined) return exactKey; + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) return aliasKey; + } + } + return columnName; + }; + + // 숫자 타입인지 확인하는 함수 + const isNumericValue = (value: any): boolean => { + if (value === null || value === undefined || value === "") return false; + const num = parseFloat(String(value)); + return !isNaN(num) && isFinite(num); + }; + + // 그룹핑 수행 + leftData.forEach((item) => { + const actualKey = getActualKey(groupByColumn, item); + const groupValue = String(item[actualKey] || item[groupByColumn] || ""); + + // 원본 ID 추출 (id, ID, 또는 첫 번째 값) + const originalId = item.id || item.ID || Object.values(item)[0]; + + if (!groupMap.has(groupValue)) { + // 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열 + groupMap.set(groupValue, { + ...item, + _groupCount: 1, + _originalIds: [originalId], + _originalItems: [item], // 🆕 원본 데이터 전체 저장 + }); + } else { + const existing = groupMap.get(groupValue); + existing._groupCount += 1; + existing._originalIds.push(originalId); + existing._originalItems.push(item); // 🆕 원본 데이터 추가 + + // 모든 키에 대해 숫자면 합산 + Object.keys(item).forEach((key) => { + const value = item[key]; + if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { + const numValue = parseFloat(String(value)); + const existingValue = parseFloat(String(existing[key] || 0)); + existing[key] = existingValue + numValue; + } + }); + + groupMap.set(groupValue, existing); + } + }); + + const result = Array.from(groupMap.values()); + console.log("🔗 [분할패널] 그룹별 합산 결과:", { + 원본개수: leftData.length, + 그룹개수: result.length, + 그룹기준: groupByColumn, + }); + + return result; + }, [leftData, leftGroupSumConfig]); + // 컴포넌트 스타일 // height 처리: 이미 px 단위면 그대로, 숫자면 px 추가 const getHeightValue = () => { @@ -494,14 +572,77 @@ export const SplitPanelLayoutComponent: React.FC // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; + // 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환 + const configuredColumns = componentConfig.leftPanel?.columns || []; + const additionalJoinColumns: Array<{ + sourceTable: string; + sourceColumn: string; + referenceTable: string; + joinAlias: string; + }> = []; + + // 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등) + const sourceColumnMap: Record = {}; + + configuredColumns.forEach((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + if (colName && colName.includes(".")) { + const [refTable, refColumn] = colName.split("."); + // 소스 컬럼 추론 (item_info → item_code) + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + + // 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼) + const existingJoin = additionalJoinColumns.find( + (j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn, + ); + + if (!existingJoin) { + // 새로운 조인 추가 (첫 번째 컬럼) + additionalJoinColumns.push({ + sourceTable: leftTableName, + sourceColumn: inferredSourceColumn, + referenceTable: refTable, + joinAlias: `${inferredSourceColumn}_${refColumn}`, + }); + sourceColumnMap[refTable] = inferredSourceColumn; + } + + // 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등) + // 단, 첫 번째 컬럼과 다른 경우만 + const existingAliases = additionalJoinColumns + .filter((j) => j.referenceTable === refTable) + .map((j) => j.joinAlias); + const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`; + + if (!existingAliases.includes(newAlias)) { + additionalJoinColumns.push({ + sourceTable: leftTableName, + sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn, + referenceTable: refTable, + joinAlias: newAlias, + }); + } + } + }); + + console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); + console.log("🔗 [분할패널] configuredColumns:", configuredColumns); + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, search: filters, // 필터 조건 전달 enableEntityJoin: true, // 엔티티 조인 활성화 dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달 + additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼 }); + // 🔍 디버깅: API 응답 데이터의 키 확인 + if (result.data && result.data.length > 0) { + console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); + console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); + } + // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; if (leftColumn && result.data.length > 0) { @@ -527,6 +668,8 @@ export const SplitPanelLayoutComponent: React.FC } }, [ componentConfig.leftPanel?.tableName, + componentConfig.leftPanel?.columns, + componentConfig.leftPanel?.dataFilter, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, @@ -563,6 +706,68 @@ export const SplitPanelLayoutComponent: React.FC const keys = componentConfig.rightPanel?.relation?.keys; const leftTable = componentConfig.leftPanel?.tableName; + // 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시 + if (leftItem._originalItems && leftItem._originalItems.length > 0) { + console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length); + + // 정렬 기준 컬럼 (복합키의 leftColumn들) + const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || []; + console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns); + + // 정렬 함수 + const sortByKeys = (data: any[]) => { + if (sortColumns.length === 0) return data; + return [...data].sort((a, b) => { + for (const col of sortColumns) { + const aVal = String(a[col] || ""); + const bVal = String(b[col] || ""); + const cmp = aVal.localeCompare(bVal, "ko-KR"); + if (cmp !== 0) return cmp; + } + return 0; + }); + }; + + // 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우) + if (leftTable === rightTableName) { + const sortedData = sortByKeys(leftItem._originalItems); + console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length); + setRightData(sortedData); + return; + } + + // 다른 테이블인 경우: 원본 ID들로 조회 + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const allResults: any[] = []; + + // 각 원본 항목에 대해 조회 + for (const originalItem of leftItem._originalItems) { + const searchConditions: Record = {}; + keys?.forEach((key: any) => { + if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { + searchConditions[key.rightColumn] = originalItem[key.leftColumn]; + } + }); + + if (Object.keys(searchConditions).length > 0) { + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + }); + if (result.data) { + allResults.push(...result.data); + } + } + } + + // 정렬 적용 + const sortedResults = sortByKeys(allResults); + console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); + setRightData(sortedResults); + return; + } + // 🆕 복합키 지원 if (keys && keys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 @@ -703,7 +908,28 @@ export const SplitPanelLayoutComponent: React.FC const uniqueValues = new Set(); leftData.forEach((item) => { - const value = item[columnName]; + // 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard) + let value: any; + + if (columnName.includes(".")) { + // 조인 컬럼: getEntityJoinValue와 동일한 로직 적용 + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + + // 정확한 키로 먼저 시도 + const exactKey = `${inferredSourceColumn}_${fieldName}`; + value = item[exactKey]; + + // 기본 별칭 패턴 시도 (item_code_name) + if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { + const aliasKey = `${inferredSourceColumn}_name`; + value = item[aliasKey]; + } + } else { + // 일반 컬럼 + value = item[columnName]; + } + if (value !== null && value !== undefined && value !== "") { // _name 필드 우선 사용 (category/entity type) const displayValue = item[`${columnName}_name`] || value; @@ -727,6 +953,15 @@ export const SplitPanelLayoutComponent: React.FC const leftTableId = `split-panel-left-${component.id}`; // 🔧 화면에 표시되는 컬럼 사용 (columns 속성) const configuredColumns = componentConfig.leftPanel?.columns || []; + + // 🆕 설정에서 지정한 라벨 맵 생성 + const configuredLabels: Record = {}; + configuredColumns.forEach((col: any) => { + if (typeof col === "object" && col.name && col.label) { + configuredLabels[col.name] = col.label; + } + }); + const displayColumns = configuredColumns .map((col: any) => { if (typeof col === "string") return col; @@ -744,7 +979,8 @@ export const SplitPanelLayoutComponent: React.FC tableName: leftTableName, columns: displayColumns.map((col: string) => ({ columnName: col, - columnLabel: leftColumnLabels[col] || col, + // 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명 + columnLabel: configuredLabels[col] || leftColumnLabels[col] || col, inputType: "text", visible: true, width: 150, @@ -756,6 +992,7 @@ export const SplitPanelLayoutComponent: React.FC onColumnVisibilityChange: setLeftColumnVisibility, onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가 getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가 + onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백 }); return () => unregisterTable(leftTableId); @@ -1712,16 +1949,25 @@ export const SplitPanelLayoutComponent: React.FC ) : ( (() => { + // 🆕 그룹별 합산된 데이터 사용 + const dataSource = summedLeftData; + console.log( + "🔍 [테이블모드 렌더링] dataSource 개수:", + dataSource.length, + "leftGroupSumConfig:", + leftGroupSumConfig, + ); + // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery - ? leftData.filter((item) => { + ? dataSource.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) - : leftData; + : dataSource; // 🔧 가시성 처리된 컬럼 사용 const columnsToShow = @@ -1917,16 +2163,25 @@ export const SplitPanelLayoutComponent: React.FC ) : ( (() => { + // 🆕 그룹별 합산된 데이터 사용 + const dataToDisplay = summedLeftData; + console.log( + "🔍 [렌더링] dataToDisplay 개수:", + dataToDisplay.length, + "leftGroupSumConfig:", + leftGroupSumConfig, + ); + // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery - ? leftData.filter((item) => { + ? dataToDisplay.filter((item) => { const searchLower = leftSearchQuery.toLowerCase(); return Object.entries(item).some(([key, value]) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(searchLower); }); }) - : leftData; + : dataToDisplay; // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { @@ -2174,23 +2429,53 @@ export const SplitPanelLayoutComponent: React.FC if (isTableMode) { // 테이블 모드 렌더링 const displayColumns = componentConfig.rightPanel?.columns || []; - const columnsToShow = - displayColumns.length > 0 - ? displayColumns.map((col) => ({ - ...col, - label: rightColumnLabels[col.name] || col.label || col.name, - format: col.format, // 🆕 포맷 설정 유지 - })) - : Object.keys(filteredData[0] || {}) - .filter((key) => shouldShowField(key)) - .slice(0, 5) - .map((key) => ({ - name: key, - label: rightColumnLabels[key] || key, - width: 150, - align: "left" as const, - format: undefined, // 🆕 기본값 - })); + + // 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시 + const relationKeys = componentConfig.rightPanel?.relation?.keys || []; + const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean); + const isGroupedMode = selectedLeftItem?._originalItems?.length > 0; + + let columnsToShow: any[] = []; + + if (displayColumns.length > 0) { + // 설정된 컬럼 사용 + columnsToShow = displayColumns.map((col) => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + })); + + // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 + if (isGroupedMode && keyColumns.length > 0) { + const existingColNames = columnsToShow.map((c) => c.name); + const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k)); + + if (missingKeyColumns.length > 0) { + const keyColsToAdd = missingKeyColumns.map((colName: string) => ({ + name: colName, + label: rightColumnLabels[colName] || colName, + width: 120, + align: "left" as const, + format: undefined, + _isKeyColumn: true, // 구분용 플래그 + })); + columnsToShow = [...keyColsToAdd, ...columnsToShow]; + console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); + } + } + } else { + // 기본 컬럼 자동 생성 + columnsToShow = Object.keys(filteredData[0] || {}) + .filter((key) => shouldShowField(key)) + .slice(0, 5) + .map((key) => ({ + name: key, + label: rightColumnLabels[key] || key, + width: 150, + align: "left" as const, + format: undefined, + })); + } return (
diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index c9971710..5685f127 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -7,21 +7,21 @@ */ export interface TableFilter { columnName: string; - operator: - | "equals" - | "contains" - | "startsWith" - | "endsWith" - | "gt" - | "lt" - | "gte" - | "lte" - | "notEquals"; + operator: "equals" | "contains" | "startsWith" | "endsWith" | "gt" | "lt" | "gte" | "lte" | "notEquals"; value: string | number | boolean; filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입 width?: number; // 필터 입력 필드 너비 (px) } +/** + * 그룹별 합산 설정 + */ +export interface GroupSumConfig { + enabled: boolean; // 그룹핑 활성화 여부 + groupByColumn: string; // 그룹 기준 컬럼 + groupByColumnLabel?: string; // 그룹 기준 컬럼 라벨 (UI 표시용) +} + /** * 컬럼 표시 설정 */ @@ -60,7 +60,8 @@ export interface TableRegistration { onFilterChange: (filters: TableFilter[]) => void; onGroupChange: (groups: string[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; - + onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경 + // 데이터 조회 함수 (선택 타입 필터용) getColumnUniqueValues?: (columnName: string) => Promise>; } @@ -77,4 +78,3 @@ export interface TableOptionsContextValue { selectedTableId: string | null; setSelectedTableId: (tableId: string | null) => void; } - From a4cf11264db95cd77ebe5ba2f69d61647182ba9f Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 11 Dec 2025 14:32:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=95=A9=EC=82=B0=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 3006 +++++++++-------- 1 file changed, 1608 insertions(+), 1398 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9fec8fc5..4c6c2dd7 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -47,18 +47,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; import { useTableOptions } from "@/contexts/TableOptionsContext"; -import { TableFilter, ColumnVisibility } from "@/types/table-options"; +import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; @@ -145,18 +141,18 @@ const debouncedApiCall = (key: string, fn: (...args: T) => P interface FilterCondition { id: string; column: string; - operator: - | "equals" - | "notEquals" - | "contains" - | "notContains" - | "startsWith" - | "endsWith" - | "greaterThan" - | "lessThan" - | "greaterOrEqual" - | "lessOrEqual" - | "isEmpty" + operator: + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "lessThan" + | "greaterOrEqual" + | "lessOrEqual" + | "isEmpty" | "isNotEmpty"; value: string; } @@ -299,7 +295,7 @@ export const TableListComponent: React.FC = ({ // 화면 컨텍스트 (데이터 제공자로 등록) const screenContext = useScreenContextOptional(); - + // 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록) const splitPanelContext = useSplitPanelContext(); // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) @@ -322,12 +318,12 @@ export const TableListComponent: React.FC = ({ newSearchValues[filter.columnName] = filter.value; } }); - + // console.log("🔍 [TableListComponent] filters → searchValues:", { // filters: filters.length, // searchValues: newSearchValues, // }); - + setSearchValues(newSearchValues); setCurrentPage(1); // 필터 변경 시 첫 페이지로 }, [filters]); @@ -342,7 +338,7 @@ export const TableListComponent: React.FC = ({ if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; const savedSettings = localStorage.getItem(storageKey); - + if (savedSettings) { try { const parsed = JSON.parse(savedSettings) as ColumnVisibility[]; @@ -357,11 +353,9 @@ export const TableListComponent: React.FC = ({ // columnVisibility 변경 시 컬럼 순서 및 가시성 적용 useEffect(() => { if (columnVisibility.length > 0) { - const newOrder = columnVisibility - .map((cv) => cv.columnName) - .filter((name) => name !== "__checkbox__"); // 체크박스 제외 + const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외 setColumnOrder(newOrder); - + // localStorage에 저장 (사용자별) if (tableConfig.selectedTable && currentUserId) { const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`; @@ -428,18 +422,18 @@ export const TableListComponent: React.FC = ({ const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) - const [headerFilters, setHeaderFilters] = useState>>({}); + const [headerFilters, setHeaderFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 const [filterGroups, setFilterGroups] = useState([]); - + // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 const filteredData = useMemo(() => { let result = data; - + // 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링 if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; @@ -448,17 +442,17 @@ export const TableListComponent: React.FC = ({ return !addedIds.has(rowId); }); } - + // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { if (values.size === 0) return true; - + // 여러 가능한 컬럼명 시도 const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - + return values.has(cellStr); }); }); @@ -469,11 +463,11 @@ export const TableListComponent: React.FC = ({ result = result.filter((row) => { return filterGroups.every((group) => { const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), ); if (validConditions.length === 0) return true; - const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { + const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => { const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; const condValue = condition.value.toLowerCase(); @@ -515,7 +509,7 @@ export const TableListComponent: React.FC = ({ }); }); } - + return result; }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); @@ -561,9 +555,9 @@ export const TableListComponent: React.FC = ({ const tableContainerRef = useRef(null); // 🆕 인라인 셀 편집 관련 상태 - const [editingCell, setEditingCell] = useState<{ - rowIndex: number; - colIndex: number; + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; columnName: string; originalValue: any; } | null>(null); @@ -572,13 +566,18 @@ export const TableListComponent: React.FC = ({ // 🆕 배치 편집 관련 상태 const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드 - const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + const [pendingChanges, setPendingChanges] = useState< + Map< + string, + { + rowIndex: number; + columnName: string; + originalValue: any; + newValue: any; + primaryKeyValue: any; + } + > + >(new Map()); // key: `${rowIndex}-${columnName}` const [localEditedData, setLocalEditedData] = useState>>({}); // 로컬 수정 데이터 // 🆕 유효성 검사 관련 상태 @@ -610,6 +609,9 @@ export const TableListComponent: React.FC = ({ const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // 🆕 그룹별 합산 설정 상태 + const [groupSumConfig, setGroupSumConfig] = useState(null); + // 🆕 Master-Detail 관련 상태 const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 @@ -639,7 +641,9 @@ export const TableListComponent: React.FC = ({ // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); - const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); + const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">( + "disconnected", + ); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); @@ -670,7 +674,7 @@ export const TableListComponent: React.FC = ({ // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; - + if (!linkedFilters || linkedFilters.length === 0 || !screenContext) { return; } @@ -689,7 +693,7 @@ export const TableListComponent: React.FC = ({ if (selectedData && selectedData.length > 0) { const sourceField = filter.sourceField || "value"; const value = selectedData[0][sourceField]; - + if (value !== linkedFilterValues[filter.targetColumn]) { newFilterValues[filter.targetColumn] = value; hasChanges = true; @@ -703,13 +707,13 @@ export const TableListComponent: React.FC = ({ if (hasChanges) { console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues); setLinkedFilterValues(newFilterValues); - + // searchValues에 연결된 필터 값 병합 - setSearchValues(prev => ({ + setSearchValues((prev) => ({ ...prev, - ...newFilterValues + ...newFilterValues, })); - + // 첫 페이지로 이동 setCurrentPage(1); } @@ -730,7 +734,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", - + getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) const selectedData = filteredData.filter((row) => { @@ -739,12 +743,12 @@ export const TableListComponent: React.FC = ({ }); return selectedData; }, - + getAllData: () => { // 🆕 필터링된 데이터 반환 return filteredData; }, - + clearSelection: () => { setSelectedRows(new Set()); setIsAllSelected(false); @@ -755,7 +759,7 @@ export const TableListComponent: React.FC = ({ const dataReceiver: DataReceivable = { componentId: component.id, componentType: "table", - + receiveData: async (receivedData: any[], config: DataReceiverConfig) => { console.log("📥 TableList 데이터 수신:", { componentId: component.id, @@ -782,8 +786,8 @@ export const TableListComponent: React.FC = ({ case "merge": // 기존 데이터와 병합 (ID 기반) - const existingMap = new Map(data.map(item => [item.id, item])); - receivedData.forEach(item => { + const existingMap = new Map(data.map((item) => [item.id, item])); + receivedData.forEach((item) => { if (item.id && existingMap.has(item.id)) { // 기존 데이터 업데이트 existingMap.set(item.id, { ...existingMap.get(item.id), ...item }); @@ -799,7 +803,7 @@ export const TableListComponent: React.FC = ({ // 상태 업데이트 setData(newData); - + // 총 아이템 수 업데이트 setTotalItems(newData.length); @@ -809,7 +813,7 @@ export const TableListComponent: React.FC = ({ throw error; } }, - + getData: () => { return data; }, @@ -820,18 +824,19 @@ export const TableListComponent: React.FC = ({ if (screenContext && component.id) { screenContext.registerDataProvider(component.id, dataProvider); screenContext.registerDataReceiver(component.id, dataReceiver); - + return () => { screenContext.unregisterDataProvider(component.id); screenContext.unregisterDataReceiver(component.id); }; } }, [screenContext, component.id, data, selectedRows]); - + // 분할 패널 컨텍스트에 데이터 수신자로 등록 // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) - const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; - + const currentSplitPosition = + splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null; + useEffect(() => { if (splitPanelContext && component.id && currentSplitPosition) { const splitPanelReceiver = { @@ -843,7 +848,7 @@ export const TableListComponent: React.FC = ({ mode, position: currentSplitPosition, }); - + await dataReceiver.receiveData(incomingData, { targetComponentId: component.id, targetComponentType: "table-list", @@ -852,9 +857,9 @@ export const TableListComponent: React.FC = ({ }); }, }; - + splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver); - + return () => { splitPanelContext.unregisterReceiver(currentSplitPosition, component.id); }; @@ -863,11 +868,12 @@ export const TableListComponent: React.FC = ({ // 테이블 등록 (Context에 등록) const tableId = `table-list-${component.id}`; - + useEffect(() => { // tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음) - const columnsToRegister = (tableConfig.columns || []) - .filter((col) => col.visible !== false && col.columnName !== "__checkbox__"); + const columnsToRegister = (tableConfig.columns || []).filter( + (col) => col.visible !== false && col.columnName !== "__checkbox__", + ); if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) { return; @@ -884,7 +890,7 @@ export const TableListComponent: React.FC = ({ const meta = columnMeta[columnName]; const inputType = meta?.inputType || "text"; - + // 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API) if (inputType === "category") { try { @@ -892,25 +898,23 @@ export const TableListComponent: React.FC = ({ tableName: tableConfig.selectedTable, columnName, }); - + // API 클라이언트 사용 (쿠키 인증 자동 처리) const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get( - `/table-categories/${tableConfig.selectedTable}/${columnName}/values` - ); - + const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); + if (response.data.success && response.data.data) { const categoryOptions = response.data.data.map((item: any) => ({ - value: item.valueCode, // 카멜케이스 + value: item.valueCode, // 카멜케이스 label: item.valueLabel, // 카멜케이스 })); - + console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", { columnName, count: categoryOptions.length, options: categoryOptions, }); - + return categoryOptions; } else { console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data); @@ -926,7 +930,7 @@ export const TableListComponent: React.FC = ({ // 에러 시 현재 데이터 기반으로 fallback } } - + // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; @@ -942,7 +946,7 @@ export const TableListComponent: React.FC = ({ // 현재 로드된 데이터에서 고유 값 추출 const uniqueValuesMap = new Map(); // value -> label - + data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { @@ -990,6 +994,7 @@ export const TableListComponent: React.FC = ({ onGroupChange: setGrouping, onColumnVisibilityChange: setColumnVisibility, getColumnUniqueValues, // 고유 값 조회 함수 등록 + onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정 }; registerTable(registration); @@ -1006,7 +1011,7 @@ export const TableListComponent: React.FC = ({ columnWidths, tableLabel, data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) - totalItems, // 전체 항목 수가 변경되면 재등록 + totalItems, // 전체 항목 수가 변경되면 재등록 registerTable, unregisterTable, ]); @@ -1226,7 +1231,7 @@ export const TableListComponent: React.FC = ({ const cols = Object.entries(columnMeta) .filter(([_, meta]) => meta.inputType === "category") .map(([columnName, _]) => columnName); - + return cols; }, [columnMeta]); @@ -1251,7 +1256,7 @@ export const TableListComponent: React.FC = ({ // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 let targetTable = tableConfig.selectedTable; let targetColumn = columnName; - + if (columnName.includes(".")) { const parts = columnName.split("."); targetTable = parts[0]; // 조인된 테이블명 (예: item_info) @@ -1278,7 +1283,7 @@ export const TableListComponent: React.FC = ({ if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { // valueCode를 문자열로 변환하여 키로 사용 const key = String(item.valueCode); @@ -1288,7 +1293,7 @@ export const TableListComponent: React.FC = ({ }; console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`); }); - + if (Object.keys(mapping).length > 0) { // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; @@ -1321,33 +1326,35 @@ export const TableListComponent: React.FC = ({ // 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드 // 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출 - const joinedColumns = tableConfig.columns - ?.filter((col) => col.columnName?.includes(".")) - .map((col) => col.columnName) || []; - + const joinedColumns = + tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || []; + // 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material) - const additionalJoinColumns = tableConfig.columns - ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) - .map((col: any) => ({ - columnName: col.columnName, // 예: item_code_material - referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info - // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) - actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName, - })) || []; - + const additionalJoinColumns = + tableConfig.columns + ?.filter((col: any) => col.additionalJoinInfo?.referenceTable) + .map((col: any) => ({ + columnName: col.columnName, // 예: item_code_material + referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info + // joinAlias에서 실제 컬럼명 추출 (item_code_material → material) + actualColumn: + col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") || + col.columnName, + })) || []; + console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); - + // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; - + // "테이블명.컬럼명" 형태 처리 for (const joinedColumn of joinedColumns) { const parts = joinedColumn.split("."); if (parts.length !== 2) continue; - + const joinedTable = parts[0]; const joinedColumnName = parts[1]; - + if (!joinedTableColumns[joinedTable]) { joinedTableColumns[joinedTable] = []; } @@ -1356,7 +1363,7 @@ export const TableListComponent: React.FC = ({ actualColumn: joinedColumnName, }); } - + // additionalJoinInfo 형태 처리 for (const col of additionalJoinColumns) { if (!joinedTableColumns[col.referenceTable]) { @@ -1367,41 +1374,43 @@ export const TableListComponent: React.FC = ({ actualColumn: col.actualColumn, // 예: material }); } - + console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); - + // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; - + for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) { try { // 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용) const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable); - + console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes); - + for (const col of columns) { const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn); - + // 컬럼명 그대로 저장 (item_code_material 또는 item_info.material) newJoinedColumnMeta[col.columnName] = { inputType: inputTypeInfo?.inputType, }; - - console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`); - + + console.log( + ` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`, + ); + // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, { url: `/table-categories/${joinedTable}/${col.actualColumn}/values`, }); - + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -1409,7 +1418,7 @@ export const TableListComponent: React.FC = ({ color: item.color, }; }); - + if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { @@ -1426,7 +1435,7 @@ export const TableListComponent: React.FC = ({ console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error); } } - + // 조인 컬럼 메타데이터 상태 업데이트 if (Object.keys(newJoinedColumnMeta).length > 0) { setJoinedColumnMeta(newJoinedColumnMeta); @@ -1438,7 +1447,7 @@ export const TableListComponent: React.FC = ({ mappingsKeys: Object.keys(mappings), mappings, }); - + if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); @@ -1452,7 +1461,12 @@ export const TableListComponent: React.FC = ({ }; loadCategoryMappings(); - }, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성 + }, [ + tableConfig.selectedTable, + categoryColumns.length, + JSON.stringify(categoryColumns), + JSON.stringify(tableConfig.columns), + ]); // 더 명확한 의존성 // ======================================== // 데이터 가져오기 @@ -1464,7 +1478,7 @@ export const TableListComponent: React.FC = ({ isDesignMode, currentPage, }); - + if (!tableConfig.selectedTable || isDesignMode) { setData([]); setTotalPages(0); @@ -1481,34 +1495,35 @@ export const TableListComponent: React.FC = ({ const sortBy = sortColumn || undefined; const sortOrder = sortDirection; const search = searchTerm || undefined; - + // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) let linkedFilterValues: Record = {}; let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - + console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { hasSplitPanelContext: !!splitPanelContext, tableName: tableConfig.selectedTable, selectedLeftData: splitPanelContext?.selectedLeftData, linkedFilters: splitPanelContext?.linkedFilters, }); - + if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) const linkedFiltersConfig = splitPanelContext.linkedFilters || []; hasLinkedFiltersConfigured = linkedFiltersConfig.some( - (filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || - filter.targetColumn === tableConfig.selectedTable + (filter) => + filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || + filter.targetColumn === tableConfig.selectedTable, ); - + // 좌측 데이터 선택 여부 확인 - hasSelectedLeftData = splitPanelContext.selectedLeftData && - Object.keys(splitPanelContext.selectedLeftData).length > 0; - + hasSelectedLeftData = + splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; + const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); - + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) for (const [key, value] of Object.entries(allLinkedFilters)) { if (key.includes(".")) { @@ -1526,7 +1541,7 @@ export const TableListComponent: React.FC = ({ console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); } } - + // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { @@ -1536,7 +1551,7 @@ export const TableListComponent: React.FC = ({ setLoading(false); return; } - + // 검색 필터와 연결 필터 병합 const filters = { ...(Object.keys(searchValues).length > 0 ? searchValues : {}), @@ -1545,18 +1560,19 @@ export const TableListComponent: React.FC = ({ const hasFilters = Object.keys(filters).length > 0; // 🆕 REST API 데이터 소스 처리 - const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); - + const isRestApiTable = + tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); + let response: any; - + if (isRestApiTable) { // REST API 데이터 소스인 경우 const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/); const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; - + if (connectionId) { console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId }); - + // REST API 연결 정보 가져오기 및 데이터 조회 const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); const restApiData = await ExternalRestApiConnectionAPI.fetchData( @@ -1564,16 +1580,16 @@ export const TableListComponent: React.FC = ({ undefined, // endpoint - 연결 정보에서 가져옴 "response", // jsonPath - 기본값 response ); - + response = { data: restApiData.rows || [], total: restApiData.total || restApiData.rows?.length || 0, totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), }; - - console.log("✅ [TableList] REST API 응답:", { - dataLength: response.data.length, - total: response.total + + console.log("✅ [TableList] REST API 응답:", { + dataLength: response.data.length, + total: response.total, }); } else { throw new Error("REST API 연결 ID를 찾을 수 없습니다."); @@ -1604,114 +1620,114 @@ 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 값 가져오기:", { + // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) + 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, }); } - } - // 3순위: 분할 패널 부모 데이터에서 값 가져오기 - if (!filterValue && splitPanelContext?.selectedLeftData) { - filterValue = splitPanelContext.selectedLeftData[fieldName]; - if (filterValue) { - console.log("🔗 [TableList] 분할패널에서 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, - size: pageSize, - sortBy, - sortOrder, - search: hasFilters ? filters : undefined, - enableEntityJoin: true, - additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, - screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 - dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 - excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 - }); - - // 실제 데이터의 item_number만 추출하여 중복 확인 - const itemNumbers = (response.data || []).map((item: any) => item.item_number); - const uniqueItemNumbers = [...new Set(itemNumbers)]; - - // console.log("✅ [TableList] API 응답 받음"); - // console.log(` - dataLength: ${response.data?.length || 0}`); - // console.log(` - total: ${response.total}`); - // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); - // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); - // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); - - setData(response.data || []); - setTotalPages(response.totalPages || 0); - setTotalItems(response.total || 0); - setError(null); - - // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) - // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) - const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - const labels: Record = {}; - cols.forEach((col) => { - labels[col.columnName] = columnLabels[col.columnName] || col.columnName; - }); - - tableDisplayStore.setTableData( - tableConfig.selectedTable, - response.data || [], - cols.map((col) => col.columnName), - sortBy, - sortOrder, - { - filterConditions: filters, - searchTerm: search, - visibleColumns: cols.map((col) => col.columnName), - columnLabels: labels, - currentPage: page, - pageSize: pageSize, - totalItems: response.total || 0, + if (filterValue || !excludeConfig.filterColumn) { + excludeFilterParam = { + enabled: true, + referenceTable: excludeConfig.referenceTable, + referenceColumn: excludeConfig.referenceColumn, + sourceColumn: excludeConfig.sourceColumn, + filterColumn: excludeConfig.filterColumn, + filterValue: filterValue, + }; + console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam); + } } - ); + + // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) + response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { + page, + size: pageSize, + sortBy, + sortOrder, + search: hasFilters ? filters : undefined, + enableEntityJoin: true, + additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달 + dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달 + }); + + // 실제 데이터의 item_number만 추출하여 중복 확인 + const itemNumbers = (response.data || []).map((item: any) => item.item_number); + const uniqueItemNumbers = [...new Set(itemNumbers)]; + + // console.log("✅ [TableList] API 응답 받음"); + // console.log(` - dataLength: ${response.data?.length || 0}`); + // console.log(` - total: ${response.total}`); + // console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`); + // console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`); + // console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`); + + setData(response.data || []); + setTotalPages(response.totalPages || 0); + setTotalItems(response.total || 0); + setError(null); + + // 🎯 Store에 필터 조건 저장 (엑셀 다운로드용) + // tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); + const labels: Record = {}; + cols.forEach((col) => { + labels[col.columnName] = columnLabels[col.columnName] || col.columnName; + }); + + tableDisplayStore.setTableData( + tableConfig.selectedTable, + response.data || [], + cols.map((col) => col.columnName), + sortBy, + sortOrder, + { + filterConditions: filters, + searchTerm: search, + visibleColumns: cols.map((col) => col.columnName), + columnLabels: labels, + currentPage: page, + pageSize: pageSize, + totalItems: response.total || 0, + }, + ); } } catch (err: any) { console.error("데이터 가져오기 실패:", err); @@ -1779,10 +1795,13 @@ export const TableListComponent: React.FC = ({ if (tableConfig.selectedTable && userId) { const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; try { - localStorage.setItem(storageKey, JSON.stringify({ - column: newSortColumn, - direction: newSortDirection - })); + localStorage.setItem( + storageKey, + JSON.stringify({ + column: newSortColumn, + direction: newSortDirection, + }), + ); console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection }); } catch (error) { console.error("❌ 정렬 상태 저장 실패:", error); @@ -1864,16 +1883,16 @@ export const TableListComponent: React.FC = ({ // 전역 저장소에 정렬된 데이터 저장 if (tableConfig.selectedTable) { - const cleanColumnOrder = ( - columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) - ).filter((col) => col !== "__checkbox__"); - + const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)).filter( + (col) => col !== "__checkbox__", + ); + // 컬럼 라벨 정보도 함께 저장 const labels: Record = {}; cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); - + tableDisplayStore.setTableData( tableConfig.selectedTable, reorderedData, @@ -1952,7 +1971,7 @@ export const TableListComponent: React.FC = ({ originalData: row, additionalData: {}, })); - + useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 데이터 저장:", { dataSourceId: tableConfig.selectedTable, @@ -1996,7 +2015,7 @@ export const TableListComponent: React.FC = ({ originalData: row, additionalData: {}, })); - + useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems); console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", { dataSourceId: tableConfig.selectedTable, @@ -2067,21 +2086,24 @@ export const TableListComponent: React.FC = ({ }; // 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용 - const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { - // 체크박스 컬럼은 편집 불가 - if (columnName === "__checkbox__") return; + const handleCellDoubleClick = useCallback( + (rowIndex: number, colIndex: number, columnName: string, value: any) => { + // 체크박스 컬럼은 편집 불가 + if (columnName === "__checkbox__") return; - // 🆕 편집 불가 컬럼 체크 - const column = visibleColumns.find((col) => col.columnName === columnName); - if (column?.editable === false) { - toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); - return; - } + // 🆕 편집 불가 컬럼 체크 + const column = visibleColumns.find((col) => col.columnName === columnName); + if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`); + return; + } - setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); - setEditingValue(value !== null && value !== undefined ? String(value) : ""); - setFocusedCell({ rowIndex, colIndex }); - }, [visibleColumns]); + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + setFocusedCell({ rowIndex, colIndex }); + }, + [visibleColumns], + ); // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); @@ -2089,25 +2111,25 @@ export const TableListComponent: React.FC = ({ // 🆕 각 컬럼의 고유값 목록 계산 const columnUniqueValues = useMemo(() => { const result: Record = {}; - + if (data.length === 0) return result; (tableConfig.columns || []).forEach((column: { columnName: string }) => { if (column.columnName === "__checkbox__") return; - + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const values = new Set(); - + data.forEach((row) => { const val = row[mappedColumnName]; if (val !== null && val !== undefined && val !== "") { values.add(String(val)); } }); - + result[column.columnName] = Array.from(values).sort(); }); - + return result; }, [data, tableConfig.columns, joinColumnMapping]); @@ -2116,13 +2138,13 @@ export const TableListComponent: React.FC = ({ setHeaderFilters((prev) => { const current = prev[columnName] || new Set(); const newSet = new Set(current); - + if (newSet.has(value)) { newSet.delete(value); } else { newSet.add(value); } - + return { ...prev, [columnName]: newSet }; }); }, []); @@ -2147,14 +2169,14 @@ export const TableListComponent: React.FC = ({ // 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } const summaryConfig = useMemo(() => { const config: Record = {}; - + // tableConfig에서 summary 설정 읽기 if (tableConfig.summaries) { tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { config[summary.columnName] = { type: summary.type, label: summary.label }; }); } - + return config; }, [tableConfig.summaries]); @@ -2308,7 +2330,16 @@ export const TableListComponent: React.FC = ({ } cancelEditing(); - }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); + }, [ + editingCell, + editingValue, + data, + tableConfig.selectedTable, + tableConfig.primaryKey, + cancelEditing, + editMode, + pendingChanges.size, + ]); // 🆕 배치 저장: 모든 변경사항 한번에 저장 const saveBatchChanges = useCallback(async () => { @@ -2329,7 +2360,7 @@ export const TableListComponent: React.FC = ({ keyValue: change.primaryKeyValue, updateField: change.columnName, updateValue: change.newValue, - }) + }), ); await Promise.all(savePromises); @@ -2358,78 +2389,86 @@ export const TableListComponent: React.FC = ({ }, [pendingChanges.size]); // 🆕 특정 셀이 수정되었는지 확인 - const isCellModified = useCallback((rowIndex: number, columnName: string) => { - return pendingChanges.has(`${rowIndex}-${columnName}`); - }, [pendingChanges]); + const isCellModified = useCallback( + (rowIndex: number, columnName: string) => { + return pendingChanges.has(`${rowIndex}-${columnName}`); + }, + [pendingChanges], + ); // 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선) - const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { - const localValue = localEditedData[rowIndex]?.[columnName]; - if (localValue !== undefined) { - return localValue; - } - return row[columnName]; - }, [localEditedData]); + const getDisplayValue = useCallback( + (row: any, rowIndex: number, columnName: string) => { + const localValue = localEditedData[rowIndex]?.[columnName]; + if (localValue !== undefined) { + return localValue; + } + return row[columnName]; + }, + [localEditedData], + ); // 🆕 유효성 검사 함수 - const validateValue = useCallback(( - value: any, - columnName: string, - row: any - ): string | null => { - // tableConfig.validation에서 컬럼별 규칙 가져오기 - const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; - if (!rules) return null; + const validateValue = useCallback( + (value: any, columnName: string, row: any): string | null => { + // tableConfig.validation에서 컬럼별 규칙 가져오기 + const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; + if (!rules) return null; - const strValue = value !== null && value !== undefined ? String(value) : ""; - const numValue = parseFloat(strValue); + const strValue = value !== null && value !== undefined ? String(value) : ""; + const numValue = parseFloat(strValue); - // 필수 검사 - if (rules.required && (!strValue || strValue.trim() === "")) { - return rules.customMessage || "필수 입력 항목입니다."; - } + // 필수 검사 + if (rules.required && (!strValue || strValue.trim() === "")) { + return rules.customMessage || "필수 입력 항목입니다."; + } - // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) - if (!strValue || strValue.trim() === "") return null; + // 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우) + if (!strValue || strValue.trim() === "") return null; - // 최소값 검사 - if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { - return rules.customMessage || `최소값은 ${rules.min}입니다.`; - } + // 최소값 검사 + if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { + return rules.customMessage || `최소값은 ${rules.min}입니다.`; + } - // 최대값 검사 - if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { - return rules.customMessage || `최대값은 ${rules.max}입니다.`; - } + // 최대값 검사 + if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { + return rules.customMessage || `최대값은 ${rules.max}입니다.`; + } - // 최소 길이 검사 - if (rules.minLength !== undefined && strValue.length < rules.minLength) { - return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; - } + // 최소 길이 검사 + if (rules.minLength !== undefined && strValue.length < rules.minLength) { + return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`; + } - // 최대 길이 검사 - if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { - return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; - } + // 최대 길이 검사 + if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { + return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`; + } - // 패턴 검사 - if (rules.pattern && !rules.pattern.test(strValue)) { - return rules.customMessage || "입력 형식이 올바르지 않습니다."; - } + // 패턴 검사 + if (rules.pattern && !rules.pattern.test(strValue)) { + return rules.customMessage || "입력 형식이 올바르지 않습니다."; + } - // 커스텀 검증 - if (rules.validate) { - const customError = rules.validate(value, row); - if (customError) return customError; - } + // 커스텀 검증 + if (rules.validate) { + const customError = rules.validate(value, row); + if (customError) return customError; + } - return null; - }, [tableConfig]); + return null; + }, + [tableConfig], + ); // 🆕 셀 유효성 에러 여부 확인 - const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { - return validationErrors.get(`${rowIndex}-${columnName}`) || null; - }, [validationErrors]); + const getCellValidationError = useCallback( + (rowIndex: number, columnName: string): string | null => { + return validationErrors.get(`${rowIndex}-${columnName}`) || null; + }, + [validationErrors], + ); // 🆕 유효성 검사 에러 설정 const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { @@ -2451,141 +2490,158 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 Excel 내보내기 함수 - const exportToExcel = useCallback((exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 (선택된 행만 또는 전체) - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - // 선택된 행만 내보내기 - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } + const exportToExcel = useCallback( + (exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 (선택된 행만 또는 전체) + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + // 선택된 행만 내보내기 + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); - return; - } + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 헤더 행 생성 - const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); - - // 데이터 행 생성 - const rows = exportData.map((row) => { - return exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - const value = row[mappedColumnName]; - - // 카테고리 매핑된 값 처리 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) { - return mapping.label; + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 헤더 행 생성 + const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); + + // 데이터 행 생성 + const rows = exportData.map((row) => { + return exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + const value = row[mappedColumnName]; + + // 카테고리 매핑된 값 처리 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) { + return mapping.label; + } } - } - - // null/undefined 처리 - if (value === null || value === undefined) { - return ""; - } - - return value; + + // null/undefined 처리 + if (value === null || value === undefined) { + return ""; + } + + return value; + }); }); - }); - // 워크시트 생성 - const wsData = [headers, ...rows]; - const ws = XLSX.utils.aoa_to_sheet(wsData); + // 워크시트 생성 + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); - // 컬럼 너비 자동 조정 - const colWidths = exportColumns.map((col, idx) => { - const headerLength = headers[idx]?.length || 10; - const maxDataLength = Math.max( - ...rows.map((row) => String(row[idx] ?? "").length) - ); - return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; - }); - ws["!cols"] = colWidths; + // 컬럼 너비 자동 조정 + const colWidths = exportColumns.map((col, idx) => { + const headerLength = headers[idx]?.length || 10; + const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length)); + return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; + }); + ws["!cols"] = colWidths; - // 워크북 생성 - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); + // 워크북 생성 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); - // 파일명 생성 - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + // 파일명 생성 + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; - // 파일 다운로드 - XLSX.writeFile(wb, fileName); + // 파일 다운로드 + XLSX.writeFile(wb, fileName); - toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); - console.log("✅ Excel 내보내기 완료:", fileName); - } catch (error) { - console.error("❌ Excel 내보내기 실패:", error); - toast.error("Excel 내보내기 중 오류가 발생했습니다."); - } - }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); + toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); + console.log("✅ Excel 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ Excel 내보내기 실패:", error); + toast.error("Excel 내보내기 중 오류가 발생했습니다."); + } + }, + [ + filteredData, + selectedRows, + visibleColumns, + columnLabels, + joinColumnMapping, + categoryMappings, + tableLabel, + tableConfig.selectedTable, + getRowKey, + ], + ); // 🆕 행 확장/축소 토글 - const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { - setExpandedRows((prev) => { - const newSet = new Set(prev); - if (newSet.has(rowKey)) { - newSet.delete(rowKey); - } else { - newSet.add(rowKey); - // 상세 데이터 로딩 (아직 없는 경우) - if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { - loadDetailData(rowKey, row); + const toggleRowExpand = useCallback( + async (rowKey: string, row: any) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + // 상세 데이터 로딩 (아직 없는 경우) + if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { + loadDetailData(rowKey, row); + } } - } - return newSet; - }); - }, [detailData, tableConfig]); + return newSet; + }); + }, + [detailData, tableConfig], + ); // 🆕 상세 데이터 로딩 - const loadDetailData = useCallback(async (rowKey: string, row: any) => { - const masterDetailConfig = (tableConfig as any).masterDetail; - if (!masterDetailConfig?.detailTable) return; + const loadDetailData = useCallback( + async (rowKey: string, row: any) => { + const masterDetailConfig = (tableConfig as any).masterDetail; + if (!masterDetailConfig?.detailTable) return; - try { - const { apiClient } = await import("@/lib/api/client"); - - // masterKey 값 가져오기 - const masterKeyField = masterDetailConfig.masterKey || "id"; - const masterKeyValue = row[masterKeyField]; - - // 상세 테이블에서 데이터 조회 - const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { - page: 1, - size: 100, - search: { - [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, - }, - autoFilter: true, - }); + try { + const { apiClient } = await import("@/lib/api/client"); - const details = response.data?.data?.data || []; - - setDetailData((prev) => ({ - ...prev, - [rowKey]: details, - })); - - console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); - } catch (error) { - console.error("❌ 상세 데이터 로딩 실패:", error); - setDetailData((prev) => ({ - ...prev, - [rowKey]: [], - })); - } - }, [tableConfig]); + // masterKey 값 가져오기 + const masterKeyField = masterDetailConfig.masterKey || "id"; + const masterKeyValue = row[masterKeyField]; + + // 상세 테이블에서 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { + page: 1, + size: 100, + search: { + [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, + }, + autoFilter: true, + }); + + const details = response.data?.data?.data || []; + + setDetailData((prev) => ({ + ...prev, + [rowKey]: details, + })); + + console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length }); + } catch (error) { + console.error("❌ 상세 데이터 로딩 실패:", error); + setDetailData((prev) => ({ + ...prev, + [rowKey]: [], + })); + } + }, + [tableConfig], + ); // 🆕 모든 행 확장/축소 const expandAllRows = useCallback(() => { @@ -2605,22 +2661,22 @@ export const TableListComponent: React.FC = ({ if (!bands || bands.length === 0) return null; // 각 band의 시작 인덱스와 colspan 계산 - const bandInfo = bands.map((band) => { - const visibleBandColumns = band.columns.filter((colName) => - visibleColumns.some((vc) => vc.columnName === colName) - ); - - const startIndex = visibleColumns.findIndex( - (vc) => visibleBandColumns.includes(vc.columnName) - ); + const bandInfo = bands + .map((band) => { + const visibleBandColumns = band.columns.filter((colName) => + visibleColumns.some((vc) => vc.columnName === colName), + ); - return { - caption: band.caption, - columns: visibleBandColumns, - colSpan: visibleBandColumns.length, - startIndex, - }; - }).filter((b) => b.colSpan > 0); + const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName)); + + return { + caption: band.caption, + columns: visibleBandColumns, + colSpan: visibleBandColumns.length, + startIndex, + }; + }) + .filter((b) => b.colSpan > 0); // Band에 포함되지 않은 컬럼 찾기 const bandedColumns = new Set(bands.flatMap((b) => b.columns)); @@ -2636,109 +2692,84 @@ export const TableListComponent: React.FC = ({ }, [tableConfig, visibleColumns]); // 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩 - const loadCascadingOptions = useCallback(async ( - columnName: string, - parentColumnName: string, - parentValue: any - ) => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return; + const loadCascadingOptions = useCallback( + async (columnName: string, parentColumnName: string, parentValue: any) => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return; - const cacheKey = `${columnName}_${parentValue}`; - - // 이미 로딩 중이면 스킵 - if (loadingCascading[cacheKey]) return; - - // 이미 캐시된 데이터가 있으면 스킵 - if (cascadingOptions[cacheKey]) return; + const cacheKey = `${columnName}_${parentValue}`; - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); + // 이미 로딩 중이면 스킵 + if (loadingCascading[cacheKey]) return; - try { - const { apiClient } = await import("@/lib/api/client"); - - // API에서 연계 옵션 로딩 - const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { - page: 1, - size: 1000, - search: { - [cascadingConfig.parentKeyField || parentColumnName]: parentValue, - }, - autoFilter: true, - }); + // 이미 캐시된 데이터가 있으면 스킵 + if (cascadingOptions[cacheKey]) return; - const items = response.data?.data?.data || []; - const options = items.map((item: any) => ({ - value: item[cascadingConfig.valueField || "id"], - label: item[cascadingConfig.labelField || "name"], - })); + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: options, - })); + try { + const { apiClient } = await import("@/lib/api/client"); - console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); - } catch (error) { - console.error("❌ Cascading options 로딩 실패:", error); - setCascadingOptions((prev) => ({ - ...prev, - [cacheKey]: [], - })); - } finally { - setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); - } - }, [tableConfig, cascadingOptions, loadingCascading]); + // API에서 연계 옵션 로딩 + const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { + page: 1, + size: 1000, + search: { + [cascadingConfig.parentKeyField || parentColumnName]: parentValue, + }, + autoFilter: true, + }); + + const items = response.data?.data?.data || []; + const options = items.map((item: any) => ({ + value: item[cascadingConfig.valueField || "id"], + label: item[cascadingConfig.labelField || "name"], + })); + + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + + console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length }); + } catch (error) { + console.error("❌ Cascading options 로딩 실패:", error); + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } finally { + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); + } + }, + [tableConfig, cascadingOptions, loadingCascading], + ); // 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기 - const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; - if (!cascadingConfig) return []; + const getCascadingOptions = useCallback( + (columnName: string, row: any): { value: string; label: string }[] => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return []; - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue === undefined || parentValue === null) return []; + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue === undefined || parentValue === null) return []; - const cacheKey = `${columnName}_${parentValue}`; - return cascadingOptions[cacheKey] || []; - }, [tableConfig, cascadingOptions]); + const cacheKey = `${columnName}_${parentValue}`; + return cascadingOptions[cacheKey] || []; + }, + [tableConfig, cascadingOptions], + ); - // 🆕 Virtual Scrolling: 보이는 행 범위 계산 - const virtualScrollInfo = useMemo(() => { - if (!isVirtualScrollEnabled || filteredData.length === 0) { - return { - startIndex: 0, - endIndex: filteredData.length, - visibleData: filteredData, - topSpacerHeight: 0, - bottomSpacerHeight: 0, - totalHeight: filteredData.length * ROW_HEIGHT, - }; - } - - const containerHeight = scrollContainerRef.current?.clientHeight || 600; - const totalRows = filteredData.length; - const totalHeight = totalRows * ROW_HEIGHT; - - // 현재 보이는 행 범위 계산 - const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); - const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; - const endIndex = Math.min(totalRows, startIndex + visibleRowCount); - - return { - startIndex, - endIndex, - visibleData: filteredData.slice(startIndex, endIndex), - topSpacerHeight: startIndex * ROW_HEIGHT, - bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, - totalHeight, - }; - }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); + // 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조) // 🆕 Virtual Scrolling: 스크롤 핸들러 - const handleVirtualScroll = useCallback((e: React.UIEvent) => { - if (!isVirtualScrollEnabled) return; - setScrollTop(e.currentTarget.scrollTop); - }, [isVirtualScrollEnabled]); + const handleVirtualScroll = useCallback( + (e: React.UIEvent) => { + if (!isVirtualScrollEnabled) return; + setScrollTop(e.currentTarget.scrollTop); + }, + [isVirtualScrollEnabled], + ); // 🆕 State Persistence: 통합 상태 저장 const saveTableState = useCallback(() => { @@ -2753,7 +2784,7 @@ export const TableListComponent: React.FC = ({ frozenColumns, showGridLines, headerFilters: Object.fromEntries( - Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) + Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), pageSize: localPageSize, timestamp: Date.now(), @@ -2765,7 +2796,18 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } - }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); + }, [ + tableStateKey, + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters, + localPageSize, + ]); // 🆕 State Persistence: 통합 상태 복원 const loadTableState = useCallback(() => { @@ -2776,7 +2818,7 @@ export const TableListComponent: React.FC = ({ if (!saved) return; const state = JSON.parse(saved); - + if (state.columnWidths) setColumnWidths(state.columnWidths); if (state.columnOrder) setColumnOrder(state.columnOrder); if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); @@ -2828,7 +2870,8 @@ export const TableListComponent: React.FC = ({ const connectWebSocket = useCallback(() => { if (!isRealTimeEnabled || !tableConfig.selectedTable) return; - const wsUrl = (tableConfig as any).wsUrl || + const wsUrl = + (tableConfig as any).wsUrl || `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; try { @@ -2876,7 +2919,7 @@ export const TableListComponent: React.FC = ({ wsRef.current.onclose = () => { setWsConnectionStatus("disconnected"); console.log("🔌 WebSocket 연결 종료"); - + // 자동 재연결 (5초 후) if (isRealTimeEnabled) { reconnectTimeoutRef.current = setTimeout(() => { @@ -2921,14 +2964,23 @@ export const TableListComponent: React.FC = ({ }, 1000); // 1초 후 저장 (디바운스) return () => clearTimeout(timeoutId); - }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); + }, [ + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters, + ]); // 🆕 Clipboard: 선택된 데이터 복사 const handleCopy = useCallback(async () => { try { // 선택된 행 데이터 가져오기 let copyData: any[]; - + if (selectedRows.size > 0) { // 선택된 행만 copyData = filteredData.filter((row, index) => { @@ -2955,15 +3007,17 @@ export const TableListComponent: React.FC = ({ const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); const rows = copyData.map((row) => - exportColumns.map((c) => { - const value = row[c.columnName]; - return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; - }).join("\t") + exportColumns + .map((c) => { + const value = row[c.columnName]; + return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; + }) + .join("\t"), ); const tsvContent = [headers.join("\t"), ...rows].join("\n"); await navigator.clipboard.writeText(tsvContent); - + toast.success(`${copyData.length}행 복사됨`); console.log("✅ 클립보드 복사:", copyData.length, "행"); } catch (error) { @@ -3013,39 +3067,42 @@ export const TableListComponent: React.FC = ({ }, [contextMenu, closeContextMenu]); // 🆕 Search Panel: 통합 검색 실행 - const executeGlobalSearch = useCallback((term: string) => { - if (!term.trim()) { - setSearchHighlights(new Set()); - return; - } + const executeGlobalSearch = useCallback( + (term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } - const lowerTerm = term.toLowerCase(); - const highlights = new Set(); + const lowerTerm = term.toLowerCase(); + const highlights = new Set(); - filteredData.forEach((row, rowIndex) => { - visibleColumns.forEach((col, colIndex) => { - const value = row[col.columnName]; - if (value !== null && value !== undefined) { - const strValue = String(value).toLowerCase(); - if (strValue.includes(lowerTerm)) { - highlights.add(`${rowIndex}-${colIndex}`); + filteredData.forEach((row, rowIndex) => { + visibleColumns.forEach((col, colIndex) => { + const value = row[col.columnName]; + if (value !== null && value !== undefined) { + const strValue = String(value).toLowerCase(); + if (strValue.includes(lowerTerm)) { + highlights.add(`${rowIndex}-${colIndex}`); + } } - } + }); }); - }); - setSearchHighlights(highlights); - - // 첫 번째 검색 결과로 포커스 이동 - if (highlights.size > 0) { - const firstHighlight = Array.from(highlights)[0]; - const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); - setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); - toast.success(`${highlights.size}개 검색 결과`); - } else { - toast.info("검색 결과가 없습니다"); - } - }, [filteredData, visibleColumns]); + setSearchHighlights(highlights); + + // 첫 번째 검색 결과로 포커스 이동 + if (highlights.size > 0) { + const firstHighlight = Array.from(highlights)[0]; + const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + toast.success(`${highlights.size}개 검색 결과`); + } else { + toast.info("검색 결과가 없습니다"); + } + }, + [filteredData, visibleColumns], + ); // 🆕 Search Panel: 다음 검색 결과로 이동 const goToNextSearchResult = useCallback(() => { @@ -3120,8 +3177,8 @@ export const TableListComponent: React.FC = ({ }, ], } - : group - ) + : group, + ), ); }, []); @@ -3134,8 +3191,8 @@ export const TableListComponent: React.FC = ({ ...group, conditions: group.conditions.filter((c) => c.id !== conditionId), } - : group - ) + : group, + ), ); }, []); @@ -3147,15 +3204,13 @@ export const TableListComponent: React.FC = ({ group.id === groupId ? { ...group, - conditions: group.conditions.map((c) => - c.id === conditionId ? { ...c, [field]: value } : c - ), + conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)), } - : group - ) + : group, + ), ); }, - [] + [], ); // 🆕 Filter Builder: 그룹 추가 @@ -3184,9 +3239,7 @@ export const TableListComponent: React.FC = ({ // 🆕 Filter Builder: 그룹 로직 변경 const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { - setFilterGroups((prev) => - prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) - ); + setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group))); }, []); // 🆕 Filter Builder: 필터 적용 @@ -3255,7 +3308,7 @@ export const TableListComponent: React.FC = ({ // 모든 그룹이 AND로 연결됨 (그룹 간) return filterGroups.every((group) => { const validConditions = group.conditions.filter( - (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value), ); if (validConditions.length === 0) return true; @@ -3266,29 +3319,35 @@ export const TableListComponent: React.FC = ({ } }); }, - [filterGroups, evaluateCondition] + [filterGroups, evaluateCondition], ); // 🆕 컬럼 드래그 시작 - const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled) return; - - setDraggedColumnIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", `col-${index}`); - }, [isColumnDragEnabled]); + const handleColumnDragStart = useCallback( + (e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled) return; + + setDraggedColumnIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${index}`); + }, + [isColumnDragEnabled], + ); // 🆕 컬럼 드래그 오버 - const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { - if (!isColumnDragEnabled || draggedColumnIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedColumnIndex) { - setDropTargetColumnIndex(index); - } - }, [isColumnDragEnabled, draggedColumnIndex]); + const handleColumnDragOver = useCallback( + (e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled || draggedColumnIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedColumnIndex) { + setDropTargetColumnIndex(index); + } + }, + [isColumnDragEnabled, draggedColumnIndex], + ); // 🆕 컬럼 드래그 종료 const handleColumnDragEnd = useCallback(() => { @@ -3297,55 +3356,64 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 컬럼 드롭 - const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + const handleColumnDrop = useCallback( + (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + handleColumnDragEnd(); + return; + } + + // 컬럼 순서 변경 + const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; + const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); + newOrder.splice(targetIndex, 0, movedColumn); + + setColumnOrder(newOrder); + toast.info("컬럼 순서가 변경되었습니다."); + console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); + handleColumnDragEnd(); - return; - } - - // 컬럼 순서 변경 - const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; - const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); - newOrder.splice(targetIndex, 0, movedColumn); - - setColumnOrder(newOrder); - toast.info("컬럼 순서가 변경되었습니다."); - console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex }); - - handleColumnDragEnd(); - }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); + }, + [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd], + ); // 🆕 행 드래그 시작 - const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { - if (!isDragEnabled) return; - - setDraggedRowIndex(index); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(index)); - - // 드래그 이미지 설정 (반투명) - const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; - dragImage.style.opacity = "0.5"; - dragImage.style.position = "absolute"; - dragImage.style.top = "-1000px"; - document.body.appendChild(dragImage); - e.dataTransfer.setDragImage(dragImage, 0, 0); - setTimeout(() => document.body.removeChild(dragImage), 0); - }, [isDragEnabled]); + const handleRowDragStart = useCallback( + (e: React.DragEvent, index: number) => { + if (!isDragEnabled) return; + + setDraggedRowIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + + // 드래그 이미지 설정 (반투명) + const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; + dragImage.style.opacity = "0.5"; + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 0, 0); + setTimeout(() => document.body.removeChild(dragImage), 0); + }, + [isDragEnabled], + ); // 🆕 행 드래그 오버 - const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { - if (!isDragEnabled || draggedRowIndex === null) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - - if (index !== draggedRowIndex) { - setDropTargetIndex(index); - } - }, [isDragEnabled, draggedRowIndex]); + const handleRowDragOver = useCallback( + (e: React.DragEvent, index: number) => { + if (!isDragEnabled || draggedRowIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedRowIndex) { + setDropTargetIndex(index); + } + }, + [isDragEnabled, draggedRowIndex], + ); // 🆕 행 드래그 종료 const handleRowDragEnd = useCallback(() => { @@ -3354,84 +3422,84 @@ export const TableListComponent: React.FC = ({ }, []); // 🆕 행 드롭 - const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - - if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { - handleRowDragEnd(); - return; - } + const handleRowDrop = useCallback( + async (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); - try { - // 로컬 데이터 재정렬 - const newData = [...filteredData]; - const [movedRow] = newData.splice(draggedRowIndex, 1); - newData.splice(targetIndex, 0, movedRow); - - // 서버에 순서 저장 (order_index 필드가 있는 경우) - const orderField = (tableConfig as any).orderField || "order_index"; - const hasOrderField = newData[0] && orderField in newData[0]; - - if (hasOrderField && tableConfig.selectedTable) { - const { apiClient } = await import("@/lib/api/client"); - const primaryKeyField = tableConfig.primaryKey || "id"; - - // 영향받는 행들의 순서 업데이트 - const updates = newData.map((row, idx) => ({ - tableName: tableConfig.selectedTable, - keyField: primaryKeyField, - keyValue: row[primaryKeyField], - updateField: orderField, - updateValue: idx + 1, - })); - - // 배치 업데이트 - await Promise.all( - updates.map((update) => - apiClient.put(`/dynamic-form/update-field`, update) - ) - ); - - toast.success("순서가 변경되었습니다."); - setRefreshTrigger((prev) => prev + 1); - } else { - // 로컬에서만 순서 변경 (저장 안함) - toast.info("순서가 변경되었습니다. (로컬만)"); - } - - console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); - } catch (error) { - console.error("❌ 행 순서 변경 실패:", error); - toast.error("순서 변경 중 오류가 발생했습니다."); - } - - handleRowDragEnd(); - }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); - - // 🆕 PDF 내보내기 (인쇄용 HTML 생성) - const exportToPdf = useCallback((exportAll: boolean = true) => { - try { - // 내보낼 데이터 선택 - let exportData: any[]; - if (exportAll) { - exportData = filteredData; - } else { - exportData = filteredData.filter((row, index) => { - const rowKey = getRowKey(row, index); - return selectedRows.has(rowKey); - }); - } - - if (exportData.length === 0) { - toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { + handleRowDragEnd(); return; } - // 컬럼 정보 가져오기 (체크박스 제외) - const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); - - // 인쇄용 HTML 생성 - const printContent = ` + try { + // 로컬 데이터 재정렬 + const newData = [...filteredData]; + const [movedRow] = newData.splice(draggedRowIndex, 1); + newData.splice(targetIndex, 0, movedRow); + + // 서버에 순서 저장 (order_index 필드가 있는 경우) + const orderField = (tableConfig as any).orderField || "order_index"; + const hasOrderField = newData[0] && orderField in newData[0]; + + if (hasOrderField && tableConfig.selectedTable) { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // 영향받는 행들의 순서 업데이트 + const updates = newData.map((row, idx) => ({ + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: row[primaryKeyField], + updateField: orderField, + updateValue: idx + 1, + })); + + // 배치 업데이트 + await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update))); + + toast.success("순서가 변경되었습니다."); + setRefreshTrigger((prev) => prev + 1); + } else { + // 로컬에서만 순서 변경 (저장 안함) + toast.info("순서가 변경되었습니다. (로컬만)"); + } + + console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); + } catch (error) { + console.error("❌ 행 순서 변경 실패:", error); + toast.error("순서 변경 중 오류가 발생했습니다."); + } + + handleRowDragEnd(); + }, + [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd], + ); + + // 🆕 PDF 내보내기 (인쇄용 HTML 생성) + const exportToPdf = useCallback( + (exportAll: boolean = true) => { + try { + // 내보낼 데이터 선택 + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다."); + return; + } + + // 컬럼 정보 가져오기 (체크박스 제외) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // 인쇄용 HTML 생성 + const printContent = ` @@ -3467,68 +3535,90 @@ export const TableListComponent: React.FC = ({ - ${exportData.map((row) => ` + ${exportData + .map( + (row) => ` - ${exportColumns.map((col) => { - const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; - let value = row[mappedColumnName]; - - // 카테고리 매핑 - if (categoryMappings[col.columnName] && value !== null && value !== undefined) { - const mapping = categoryMappings[col.columnName][String(value)]; - if (mapping) value = mapping.label; - } - - const meta = columnMeta[col.columnName]; - const inputType = meta?.inputType || (col as any).inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; - - return `${value ?? ""}`; - }).join("")} + ${exportColumns + .map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + let value = row[mappedColumnName]; + + // 카테고리 매핑 + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) value = mapping.label; + } + + const meta = columnMeta[col.columnName]; + const inputType = meta?.inputType || (col as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return `${value ?? ""}`; + }) + .join("")} - `).join("")} + `, + ) + .join("")} `; - // 새 창에서 인쇄 - const printWindow = window.open("", "_blank"); - if (printWindow) { - printWindow.document.write(printContent); - printWindow.document.close(); - printWindow.onload = () => { - printWindow.print(); - }; - toast.success("인쇄 창이 열렸습니다."); - } else { - toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + // 새 창에서 인쇄 + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(printContent); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + toast.success("인쇄 창이 열렸습니다."); + } else { + toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요."); + } + } catch (error) { + console.error("❌ PDF 내보내기 실패:", error); + toast.error("PDF 내보내기 중 오류가 발생했습니다."); } - } catch (error) { - console.error("❌ PDF 내보내기 실패:", error); - toast.error("PDF 내보내기 중 오류가 발생했습니다."); - } - }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); + }, + [ + filteredData, + selectedRows, + visibleColumns, + columnLabels, + joinColumnMapping, + categoryMappings, + columnMeta, + tableLabel, + tableConfig.selectedTable, + getRowKey, + ], + ); // 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리) - const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { - switch (e.key) { - case "Enter": - e.preventDefault(); - saveEditing(); - break; - case "Escape": - e.preventDefault(); - cancelEditing(); - break; - case "Tab": - e.preventDefault(); - saveEditing(); - // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 - break; - } - }, [saveEditing, cancelEditing]); + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + // Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리 + break; + } + }, + [saveEditing, cancelEditing], + ); // 🆕 편집 입력 필드가 나타나면 자동 포커스 useEffect(() => { @@ -3545,9 +3635,9 @@ export const TableListComponent: React.FC = ({ useEffect(() => { if (focusedCell && tableContainerRef.current) { const focusedCellElement = tableContainerRef.current.querySelector( - `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` + `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`, ) as HTMLElement; - + if (focusedCellElement) { focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); } @@ -3636,239 +3726,242 @@ export const TableListComponent: React.FC = ({ }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화 // 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치) - const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { - // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) - if (editingCell) return; - - if (!focusedCell || data.length === 0) return; + const handleTableKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리) + if (editingCell) return; - const { rowIndex, colIndex } = focusedCell; - const maxRowIndex = data.length - 1; - const maxColIndex = visibleColumns.length - 1; + if (!focusedCell || data.length === 0) return; - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); - } - break; - case "ArrowDown": - e.preventDefault(); - if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); - } - break; - case "ArrowLeft": - e.preventDefault(); - if (colIndex > 0) { - setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } - break; - case "ArrowRight": - e.preventDefault(); - if (colIndex < maxColIndex) { - setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } - break; - case "Enter": - e.preventDefault(); - // 현재 행 선택/해제 - const enterRow = data[rowIndex]; - if (enterRow) { - const rowKey = getRowKey(enterRow, rowIndex); - const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); - } - break; - case " ": // Space - e.preventDefault(); - // 체크박스 토글 - const spaceRow = data[rowIndex]; - if (spaceRow) { - const currentRowKey = getRowKey(spaceRow, rowIndex); - const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked); - } - break; - case "F2": - // 🆕 F2: 편집 모드 진입 - e.preventDefault(); - { - const col = visibleColumns[colIndex]; - if (col && col.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (col.editable === false) { - toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); - break; - } - const row = data[rowIndex]; - const mappedCol = joinColumnMapping[col.columnName] || col.columnName; - const val = row?.[mappedCol]; - setEditingCell({ - rowIndex, - colIndex, - columnName: col.columnName, - originalValue: val - }); - setEditingValue(val !== null && val !== undefined ? String(val) : ""); + const { rowIndex, colIndex } = focusedCell; + const maxRowIndex = data.length - 1; + const maxColIndex = visibleColumns.length - 1; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); } - } - break; - case "b": - case "B": - // 🆕 Ctrl+B: 배치 편집 모드 토글 - if (e.ctrlKey) { + break; + case "ArrowDown": e.preventDefault(); - setEditMode((prev) => { - const newMode = prev === "immediate" ? "batch" : "immediate"; - if (newMode === "immediate" && pendingChanges.size > 0) { - // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 - const confirmDiscard = window.confirm( - `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?` - ); - if (confirmDiscard) { - setPendingChanges(new Map()); - setLocalEditedData({}); - toast.info("배치 편집 모드 종료"); - return "immediate"; - } - return "batch"; - } - toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); - return newMode; - }); - } - break; - case "s": - case "S": - // 🆕 Ctrl+S: 배치 저장 - if (e.ctrlKey && editMode === "batch") { + if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); + } + break; + case "ArrowLeft": e.preventDefault(); - saveBatchChanges(); - } - break; - case "c": - case "C": - // 🆕 Ctrl+C: 선택된 행/셀 복사 - if (e.ctrlKey) { - e.preventDefault(); - handleCopy(); - } - break; - case "v": - case "V": - // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) - if (e.ctrlKey && editingCell) { - // 기본 동작 허용 (input에서 처리) - } - break; - case "a": - case "A": - // 🆕 Ctrl+A: 전체 선택 - if (e.ctrlKey) { - e.preventDefault(); - handleSelectAllRows(); - } - break; - case "f": - case "F": - // 🆕 Ctrl+F: 통합 검색 패널 열기 - if (e.ctrlKey) { - e.preventDefault(); - setIsSearchPanelOpen(true); - } - break; - case "F3": - // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); - } - break; - case "Home": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+Home: 첫 번째 셀로 - setFocusedCell({ rowIndex: 0, colIndex: 0 }); - } else { - // Home: 현재 행의 첫 번째 셀로 - setFocusedCell({ rowIndex, colIndex: 0 }); - } - break; - case "End": - e.preventDefault(); - if (e.ctrlKey) { - // Ctrl+End: 마지막 셀로 - setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); - } else { - // End: 현재 행의 마지막 셀로 - setFocusedCell({ rowIndex, colIndex: maxColIndex }); - } - break; - case "PageUp": - e.preventDefault(); - // 10행 위로 - setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); - break; - case "PageDown": - e.preventDefault(); - // 10행 아래로 - setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); - break; - case "Escape": - e.preventDefault(); - // 포커스 해제 - setFocusedCell(null); - break; - case "Tab": - e.preventDefault(); - if (e.shiftKey) { - // Shift+Tab: 이전 셀 if (colIndex > 0) { setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); - } else if (rowIndex > 0) { - setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); } - } else { - // Tab: 다음 셀 + break; + case "ArrowRight": + e.preventDefault(); if (colIndex < maxColIndex) { setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); - } else if (rowIndex < maxRowIndex) { - setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); } - } - break; - default: - // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) - if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { - const column = visibleColumns[colIndex]; - if (column && column.columnName !== "__checkbox__") { - // 🆕 편집 불가 컬럼 체크 - if (column.editable === false) { - toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); - break; + break; + case "Enter": + e.preventDefault(); + // 현재 행 선택/해제 + const enterRow = data[rowIndex]; + if (enterRow) { + const rowKey = getRowKey(enterRow, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + handleRowSelection(rowKey, !isCurrentlySelected); + } + break; + case " ": // Space + e.preventDefault(); + // 체크박스 토글 + const spaceRow = data[rowIndex]; + if (spaceRow) { + const currentRowKey = getRowKey(spaceRow, rowIndex); + const isChecked = selectedRows.has(currentRowKey); + handleRowSelection(currentRowKey, !isChecked); + } + break; + case "F2": + // 🆕 F2: 편집 모드 진입 + e.preventDefault(); + { + const col = visibleColumns[colIndex]; + if (col && col.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (col.editable === false) { + toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + const row = data[rowIndex]; + const mappedCol = joinColumnMapping[col.columnName] || col.columnName; + const val = row?.[mappedCol]; + setEditingCell({ + rowIndex, + colIndex, + columnName: col.columnName, + originalValue: val, + }); + setEditingValue(val !== null && val !== undefined ? String(val) : ""); } - e.preventDefault(); - // 편집 시작 (현재 키를 초기값으로) - const row = data[rowIndex]; - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const value = row?.[mappedColumnName]; - - setEditingCell({ - rowIndex, - colIndex, - columnName: column.columnName, - originalValue: value - }); - setEditingValue(e.key); // 입력한 키로 시작 } - } - break; - } - }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); + break; + case "b": + case "B": + // 🆕 Ctrl+B: 배치 편집 모드 토글 + if (e.ctrlKey) { + e.preventDefault(); + setEditMode((prev) => { + const newMode = prev === "immediate" ? "batch" : "immediate"; + if (newMode === "immediate" && pendingChanges.size > 0) { + // 즉시 모드로 전환 시 저장되지 않은 변경사항 경고 + const confirmDiscard = window.confirm( + `저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`, + ); + if (confirmDiscard) { + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("배치 편집 모드 종료"); + return "immediate"; + } + return "batch"; + } + toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드"); + return newMode; + }); + } + break; + case "s": + case "S": + // 🆕 Ctrl+S: 배치 저장 + if (e.ctrlKey && editMode === "batch") { + e.preventDefault(); + saveBatchChanges(); + } + break; + case "c": + case "C": + // 🆕 Ctrl+C: 선택된 행/셀 복사 + if (e.ctrlKey) { + e.preventDefault(); + handleCopy(); + } + break; + case "v": + case "V": + // 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만) + if (e.ctrlKey && editingCell) { + // 기본 동작 허용 (input에서 처리) + } + break; + case "a": + case "A": + // 🆕 Ctrl+A: 전체 선택 + if (e.ctrlKey) { + e.preventDefault(); + handleSelectAllRows(); + } + break; + case "f": + case "F": + // 🆕 Ctrl+F: 통합 검색 패널 열기 + if (e.ctrlKey) { + e.preventDefault(); + setIsSearchPanelOpen(true); + } + break; + case "F3": + // 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과 + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+Home: 첫 번째 셀로 + setFocusedCell({ rowIndex: 0, colIndex: 0 }); + } else { + // Home: 현재 행의 첫 번째 셀로 + setFocusedCell({ rowIndex, colIndex: 0 }); + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+End: 마지막 셀로 + setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); + } else { + // End: 현재 행의 마지막 셀로 + setFocusedCell({ rowIndex, colIndex: maxColIndex }); + } + break; + case "PageUp": + e.preventDefault(); + // 10행 위로 + setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); + break; + case "PageDown": + e.preventDefault(); + // 10행 아래로 + setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); + break; + case "Escape": + e.preventDefault(); + // 포커스 해제 + setFocusedCell(null); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: 이전 셀 + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } else if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); + } + } else { + // Tab: 다음 셀 + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } else if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); + } + } + break; + default: + // 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등) + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + const column = visibleColumns[colIndex]; + if (column && column.columnName !== "__checkbox__") { + // 🆕 편집 불가 컬럼 체크 + if (column.editable === false) { + toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`); + break; + } + e.preventDefault(); + // 편집 시작 (현재 키를 초기값으로) + const row = data[rowIndex]; + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const value = row?.[mappedColumnName]; + + setEditingCell({ + rowIndex, + colIndex, + columnName: column.columnName, + originalValue: value, + }); + setEditingValue(e.key); // 입력한 키로 시작 + } + } + break; + } + }, + [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection], + ); const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; @@ -3911,7 +4004,8 @@ export const TableListComponent: React.FC = ({ // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능 // 이 체크를 가장 먼저 수행 (null 체크보다 앞에) if (column.entityDisplayConfig && rowData) { - const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; + const displayColumns = + column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns; const separator = column.entityDisplayConfig.separator; if (displayColumns && displayColumns.length > 0) { @@ -3920,13 +4014,13 @@ export const TableListComponent: React.FC = ({ .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); }) @@ -3978,20 +4072,23 @@ export const TableListComponent: React.FC = ({ // 1. 원래 컬럼명 (item_info.material) // 2. 점(.) 뒤의 컬럼명만 (material) let mapping = categoryMappings[column.columnName]; - + if (!mapping && column.columnName.includes(".")) { const simpleColumnName = column.columnName.split(".").pop(); if (simpleColumnName) { mapping = categoryMappings[simpleColumnName]; } } - + const { Badge } = require("@/components/ui/badge"); // 다중 값 처리: 콤마로 구분된 값들을 분리 const valueStr = String(value); - const values = valueStr.includes(",") - ? valueStr.split(",").map(v => v.trim()).filter(v => v) + const values = valueStr.includes(",") + ? valueStr + .split(",") + .map((v) => v.trim()) + .filter((v) => v) : [valueStr]; // 단일 값인 경우 (기존 로직) @@ -4073,8 +4170,8 @@ export const TableListComponent: React.FC = ({ try { const date = new Date(value); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } catch { return String(value); @@ -4116,8 +4213,8 @@ export const TableListComponent: React.FC = ({ try { const date = new Date(value); const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } catch { return value; @@ -4149,7 +4246,7 @@ export const TableListComponent: React.FC = ({ // 필터 설정 localStorage 키 생성 (화면별로 독립적) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId + return screenId ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); @@ -4157,7 +4254,7 @@ export const TableListComponent: React.FC = ({ // 그룹 설정 localStorage 키 생성 (화면별로 독립적) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId + return screenId ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` : `tableList_groupSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable, screenId]); @@ -4338,15 +4435,15 @@ export const TableListComponent: React.FC = ({ // 카테고리/엔티티 타입인 경우 _name 필드 사용 const inputType = columnMeta?.[col]?.inputType; let displayValue = item[col]; - - if (inputType === 'category' || inputType === 'entity' || inputType === 'code') { + + if (inputType === "category" || inputType === "entity" || inputType === "code") { // _name 필드가 있으면 사용 (예: division_name, writer_name) const nameField = `${col}_name`; if (item[nameField] !== undefined && item[nameField] !== null) { displayValue = item[nameField]; } } - + const label = columnLabels[col] || col; return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`; }); @@ -4366,20 +4463,18 @@ export const TableListComponent: React.FC = ({ // 🆕 그룹별 소계 계산 const groupSummary: Record = {}; - + // 숫자형 컬럼에 대해 소계 계산 (tableConfig.columns || []).forEach((col: { columnName: string }) => { if (col.columnName === "__checkbox__") return; - + const colMeta = columnMeta?.[col.columnName]; const inputType = colMeta?.inputType; const isNumeric = inputType === "number" || inputType === "decimal"; - + if (isNumeric) { - const values = items - .map((item) => parseFloat(item[col.columnName])) - .filter((v) => !isNaN(v)); - + const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v)); + if (values.length > 0) { const sum = values.reduce((a, b) => a + b, 0); groupSummary[col.columnName] = { @@ -4401,6 +4496,114 @@ export const TableListComponent: React.FC = ({ }); }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); + // 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우) + const summedData = useMemo(() => { + // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 + if (!groupSumConfig?.enabled || !groupSumConfig?.groupByColumn) { + return filteredData; + } + + console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig); + + const groupByColumn = groupSumConfig.groupByColumn; + const groupMap = new Map(); + + // 조인 컬럼인지 확인하고 실제 키 추론 + const getActualKey = (columnName: string, item: any): string => { + if (columnName.includes(".")) { + const [refTable, fieldName] = columnName.split("."); + const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); + const exactKey = `${inferredSourceColumn}_${fieldName}`; + if (item[exactKey] !== undefined) return exactKey; + if (fieldName === "item_name" || fieldName === "name") { + const aliasKey = `${inferredSourceColumn}_name`; + if (item[aliasKey] !== undefined) return aliasKey; + } + } + return columnName; + }; + + // 숫자 타입인지 확인하는 함수 + const isNumericValue = (value: any): boolean => { + if (value === null || value === undefined || value === "") return false; + const num = parseFloat(String(value)); + return !isNaN(num) && isFinite(num); + }; + + // 그룹핑 수행 + filteredData.forEach((item) => { + const actualKey = getActualKey(groupByColumn, item); + const groupValue = String(item[actualKey] || item[groupByColumn] || ""); + + if (!groupMap.has(groupValue)) { + // 첫 번째 항목을 기준으로 초기화 + groupMap.set(groupValue, { ...item, _groupCount: 1 }); + } else { + const existing = groupMap.get(groupValue); + existing._groupCount += 1; + + // 모든 키에 대해 숫자면 합산 + Object.keys(item).forEach((key) => { + const value = item[key]; + if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) { + const numValue = parseFloat(String(value)); + const existingValue = parseFloat(String(existing[key] || 0)); + existing[key] = existingValue + numValue; + } + }); + + groupMap.set(groupValue, existing); + } + }); + + const result = Array.from(groupMap.values()); + console.log("🔗 [테이블리스트] 그룹별 합산 결과:", { + 원본개수: filteredData.length, + 그룹개수: result.length, + 그룹기준: groupByColumn, + }); + + return result; + }, [filteredData, groupSumConfig]); + + // 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData + const displayData = useMemo(() => { + return groupSumConfig?.enabled ? summedData : filteredData; + }, [groupSumConfig?.enabled, summedData, filteredData]); + + // 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치) + const virtualScrollInfo = useMemo(() => { + const dataSource = displayData; + if (!isVirtualScrollEnabled || dataSource.length === 0) { + return { + startIndex: 0, + endIndex: dataSource.length, + visibleData: dataSource, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + totalHeight: dataSource.length * ROW_HEIGHT, + }; + } + + const containerHeight = scrollContainerRef.current?.clientHeight || 600; + const totalRows = dataSource.length; + const totalHeight = totalRows * ROW_HEIGHT; + + // 현재 보이는 행 범위 계산 + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; + const endIndex = Math.min(totalRows, startIndex + visibleRowCount); + + return { + startIndex, + endIndex, + visibleData: dataSource.slice(startIndex, endIndex), + topSpacerHeight: startIndex * ROW_HEIGHT, + bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, + totalHeight, + }; + }, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]); + // 저장된 그룹 설정 불러오기 useEffect(() => { if (!groupSettingKey || visibleColumns.length === 0) return; @@ -4429,7 +4632,7 @@ export const TableListComponent: React.FC = ({ // sortColumn, // sortDirection, // }); - + if (!isDesignMode && tableConfig.selectedTable) { fetchTableDataDebounced(); } @@ -4475,43 +4678,45 @@ export const TableListComponent: React.FC = ({ }, [tableConfig.selectedTable, isDesignMode]); // 🎯 컬럼 너비 자동 계산 (내용 기반) - const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => { - // 기본 너비 설정 - const MIN_WIDTH = 100; - const MAX_WIDTH = 400; - const PADDING = 48; // 좌우 패딩 + 여유 공간 - const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) + const calculateOptimalColumnWidth = useCallback( + (columnName: string, displayName: string): number => { + // 기본 너비 설정 + const MIN_WIDTH = 100; + const MAX_WIDTH = 400; + const PADDING = 48; // 좌우 패딩 + 여유 공간 + const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) - // 헤더 텍스트 너비 계산 (대략 8px per character) - const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; + // 헤더 텍스트 너비 계산 (대략 8px per character) + const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; - // 데이터 셀 너비 계산 (상위 50개 샘플링) - const sampleSize = Math.min(50, data.length); - let maxDataWidth = headerWidth; + // 데이터 셀 너비 계산 (상위 50개 샘플링) + const sampleSize = Math.min(50, data.length); + let maxDataWidth = headerWidth; - for (let i = 0; i < sampleSize; i++) { - const cellValue = data[i]?.[columnName]; - if (cellValue !== null && cellValue !== undefined) { - const cellText = String(cellValue); - // 숫자는 좁게, 텍스트는 넓게 계산 - const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; - const charWidth = isNumber ? 8 : 9; - const cellWidth = cellText.length * charWidth + PADDING; - maxDataWidth = Math.max(maxDataWidth, cellWidth); + for (let i = 0; i < sampleSize; i++) { + const cellValue = data[i]?.[columnName]; + if (cellValue !== null && cellValue !== undefined) { + const cellText = String(cellValue); + // 숫자는 좁게, 텍스트는 넓게 계산 + const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; + const charWidth = isNumber ? 8 : 9; + const cellWidth = cellText.length * charWidth + PADDING; + maxDataWidth = Math.max(maxDataWidth, cellWidth); + } } - } - // 최소/최대 범위 내로 제한 - return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); - }, [data]); + // 최소/최대 범위 내로 제한 + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); + }, + [data], + ); // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 useEffect(() => { if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { const timer = setTimeout(() => { - const storageKey = tableConfig.selectedTable && userId - ? `table_column_widths_${tableConfig.selectedTable}_${userId}` - : null; + const storageKey = + tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null; // 1. localStorage에서 저장된 너비 불러오기 let savedWidths: Record = {}; @@ -4541,8 +4746,8 @@ export const TableListComponent: React.FC = ({ } else { // 저장된 너비가 없으면 자동 계산 const optimalWidth = calculateOptimalColumnWidth( - column.columnName, - columnLabels[column.columnName] || column.displayName + column.columnName, + columnLabels[column.columnName] || column.displayName, ); newWidths[column.columnName] = optimalWidth; hasAnyWidth = true; @@ -4618,24 +4823,14 @@ export const TableListComponent: React.FC = ({ {/* 🆕 내보내기 버튼 (Excel/PDF) */} -
Excel
- @@ -4651,12 +4846,7 @@ export const TableListComponent: React.FC = ({
PDF/인쇄
- @@ -4689,7 +4879,18 @@ export const TableListComponent: React.FC = ({
); - }, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); + }, [ + tableConfig.pagination, + tableConfig.toolbar?.showPaginationRefresh, + isDesignMode, + currentPage, + totalPages, + totalItems, + loading, + selectedRows.size, + exportToExcel, + exportToPdf, + ]); // ======================================== // 렌더링 @@ -4793,7 +4994,7 @@ export const TableListComponent: React.FC = ({
{/* 편집 모드 토글 */} {(tableConfig.toolbar?.showEditMode ?? true) && ( -
+
{activeFilterCount > 0 && ( @@ -5001,15 +5198,13 @@ export const TableListComponent: React.FC = ({ {/* 🆕 배치 편집 툴바 */} {(editMode === "batch" || pendingChanges.size > 0) && ( -
+
- + 배치 편집 모드 {pendingChanges.size > 0 && ( - - {pendingChanges.size}개 변경사항 - + {pendingChanges.size}개 변경사항 )}
@@ -5065,7 +5260,7 @@ export const TableListComponent: React.FC = ({
= ({
= ({ {visibleColumns.map((column, colIdx) => { // 이 컬럼이 속한 band 찾기 const band = columnBandsInfo.bands.find( - (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx + (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx, ); - + // band의 첫 번째 컬럼인 경우에만 렌더링 if (band) { return ( {band.caption} ); } - + // band에 속하지 않은 컬럼 (개별 표시) - const isInAnyBand = columnBandsInfo.bands.some( - (b) => b.columns.includes(column.columnName) - ); + const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName)); if (!isInAnyBand) { return ( {columnLabels[column.columnName] || column.columnName} ); } - + // band의 중간 컬럼은 렌더링하지 않음 return null; })} @@ -5176,7 +5369,7 @@ export const TableListComponent: React.FC = ({ // 🆕 Column Reordering 상태 const isColumnDragging = draggedColumnIndex === columnIndex; const isColumnDropTarget = dropTargetColumnIndex === columnIndex; - + return ( = ({ "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 - isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", - isColumnDragging && "opacity-50 bg-primary/20", - isColumnDropTarget && "border-l-4 border-l-primary", + isColumnDragEnabled && + column.columnName !== "__checkbox__" && + "cursor-grab active:cursor-grabbing", + isColumnDragging && "bg-primary/20 opacity-50", + isColumnDropTarget && "border-l-primary border-l-4", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -5227,7 +5422,7 @@ export const TableListComponent: React.FC = ({ {/* 🆕 편집 불가 컬럼 표시 */} {column.editable === false && ( - + )} {columnLabels[column.columnName] || column.displayName} @@ -5235,75 +5430,82 @@ export const TableListComponent: React.FC = ({ {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} - {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( - setOpenFilterColumn(open ? column.columnName : null)} - > - - - - e.stopPropagation()} + {tableConfig.headerFilter !== false && + columnUniqueValues[column.columnName]?.length > 0 && ( + setOpenFilterColumn(open ? column.columnName : null)} > -
-
- 필터: {columnLabels[column.columnName] || column.displayName} - {headerFilters[column.columnName]?.size > 0 && ( - - )} -
-
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { - const isSelected = headerFilters[column.columnName]?.has(val); - return ( -
toggleHeaderFilter(column.columnName, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
+ + + + e.stopPropagation()} + > +
+
+ + 필터: {columnLabels[column.columnName] || column.displayName} + + {headerFilters[column.columnName]?.size > 0 && ( + + )} +
+
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); + return ( +
toggleHeaderFilter(column.columnName, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( +
+ ...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개 +
+ )} +
-
- - - )} + + + )}
)} {/* 리사이즈 핸들 (체크박스 제외) */} @@ -5346,7 +5548,7 @@ export const TableListComponent: React.FC = ({ const finalWidth = Math.max(80, thElement.offsetWidth); setColumnWidths((prev) => { const newWidths = { ...prev, [column.columnName]: finalWidth }; - + // 🎯 localStorage에 컬럼 너비 저장 (사용자별) if (tableConfig.selectedTable && userId) { const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; @@ -5356,7 +5558,7 @@ export const TableListComponent: React.FC = ({ console.error("컬럼 너비 저장 실패:", error); } } - + return newWidths; }); } @@ -5479,8 +5681,10 @@ export const TableListComponent: React.FC = ({ = ({ ))} {/* 🆕 그룹별 소계 행 */} {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( - + {visibleColumns.map((column, colIndex) => { const summary = group.summary?.[column.columnName]; const meta = columnMeta[column.columnName]; const inputType = meta?.inputType || (column as any).inputType; const isNumeric = inputType === "number" || inputType === "decimal"; - + if (colIndex === 0 && column.columnName === "__checkbox__") { return ( @@ -5523,15 +5727,17 @@ export const TableListComponent: React.FC = ({ ); } - + if (colIndex === 0 && column.columnName !== "__checkbox__") { return ( - 소계 ({group.count}건) + + 소계 ({group.count}건) + ); } - + if (summary) { return ( = ({ ); } - + return ; })} @@ -5560,193 +5766,208 @@ export const TableListComponent: React.FC = ({ )} - {/* 데이터 행 렌더링 */} - {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { + {/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */} + {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => { // Virtual Scrolling에서는 실제 인덱스 계산 const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; const rowKey = getRowKey(row, index); const isRowSelected = selectedRows.has(rowKey); const isRowFocused = focusedCell?.rowIndex === index; - - // 🆕 Drag & Drop 상태 - const isDragging = draggedRowIndex === index; - const isDropTarget = dropTargetIndex === index; - - return ( - handleRowClick(row, index, e)} - role="row" - aria-selected={isRowSelected} - // 🆕 Drag & Drop 이벤트 - draggable={isDragEnabled} - onDragStart={(e) => handleRowDragStart(e, index)} - onDragOver={(e) => handleRowDragOver(e, index)} - onDragEnd={handleRowDragEnd} - onDrop={(e) => handleRowDrop(e, index)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 - const cellValue = editMode === "batch" - ? getDisplayValue(row, index, mappedColumnName) - : row[mappedColumnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; + // 🆕 Drag & Drop 상태 + const isDragging = draggedRowIndex === index; + const isDropTarget = dropTargetIndex === index; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); - - // 셀 포커스 상태 - const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; - - // 🆕 배치 편집: 수정된 셀 여부 - const isModified = isCellModified(index, mappedColumnName); - - // 🆕 유효성 검사 에러 - const cellValidationError = getCellValidationError(index, mappedColumnName); - - // 🆕 검색 하이라이트 여부 - const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); + return ( + handleRowClick(row, index, e)} + role="row" + aria-selected={isRowSelected} + // 🆕 Drag & Drop 이벤트 + draggable={isDragEnabled} + onDragStart={(e) => handleRowDragStart(e, index)} + onDragOver={(e) => handleRowDragOver(e, index)} + onDragEnd={handleRowDragEnd} + onDrop={(e) => handleRowDrop(e, index)} + > + {visibleColumns.map((column, colIndex) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + // 🆕 배치 편집: 로컬 수정 데이터 우선 표시 + const cellValue = + editMode === "batch" + ? getDisplayValue(row, index, mappedColumnName) + : row[mappedColumnName]; - // 틀고정된 컬럼의 left 위치 계산 - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 셀 포커스 상태 + const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; + + // 🆕 배치 편집: 수정된 셀 여부 + const isModified = isCellModified(index, mappedColumnName); + + // 🆕 유효성 검사 에러 + const cellValidationError = getCellValidationError(index, mappedColumnName); + + // 🆕 검색 하이라이트 여부 + const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); + + // 틀고정된 컬럼의 left 위치 계산 + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } } - } - return ( - handleCellClick(index, colIndex, e)} - onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} - onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} - role="gridcell" - tabIndex={isCellFocused ? 0 : -1} - > - {/* 🆕 인라인 편집 모드 */} - {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( - // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 - (() => { - const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; - const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; - - // 부모 값이 변경되면 옵션 로딩 - if (cascadingConfig && options.length === 0) { - const parentValue = row[cascadingConfig.parentColumn]; - if (parentValue !== undefined && parentValue !== null) { - loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); - } - } - - // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 - const colMeta = columnMeta[column.columnName]; - const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; - const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; - - if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { - const selectOptions = cascadingConfig - ? options - : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ - value, - label: info.label, - })); - - return ( - - ); - } - - // 일반 입력 필드 - return ( - setEditingValue(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={saveEditing} - className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" - style={{ - textAlign: isNumeric ? "right" : column.align || "left", - }} - /> - ); - })() - ) : column.columnName === "__checkbox__" ? ( - renderCheckboxCell(row, index) - ) : ( - formatCellValue(cellValue, column, row) - )} - - ); - })} - - ); - })} + return ( + handleCellClick(index, colIndex, e)} + onDoubleClick={() => + handleCellDoubleClick(index, colIndex, column.columnName, cellValue) + } + onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} + role="gridcell" + tabIndex={isCellFocused ? 0 : -1} + > + {/* 🆕 인라인 편집 모드 */} + {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex + ? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력 + (() => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[ + column.columnName + ]; + const options = cascadingConfig + ? getCascadingOptions(column.columnName, row) + : []; + + // 부모 값이 변경되면 옵션 로딩 + if (cascadingConfig && options.length === 0) { + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue !== undefined && parentValue !== null) { + loadCascadingOptions( + column.columnName, + cascadingConfig.parentColumn, + parentValue, + ); + } + } + + // 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운 + const colMeta = columnMeta[column.columnName]; + const isCategoryType = + colMeta?.inputType === "category" || colMeta?.inputType === "code"; + const hasCategoryOptions = + categoryMappings[column.columnName] && + Object.keys(categoryMappings[column.columnName]).length > 0; + + if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { + const selectOptions = cascadingConfig + ? options + : Object.entries(categoryMappings[column.columnName] || {}).map( + ([value, info]) => ({ + value, + label: info.label, + }), + ); + + return ( + + ); + } + + // 일반 입력 필드 + return ( + setEditingValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={saveEditing} + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + style={{ + textAlign: isNumeric ? "right" : column.align || "left", + }} + /> + ); + })() + : column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} + + ); + })} + + ); + })} {/* 🆕 Virtual Scrolling: Bottom Spacer */} {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( @@ -5759,7 +5980,7 @@ export const TableListComponent: React.FC = ({ {/* 🆕 데이터 요약 (Total Summaries) */} {summaryData && Object.keys(summaryData).length > 0 && ( - + {visibleColumns.map((column, colIndex) => { const summary = summaryData[column.columnName]; @@ -5932,24 +6153,20 @@ export const TableListComponent: React.FC = ({ closeContextMenu(); }} > - - 셀 복사 + 셀 복사 {/* 행 복사 */}
@@ -5961,9 +6178,7 @@ export const TableListComponent: React.FC = ({ return ( ); })()} @@ -6041,8 +6255,7 @@ export const TableListComponent: React.FC = ({ closeContextMenu(); }} > - - 행 삭제 + 행 삭제
@@ -6190,10 +6403,7 @@ export const TableListComponent: React.FC = ({ > 취소 -