From 34cd7ba9e320fce91356c5f54a8e6d7565a403bb Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 20 Nov 2025 10:23:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20UPSERT=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SelectedItemsDetailInput 컴포넌트 수정 모드 지원 - 그룹화된 데이터 UPSERT API 추가 (/api/data/upsert-grouped) - 부모 키 기준으로 기존 레코드 조회 후 INSERT/UPDATE/DELETE - 각 레코드의 모든 필드 조합을 고유 키로 사용 - created_date 보존 (UPDATE 시) - 수정 모드에서 groupByColumns 기준으로 관련 레코드 조회 - 날짜 타입 ISO 형식 자동 감지 및 포맷팅 (YYYY.MM.DD) 주요 변경사항: - backend: dataService.upsertGroupedRecords() 메서드 구현 - backend: dataRoutes POST /api/data/upsert-grouped 엔드포인트 추가 - frontend: ScreenModal에서 groupByColumns 파라미터 전달 - frontend: SelectedItemsDetailInput 수정 모드 로직 추가 - frontend: 날짜 필드 타임존 제거 및 포맷팅 개선 --- backend-node/src/routes/dataRoutes.ts | 145 ++- backend-node/src/services/dataService.ts | 523 +++++++++- .../src/services/entityJoinService.ts | 112 +- backend-node/src/utils/dataFilterUtil.ts | 88 +- frontend/components/common/ScreenModal.tsx | 91 ++ .../config-panels/DataFilterConfigPanel.tsx | 297 ++++-- frontend/lib/api/data.ts | 115 ++- frontend/lib/api/entityJoin.ts | 25 +- .../SelectedItemsDetailInputComponent.tsx | 231 ++++- .../SplitPanelLayoutComponent.tsx | 388 +++++-- .../SplitPanelLayoutConfigPanel.tsx | 971 ++++++++++++++++-- .../components/split-panel-layout/types.ts | 37 + frontend/types/screen-management.ts | 26 +- 13 files changed, 2704 insertions(+), 345 deletions(-) diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 5193977a..b7ba4983 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,7 +14,7 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } = + const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } = req.query; // 입력값 검증 @@ -37,6 +37,9 @@ router.get( } } + // 🆕 enableEntityJoin 파싱 + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; const columns = [leftColumn as string, rightColumn as string]; @@ -64,6 +67,31 @@ router.get( // 회사 코드 추출 (멀티테넌시 필터링) const userCompany = req.user?.companyCode; + // displayColumns 파싱 (item_info.item_name 등) + let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined; + if (displayColumns) { + try { + parsedDisplayColumns = JSON.parse(displayColumns as string); + } catch (e) { + console.error("displayColumns 파싱 실패:", e); + } + } + + // 🆕 deduplication 파싱 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined; + if (deduplication) { + try { + parsedDeduplication = JSON.parse(deduplication as string); + } catch (e) { + console.error("deduplication 파싱 실패:", e); + } + } + console.log(`🔗 조인 데이터 조회:`, { leftTable, rightTable, @@ -71,10 +99,13 @@ router.get( rightColumn, leftValue, userCompany, - dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그 + dataFilter: parsedDataFilter, + enableEntityJoin: enableEntityJoinFlag, + displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그 + deduplication: parsedDeduplication, // 🆕 중복 제거 로그 }); - // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달) + // 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달) const result = await dataService.getJoinedData( leftTable as string, rightTable as string, @@ -82,7 +113,10 @@ router.get( rightColumn as string, leftValue as string, userCompany, - parsedDataFilter // 🆕 데이터 필터 전달 + parsedDataFilter, + enableEntityJoinFlag, + parsedDisplayColumns, // 🆕 표시 컬럼 전달 + parsedDeduplication // 🆕 중복 제거 설정 전달 ); if (!result.success) { @@ -305,10 +339,31 @@ router.get( }); } - console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`); + const { enableEntityJoin, groupByColumns } = req.query; + const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + + // groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분) + let groupByColumnsArray: string[] = []; + if (groupByColumns) { + try { + if (typeof groupByColumns === "string") { + // JSON 형식이면 파싱, 아니면 쉼표로 분리 + groupByColumnsArray = groupByColumns.startsWith("[") + ? JSON.parse(groupByColumns) + : groupByColumns.split(",").map(c => c.trim()); + } + } catch (error) { + console.warn("groupByColumns 파싱 실패:", error); + } + } - // 레코드 상세 조회 - const result = await dataService.getRecordDetail(tableName, id); + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { + enableEntityJoin: enableEntityJoinFlag, + groupByColumns: groupByColumnsArray + }); + + // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) + const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray); if (!result.success) { return res.status(400).json(result); @@ -523,6 +578,82 @@ router.post( } ); +/** + * 그룹화된 데이터 UPSERT API + * POST /api/data/upsert-grouped + * + * 요청 본문: + * { + * tableName: string, + * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, + * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] + * } + */ +router.post( + "/upsert-grouped", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName, parentKeys, records } = req.body; + + // 입력값 검증 + if (!tableName || !parentKeys || !records || !Array.isArray(records)) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", + error: "MISSING_PARAMETERS", + }); + } + + // 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, { + parentKeys, + recordCount: records.length, + }); + + // UPSERT 수행 + const result = await dataService.upsertGroupedRecords( + tableName, + parentKeys, + records + ); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + return res.json({ + success: true, + message: "데이터가 저장되었습니다.", + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + } catch (error) { + console.error("그룹화된 데이터 UPSERT 오류:", error); + return res.status(500).json({ + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + router.delete( "/:tableName/:id", authenticateToken, diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index bd7f74e1..dd40432e 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -14,6 +14,7 @@ * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; +import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 interface GetTableDataParams { @@ -53,6 +54,103 @@ const BLOCKED_TABLES = [ const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price = true인 행 찾기 + selectedRow = rows.find(row => row.base_price === true) || rows[0]; + break; + + case "current_date": + // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 + const today = new Date(); + today.setHours(0, 0, 0, 0); // 시간 제거 + + selectedRow = rows.find(row => { + const startDate = row.start_date ? new Date(row.start_date) : null; + const endDate = row.end_date ? new Date(row.end_date) : null; + + if (startDate) startDate.setHours(0, 0, 0, 0); + if (endDate) endDate.setHours(0, 0, 0, 0); + + const afterStart = !startDate || today >= startDate; + const beforeEnd = !endDate || today <= endDate; + + return afterStart && beforeEnd; + }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 + break; + + default: + selectedRow = rows[0]; + } + + result.push(selectedRow); + } + + return result; + } + /** * 테이블 접근 검증 (공통 메서드) */ @@ -374,11 +472,13 @@ class DataService { } /** - * 레코드 상세 조회 + * 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회) */ async getRecordDetail( tableName: string, - id: string | number + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] ): Promise> { try { // 테이블 접근 검증 @@ -401,6 +501,87 @@ class DataService { pkColumn = pkResult[0].attname; } + // 🆕 Entity Join이 활성화된 경우 + if (enableEntityJoin) { + const { EntityJoinService } = await import("./entityJoinService"); + const entityJoinService = new EntityJoinService(); + + // Entity Join 구성 감지 + const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + + if (joinConfigs.length > 0) { + console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`); + + // Entity Join 쿼리 생성 (개별 파라미터로 전달) + const { query: joinQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 + ); + + const result = await pool.query(joinQuery, [id]); + + if (result.rows.length === 0) { + return { + success: false, + message: "레코드를 찾을 수 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + + console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]); + + // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 + if (groupByColumns.length > 0) { + const baseRecord = result.rows[0]; + + // 그룹핑 컬럼들의 값 추출 + const groupConditions: string[] = []; + const groupValues: any[] = []; + let paramIndex = 1; + + for (const col of groupByColumns) { + const value = baseRecord[col]; + if (value !== undefined && value !== null) { + groupConditions.push(`main."${col}" = $${paramIndex}`); + groupValues.push(value); + paramIndex++; + } + } + + if (groupConditions.length > 0) { + const groupWhereClause = groupConditions.join(" AND "); + + console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues); + + // 그룹핑 기준으로 모든 레코드 조회 + const { query: groupQuery } = entityJoinService.buildJoinQuery( + tableName, + joinConfigs, + ["*"], + groupWhereClause + ); + + const groupResult = await pool.query(groupQuery, groupValues); + + console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`); + + return { + success: true, + data: groupResult.rows, // 🔧 배열로 반환! + }; + } + } + + return { + success: true, + data: result.rows[0], // 그룹핑 없으면 단일 레코드 + }; + } + } + + // 기본 쿼리 (Entity Join 없음) const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; const result = await query(queryText, [id]); @@ -427,7 +608,7 @@ class DataService { } /** - * 조인된 데이터 조회 + * 조인된 데이터 조회 (🆕 Entity 조인 지원) */ async getJoinedData( leftTable: string, @@ -436,7 +617,15 @@ class DataService { rightColumn: string, leftValue?: string | number, userCompany?: string, - dataFilter?: any // 🆕 데이터 필터 + dataFilter?: any, // 🆕 데이터 필터 + enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 + displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) + deduplication?: { // 🆕 중복 제거 설정 + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } ): Promise> { try { // 왼쪽 테이블 접근 검증 @@ -451,6 +640,143 @@ class DataService { return rightValidation.error!; } + // 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용 + if (enableEntityJoin) { + try { + const { entityJoinService } = await import("./entityJoinService"); + const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); + + // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) + if (displayColumns && Array.isArray(displayColumns)) { + // 테이블별로 요청된 컬럼들을 그룹핑 + const tableColumns: Record> = {}; + + for (const col of displayColumns) { + if (col.name && col.name.includes('.')) { + const [refTable, refColumn] = col.name.split('.'); + if (!tableColumns[refTable]) { + tableColumns[refTable] = new Set(); + } + tableColumns[refTable].add(refColumn); + } + } + + // 각 테이블별로 처리 + for (const [refTable, refColumns] of Object.entries(tableColumns)) { + // 이미 조인 설정에 있는지 확인 + const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable); + + if (existingJoins.length > 0) { + // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 + for (const refColumn of refColumns) { + // 이미 해당 컬럼을 표시하는 조인이 있는지 확인 + const existingJoin = existingJoins.find( + jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn + ); + + if (!existingJoin) { + // 없으면 새 조인 설정 복제하여 추가 + const baseJoin = existingJoins[0]; + const newJoin = { + ...baseJoin, + displayColumns: [refColumn], + aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size) + // ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴 + referenceTable: refTable, + referenceColumn: baseJoin.referenceColumn, // item_number 등 + }; + joinConfigs.push(newJoin); + console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`); + } + } + } else { + console.warn(`⚠️ 조인 설정 없음: ${refTable}`); + } + } + } + + if (joinConfigs.length > 0) { + console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`); + + // WHERE 조건 생성 + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + // 좌측 테이블 조인 조건 (leftValue로 필터링) + // rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002') + if (leftValue !== undefined && leftValue !== null) { + whereConditions.push(`main."${rightColumn}" = $${paramIndex}`); + values.push(leftValue); + paramIndex++; + } + + // 회사별 필터링 + if (userCompany && userCompany !== "*") { + const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + if (hasCompanyCode) { + whereConditions.push(`main.company_code = $${paramIndex}`); + values.push(userCompany); + paramIndex++; + } + } + + // 데이터 필터 적용 (buildDataFilterWhereClause 사용) + if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { + const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil"); + const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex); + if (filterResult.whereClause) { + whereConditions.push(filterResult.whereClause); + values.push(...filterResult.params); + paramIndex += filterResult.params.length; + console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + console.log(`📊 필터 파라미터:`, filterResult.params); + } + } + + const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; + + // Entity 조인 쿼리 빌드 + // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 + const selectColumns = ["*"]; + + const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( + rightTable, + joinConfigs, + selectColumns, + whereClause, + "", + undefined, + undefined + ); + + console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); + console.log(`🔍 파라미터:`, values); + + const result = await pool.query(finalQuery, values); + + console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`); + + // 🆕 중복 제거 처리 + let finalData = result.rows; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(result.rows, deduplication); + console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`); + } + + return { + success: true, + data: finalData, + }; + } + } catch (error) { + console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error); + // Entity 조인 실패 시 기본 조인으로 폴백 + } + } + + // 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시) let queryText = ` SELECT DISTINCT r.* FROM "${rightTable}" r @@ -501,9 +827,17 @@ class DataService { const result = await query(queryText, values); + // 🆕 중복 제거 처리 + let finalData = result; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(result, deduplication); + console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`); + } + return { success: true, - data: result, + data: finalData, }; } catch (error) { console.error( @@ -728,6 +1062,185 @@ class DataService { }; } } + + /** + * 그룹화된 데이터 UPSERT + * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 + * - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행 + * - 각 레코드의 모든 필드 조합을 고유 키로 사용 + */ + async upsertGroupedRecords( + tableName: string, + parentKeys: Record, + records: Array> + ): Promise> { + try { + // 테이블 접근 권한 검증 + if (!this.canAccessTable(tableName)) { + return { + success: false, + message: `테이블 '${tableName}'에 접근할 수 없습니다.`, + error: "ACCESS_DENIED", + }; + } + + // Primary Key 감지 + const pkColumn = await this.detectPrimaryKey(tableName); + if (!pkColumn) { + return { + success: false, + message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, + error: "PRIMARY_KEY_NOT_FOUND", + }; + } + + console.log(`🔍 UPSERT 시작: ${tableName}`, { + parentKeys, + newRecordsCount: records.length, + primaryKey: pkColumn, + }); + + // 1. 기존 DB 레코드 조회 (parentKeys 기준) + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(parentKeys)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + const whereClause = whereConditions.join(" AND "); + const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; + + console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues }); + + const existingRecords = await pool.query(selectQuery, whereValues); + + console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); + + // 2. 새 레코드와 기존 레코드 비교 + let inserted = 0; + let updated = 0; + let deleted = 0; + + // 새 레코드 처리 (INSERT or UPDATE) + for (const newRecord of records) { + // 전체 레코드 데이터 (parentKeys + newRecord) + const fullRecord = { ...parentKeys, ...newRecord }; + + // 고유 키: parentKeys 제외한 나머지 필드들 + const uniqueFields = Object.keys(newRecord); + + // 기존 레코드에서 일치하는 것 찾기 + const existingRecord = existingRecords.rows.find((existing) => { + return uniqueFields.every((field) => { + const existingValue = existing[field]; + const newValue = newRecord[field]; + + // null/undefined 처리 + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + // Date 타입 처리 + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + // 문자열 비교 + return String(existingValue) === String(newValue); + }); + }); + + if (existingRecord) { + // UPDATE: 기존 레코드가 있으면 업데이트 + const updateFields: string[] = []; + const updateValues: any[] = []; + let updateParamIndex = 1; + + for (const [key, value] of Object.entries(fullRecord)) { + if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 + updateFields.push(`"${key}" = $${updateParamIndex}`); + updateValues.push(value); + updateParamIndex++; + } + } + + updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${updateParamIndex} + `; + + await pool.query(updateQuery, updateValues); + updated++; + + console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } else { + // INSERT: 기존 레코드가 없으면 삽입 + const insertFields = Object.keys(fullRecord); + const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`); + const insertValues = Object.values(fullRecord); + + const insertQuery = ` + INSERT INTO "${tableName}" (${insertFields.map(f => `"${f}"`).join(", ")}) + VALUES (${insertPlaceholders.join(", ")}) + `; + + await pool.query(insertQuery, insertValues); + inserted++; + + console.log(`➕ INSERT: 새 레코드`); + } + } + + // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) + for (const existingRecord of existingRecords.rows) { + const uniqueFields = Object.keys(records[0] || {}); + + const stillExists = records.some((newRecord) => { + return uniqueFields.every((field) => { + const existingValue = existingRecord[field]; + const newValue = newRecord[field]; + + if (existingValue == null && newValue == null) return true; + if (existingValue == null || newValue == null) return false; + + if (existingValue instanceof Date && typeof newValue === 'string') { + return existingValue.toISOString().split('T')[0] === newValue.split('T')[0]; + } + + return String(existingValue) === String(newValue); + }); + }); + + if (!stillExists) { + // DELETE: 새 레코드에 없으면 삭제 + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existingRecord[pkColumn]]); + deleted++; + + console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + } + } + + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + + return { + success: true, + data: { inserted, updated, deleted }, + }; + } catch (error) { + console.error(`UPSERT 오류 (${tableName}):`, error); + return { + success: false, + message: "데이터 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } } export const dataService = new DataService(); diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index fef50914..88aca52d 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -81,18 +81,18 @@ export class EntityJoinService { let referenceColumn = column.reference_column; let displayColumn = column.display_column; - if (column.input_type === 'category') { - // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || 'table_column_category_values'; - referenceColumn = referenceColumn || 'value_code'; - displayColumn = displayColumn || 'value_label'; - - logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { - referenceTable, - referenceColumn, - displayColumn, - }); - } + if (column.input_type === "category") { + // 카테고리 타입: reference 정보가 비어있어도 자동 설정 + referenceTable = referenceTable || "table_column_category_values"; + referenceColumn = referenceColumn || "value_code"; + displayColumn = displayColumn || "value_label"; + + logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { + referenceTable, + referenceColumn, + displayColumn, + }); + } logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, @@ -214,8 +214,14 @@ export class EntityJoinService { ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지) + // "*"는 특별 처리: AS 없이 그냥 main.*만 const baseColumns = selectColumns - .map((col) => `main.${col}::TEXT AS ${col}`) + .map((col) => { + if (col === "*") { + return "main.*"; + } + return `main.${col}::TEXT AS ${col}`; + }) .join(", "); // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) @@ -255,7 +261,9 @@ export class EntityJoinService { // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); - logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`); + logger.info( + `🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}` + ); }); const joinColumns = joinConfigs @@ -266,64 +274,48 @@ export class EntityJoinService { config.displayColumn, ]; const separator = config.separator || " - "; - + // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`); - + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + ); + // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) // sourceColumn_label 형식으로 추가 - resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`); + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` + ); } else { - resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`); + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + ); } } else { // 여러 컬럼인 경우 CONCAT으로 연결 // 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리 const concatParts = displayColumns .map((col) => { - // 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용) - // 현재는 dept_info 테이블의 컬럼들을 확인 - const isJoinTableColumn = [ - "dept_name", - "dept_code", - "master_user_id", - "location_name", - "parent_dept_code", - "master_sabun", - "location", - "data_type", - "company_name", - "sales_yn", - "status", - "value_label", // table_column_category_values - "user_name", // user_info - ].includes(col); + // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { // 조인 테이블 컬럼은 조인 별칭 사용 @@ -337,7 +329,7 @@ export class EntityJoinService { resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); } - + // 모든 resultColumns를 반환 return resultColumns.join(", "); }) @@ -356,13 +348,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); @@ -424,7 +416,7 @@ export class EntityJoinService { } // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { logger.info( `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` ); @@ -578,13 +570,13 @@ export class EntityJoinService { .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === 'table_column_category_values') { + if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - + return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index d00861fb..a4e81fd6 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -6,9 +6,28 @@ export interface ColumnFilter { id: string; columnName: string; - operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" + | "is_not_null" + | "greater_than" + | "less_than" + | "greater_than_or_equal" + | "less_than_or_equal" + | "between" + | "date_range_contains"; value: string | string[]; - valueType: "static" | "category" | "code"; + valueType: "static" | "category" | "code" | "dynamic"; + rangeConfig?: { + startColumn: string; + endColumn: string; + }; } export interface DataFilterConfig { @@ -123,6 +142,71 @@ export function buildDataFilterWhereClause( conditions.push(`${columnRef} IS NOT NULL`); break; + case "greater_than": + conditions.push(`${columnRef} > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than": + conditions.push(`${columnRef} < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "greater_than_or_equal": + conditions.push(`${columnRef} >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "less_than_or_equal": + conditions.push(`${columnRef} <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + + case "between": + if (Array.isArray(value) && value.length === 2) { + conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`); + params.push(value[0], value[1]); + paramIndex += 2; + } + break; + + case "date_range_contains": + // 날짜 범위 포함: start_date <= value <= end_date + // filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" } + // NULL 처리: + // - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속) + // - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속) + // - 둘 다 있으면: start_date <= value <= end_date + if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) { + const startCol = getColumnRef(filter.rangeConfig.startColumn); + const endCol = getColumnRef(filter.rangeConfig.endColumn); + + // value가 "TODAY"면 현재 날짜로 변환 + const actualValue = filter.valueType === "dynamic" && value === "TODAY" + ? "CURRENT_DATE" + : `$${paramIndex}`; + + if (actualValue === "CURRENT_DATE") { + // CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함 + // NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))` + ); + } else { + // NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param) + conditions.push( + `((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))` + ); + params.push(value); + paramIndex++; + } + } + break; + default: // 알 수 없는 연산자는 무시 break; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 087444b7..72a1a2ca 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -221,6 +221,97 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("API 응답:", { screenInfo, layoutData }); + // 🆕 URL 파라미터 확인 (수정 모드) + if (typeof window !== "undefined") { + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + const editId = urlParams.get("editId"); + const tableName = urlParams.get("tableName") || screenInfo.tableName; + const groupByColumnsParam = urlParams.get("groupByColumns"); + + console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam }); + + // 수정 모드이고 editId가 있으면 해당 레코드 조회 + if (mode === "edit" && editId && tableName) { + try { + console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam }); + + const { dataApi } = await import("@/lib/api/data"); + + // groupByColumns 파싱 + let groupByColumns: string[] = []; + if (groupByColumnsParam) { + try { + groupByColumns = JSON.parse(groupByColumnsParam); + console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns); + } catch (e) { + console.warn("groupByColumns 파싱 실패:", e); + } + } else { + console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!"); + } + + console.log("🚀 [ScreenModal] API 호출 직전:", { + tableName, + editId, + enableEntityJoin: true, + groupByColumns, + groupByColumnsLength: groupByColumns.length, + }); + + // 🆕 apiClient를 named import로 가져오기 + const { apiClient } = await import("@/lib/api/client"); + const params: any = { + enableEntityJoin: true, + }; + if (groupByColumns.length > 0) { + params.groupByColumns = JSON.stringify(groupByColumns); + console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns); + } + + console.log("📡 [ScreenModal] 실제 API 요청:", { + url: `/data/${tableName}/${editId}`, + params, + }); + + const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params }); + const response = apiResponse.data; + + console.log("📩 [ScreenModal] API 응답 받음:", { + success: response.success, + hasData: !!response.data, + dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음", + dataLength: Array.isArray(response.data) ? response.data.length : 1, + }); + + if (response.success && response.data) { + // 배열인 경우 (그룹핑) vs 단일 객체 + const isArray = Array.isArray(response.data); + + if (isArray) { + console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } else { + console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")"); + console.log("📊 모든 필드 키:", Object.keys(response.data)); + console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); + } + + setFormData(response.data); + + // setFormData 직후 확인 + console.log("🔄 setFormData 호출 완료"); + } else { + console.error("❌ 수정 데이터 로드 실패:", response.error); + toast.error("데이터를 불러올 수 없습니다."); + } + } catch (error) { + console.error("❌ 수정 데이터 조회 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + } + } + // screenApi는 직접 데이터를 반환하므로 .success 체크 불필요 if (screenInfo && layoutData) { const components = layoutData.components || []; diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index f3fed8bf..724c2453 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -186,75 +186,93 @@ export function DataFilterConfigPanel({ - {/* 컬럼 선택 */} -
- - -
+ {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} + {filter.operator !== "date_range_contains" && ( +
+ + +
+ )} {/* 연산자 선택 */}
- {/* 값 타입 선택 (카테고리/코드 컬럼만) */} - {isCategoryOrCodeColumn(filter.columnName) && ( + {/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */} + {filter.operator === "date_range_contains" && ( + <> +
+

+ 💡 날짜 범위 필터링 규칙: +
• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터 +
• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터 +
• 둘 다 있으면 → 기간 내 데이터만 +

+
+
+ + +
+
+ + +
+ + )} + + {/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */} + {(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
)} - {/* 값 입력 (NULL 체크 제외) */} - {filter.operator !== "is_null" && filter.operator !== "is_not_null" && ( + {/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */} + {filter.operator !== "is_null" && + filter.operator !== "is_not_null" && + !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} @@ -328,11 +455,22 @@ export function DataFilterConfigPanel({ placeholder="쉼표로 구분 (예: 값1, 값2, 값3)" className="h-8 text-xs sm:h-10 sm:text-sm" /> + ) : filter.operator === "between" ? ( + { + const values = e.target.value.split("~").map((v) => v.trim()); + handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]); + }} + placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> ) : ( handleFilterChange(filter.id, "value", e.target.value)} - placeholder="필터 값 입력" + placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"} className="h-8 text-xs sm:h-10 sm:text-sm" /> )} @@ -341,10 +479,23 @@ export function DataFilterConfigPanel({ ? "카테고리 값을 선택하세요" : filter.operator === "in" || filter.operator === "not_in" ? "여러 값은 쉼표(,)로 구분하세요" + : filter.operator === "between" + ? "시작과 종료 값을 ~로 구분하세요" + : filter.operator === "date_range_contains" + ? "기간 내에 포함되는지 확인할 날짜를 선택하세요" : "필터링할 값을 입력하세요"}

)} + + {/* date_range_contains의 dynamic 타입 안내 */} + {filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( +
+

+ ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다. +

+
+ )} ))} diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index b3c023bf..72002ad1 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -42,10 +42,49 @@ export const dataApi = { * 특정 레코드 상세 조회 * @param tableName 테이블명 * @param id 레코드 ID + * @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false) + * @param groupByColumns 그룹핑 기준 컬럼들 (배열) */ - getRecordDetail: async (tableName: string, id: string | number): Promise => { - const response = await apiClient.get(`/data/${tableName}/${id}`); - return response.data?.data || response.data; + getRecordDetail: async ( + tableName: string, + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] + ): Promise<{ success: boolean; data?: any; error?: string }> => { + try { + const params: any = {}; + if (enableEntityJoin) { + params.enableEntityJoin = true; + } + if (groupByColumns.length > 0) { + params.groupByColumns = JSON.stringify(groupByColumns); + } + + console.log("🌐 [dataApi.getRecordDetail] API 호출:", { + tableName, + id, + enableEntityJoin, + groupByColumns, + params, + url: `/data/${tableName}/${id}`, + }); + + const response = await apiClient.get(`/data/${tableName}/${id}`, { params }); + + console.log("📥 [dataApi.getRecordDetail] API 응답:", { + success: response.data?.success, + dataType: Array.isArray(response.data?.data) ? "배열" : "객체", + dataCount: Array.isArray(response.data?.data) ? response.data.data.length : 1, + }); + + return response.data; // { success: true, data: ... } 형식 그대로 반환 + } catch (error: any) { + console.error("❌ [dataApi.getRecordDetail] API 오류:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "레코드 조회 실패", + }; + } }, /** @@ -55,6 +94,9 @@ export const dataApi = { * @param leftColumn 좌측 컬럼명 * @param rightColumn 우측 컬럼명 (외래키) * @param leftValue 좌측 값 (필터링) + * @param dataFilter 데이터 필터 + * @param enableEntityJoin Entity 조인 활성화 + * @param displayColumns 표시할 컬럼 목록 (tableName.columnName 형식 포함) */ getJoinedData: async ( leftTable: string, @@ -62,7 +104,15 @@ export const dataApi = { leftColumn: string, rightColumn: string, leftValue?: any, - dataFilter?: any, // 🆕 데이터 필터 + dataFilter?: any, + enableEntityJoin?: boolean, + displayColumns?: Array<{ name: string; label?: string }>, + deduplication?: { // 🆕 중복 제거 설정 + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }, ): Promise => { const response = await apiClient.get(`/data/join`, { params: { @@ -71,7 +121,10 @@ export const dataApi = { leftColumn, rightColumn, leftValue, - dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달 + dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, + enableEntityJoin: enableEntityJoin ?? true, + displayColumns: displayColumns ? JSON.stringify(displayColumns) : undefined, // 🆕 표시 컬럼 전달 + deduplication: deduplication ? JSON.stringify(deduplication) : undefined, // 🆕 중복 제거 설정 전달 }, }); const raw = response.data || {}; @@ -115,4 +168,56 @@ export const dataApi = { const response = await apiClient.delete(`/data/${tableName}/${id}`); return response.data; // success, message 포함된 전체 응답 반환 }, + + /** + * 특정 레코드 상세 조회 + * @param tableName 테이블명 + * @param id 레코드 ID + * @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false) + */ + getRecordDetail: async ( + tableName: string, + id: string | number, + enableEntityJoin: boolean = false + ): Promise<{ success: boolean; data?: any; error?: string }> => { + try { + const params: any = {}; + if (enableEntityJoin) { + params.enableEntityJoin = "true"; + } + const response = await apiClient.get(`/data/${tableName}/${id}`, { params }); + return response.data; // { success: true, data: ... } 형식 그대로 반환 + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message || "레코드 조회 실패", + }; + } + }, + + /** + * 그룹화된 데이터 UPSERT + * @param tableName 테이블명 + * @param parentKeys 부모 키 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }) + * @param records 레코드 배열 + */ + upsertGroupedRecords: async ( + tableName: string, + parentKeys: Record, + records: Array> + ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { + try { + const response = await apiClient.post('/data/upsert-grouped', { + tableName, + parentKeys, + records, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message || "데이터 저장 실패", + }; + } + }, }; diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index 4402b557..a84f3355 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -71,25 +71,6 @@ export const entityJoinApi = { dataFilter?: any; // 🆕 데이터 필터 } = {}, ): Promise => { - const searchParams = new URLSearchParams(); - - if (params.page) searchParams.append("page", params.page.toString()); - if (params.size) searchParams.append("size", params.size.toString()); - if (params.sortBy) searchParams.append("sortBy", params.sortBy); - if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder); - if (params.enableEntityJoin !== undefined) { - searchParams.append("enableEntityJoin", params.enableEntityJoin.toString()); - } - - // 검색 조건 추가 - if (params.search) { - Object.entries(params.search).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.append(key, String(value)); - } - }); - } - // 🔒 멀티테넌시: company_code 자동 필터링 활성화 const autoFilter = { enabled: true, @@ -99,7 +80,11 @@ export const entityJoinApi = { const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params: { - ...params, + page: params.page, + size: params.size, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + enableEntityJoin: params.enableEntityJoin, search: params.search ? JSON.stringify(params.search) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c615012e..82abcf20 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -216,6 +216,98 @@ export const SelectedItemsDetailInputComponent: React.FC { + // 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면) + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + + if (mode === "edit" && formData) { + // 배열인지 단일 객체인지 확인 + const isArray = Array.isArray(formData); + const dataArray = isArray ? formData : [formData]; + + if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { + console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음"); + return; + } + + console.log(`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? '그룹 레코드' : '단일 레코드'} (${dataArray.length}개)`); + console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2)); + + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + + // 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정 + const firstRecord = dataArray[0]; + const mainFieldGroups: Record = {}; + + // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) + groups.forEach((group) => { + const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); + + if (groupFields.length === 0) { + mainFieldGroups[group.id] = []; + return; + } + + // 🆕 각 레코드에서 그룹 데이터 추출 + const entriesMap = new Map(); + + dataArray.forEach((record) => { + const entryData: Record = {}; + + groupFields.forEach((field: any) => { + let fieldValue = record[field.name]; + if (fieldValue !== undefined && fieldValue !== null) { + // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) + if (field.type === "date" || field.type === "datetime") { + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) + } + } + entryData[field.name] = fieldValue; + } + }); + + // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) + const entryKey = JSON.stringify(entryData); + + if (!entriesMap.has(entryKey)) { + entriesMap.set(entryKey, { + id: `${group.id}_entry_${entriesMap.size + 1}`, + ...entryData, + }); + } + }); + + mainFieldGroups[group.id] = Array.from(entriesMap.values()); + }); + + // 그룹이 없으면 기본 그룹 생성 + if (groups.length === 0) { + mainFieldGroups["default"] = []; + } + + const newItem: ItemData = { + id: String(firstRecord.id || firstRecord.item_id || "edit"), + originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용 + fieldGroups: mainFieldGroups, + }; + + setItems([newItem]); + + console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", { + recordCount: dataArray.length, + item: newItem, + fieldGroupsKeys: Object.keys(mainFieldGroups), + firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0, + }); + return; + } + + // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); @@ -253,11 +345,11 @@ export const SelectedItemsDetailInputComponent: React.FC { - const handleSaveRequest = (event: Event) => { + const handleSaveRequest = async (event: Event) => { // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); @@ -269,7 +361,88 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { + if (items.length === 0) { + console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음"); + return; + } + + // 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면) + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get("mode"); + const isEditMode = mode === "edit"; + + console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode }); + + if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) { + // 🔄 수정 모드: UPSERT API 사용 + try { + console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작"); + + // 부모 키 추출 (parentDataMapping에서) + const parentKeys: Record = {}; + + // formData 또는 items[0].originalData에서 부모 데이터 가져오기 + const sourceData = formData || items[0]?.originalData || {}; + + componentConfig.parentDataMapping.forEach((mapping) => { + const value = sourceData[mapping.sourceField]; + if (value !== undefined && value !== null) { + parentKeys[mapping.targetField] = value; + } + }); + + console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); + + // items를 Cartesian Product로 변환 + const records = generateCartesianProduct(items); + + console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", { + parentKeys, + recordCount: records.length, + records, + }); + + // UPSERT API 호출 + const { dataApi } = await import("@/lib/api/data"); + const result = await dataApi.upsertGroupedRecords( + componentConfig.targetTable || "", + parentKeys, + records + ); + + if (result.success) { + console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", { + inserted: result.inserted, + updated: result.updated, + deleted: result.deleted, + }); + + // 저장 성공 이벤트 발생 + window.dispatchEvent(new CustomEvent("formSaveSuccess", { + detail: { message: "데이터가 저장되었습니다." }, + })); + } else { + console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error); + window.dispatchEvent(new CustomEvent("formSaveError", { + detail: { message: result.error || "데이터 저장 실패" }, + })); + } + + // event.preventDefault() 역할 + if (event instanceof CustomEvent && event.detail) { + event.detail.skipDefaultSave = true; // 기본 저장 로직 건너뛰기 + } + + } catch (error) { + console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error); + window.dispatchEvent(new CustomEvent("formSaveError", { + detail: { message: "데이터 저장 중 오류가 발생했습니다." }, + })); + } + } else { + // 📝 생성 모드: 기존 로직 (Cartesian Product 생성 후 formData에 추가) + console.log("📝 [SelectedItemsDetailInput] 생성 모드: 기존 저장 로직 사용"); + console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", { key: componentKey, itemsCount: items.length, @@ -287,22 +460,16 @@ export const SelectedItemsDetailInputComponent: React.FC 0, - hasCallback: !!onFormDataChange, - itemsLength: items.length, - }); } }; // 저장 버튼 클릭 시 데이터 수집 - window.addEventListener("beforeFormSave", handleSaveRequest); + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); return () => { - window.removeEventListener("beforeFormSave", handleSaveRequest); + window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange]); + }, [items, component.id, onFormDataChange, componentConfig, formData]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -768,7 +935,22 @@ export const SelectedItemsDetailInputComponent: React.FC entry[f.name] || "-").join(" / "); + return fields.map((f) => { + const value = entry[f.name]; + if (!value) return "-"; + + const strValue = String(value); + + // 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관) + // ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD + const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); + if (isoDateMatch) { + const [, year, month, day] = isoDateMatch; + return `${year}.${month}.${day}`; + } + + return strValue; + }).join(" / "); } // displayItems 설정대로 렌더링 @@ -856,13 +1038,22 @@ export const SelectedItemsDetailInputComponent: React.FC setIsLoadingRight(true); try { if (relationshipType === "detail") { - // 상세 모드: 동일 테이블의 상세 정보 + // 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화) const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; - const detail = await dataApi.getRecordDetail(rightTableName, primaryKey); + + // 🆕 엔티티 조인 API 사용 + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: { id: primaryKey }, + enableEntityJoin: true, // 엔티티 조인 활성화 + size: 1, + }); + + const detail = result.items && result.items.length > 0 ? result.items[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -388,6 +397,9 @@ export const SplitPanelLayoutComponent: React.FC rightColumn, leftValue, componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 + true, // 🆕 Entity 조인 활성화 + componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) + componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) } @@ -754,12 +766,91 @@ export const SplitPanelLayoutComponent: React.FC ); // 수정 버튼 핸들러 - const handleEditClick = useCallback((panel: "left" | "right", item: any) => { - setEditModalPanel(panel); - setEditModalItem(item); - setEditModalFormData({ ...item }); - setShowEditModal(true); - }, []); + const handleEditClick = useCallback( + (panel: "left" | "right", item: any) => { + // 🆕 우측 패널 수정 버튼 설정 확인 + if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { + const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; + + if (modalScreenId) { + // 커스텀 모달 화면 열기 + const rightTableName = componentConfig.rightPanel?.tableName || ""; + + // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) + let primaryKeyName = "id"; + let primaryKeyValue: any; + + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else { + // 첫 번째 필드를 Primary Key로 간주 + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } + + console.log(`✅ 수정 모달 열기:`, { + tableName: rightTableName, + primaryKeyName, + primaryKeyValue, + screenId: modalScreenId, + fullItem: item, + }); + + // modalDataStore에도 저장 (호환성 유지) + import("@/stores/modalDataStore").then(({ useModalDataStore }) => { + useModalDataStore.getState().setData(rightTableName, [item]); + }); + + // 🆕 groupByColumns 추출 + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + + console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { + groupByColumns, + editButtonConfig: componentConfig.rightPanel?.editButton, + hasGroupByColumns: groupByColumns.length > 0, + }); + + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: modalScreenId, + urlParams: { + mode: "edit", + editId: primaryKeyValue, + tableName: rightTableName, + ...(groupByColumns.length > 0 && { + groupByColumns: JSON.stringify(groupByColumns), + }), + }, + }, + }), + ); + + console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { + screenId: modalScreenId, + editId: primaryKeyValue, + tableName: rightTableName, + groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", + }); + + return; + } + } + + // 기존 자동 편집 모드 (인라인 편집 모달) + setEditModalPanel(panel); + setEditModalItem(item); + setEditModalFormData({ ...item }); + setShowEditModal(true); + }, + [componentConfig], + ); // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { @@ -1850,16 +1941,20 @@ export const SplitPanelLayoutComponent: React.FC {!isDesignMode && (
- + {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )}
{/* 수정 버튼 */} - {!isDesignMode && ( - + + {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} + )} {/* 삭제 버튼 */} {!isDesignMode && ( @@ -2011,22 +2191,43 @@ export const SplitPanelLayoutComponent: React.FC
- {allValues.map(([key, value]) => ( - - - - - ))} + {allValues.map(([key, value, label]) => { + // 포맷 설정 찾기 + const colConfig = rightColumns?.find(c => c.name === key); + const format = colConfig?.format; + + // 숫자 포맷 적용 + let displayValue = String(value); + if (value !== null && value !== undefined && value !== "" && format) { + const numValue = typeof value === 'number' ? value : parseFloat(String(value)); + if (!isNaN(numValue)) { + displayValue = numValue.toLocaleString('ko-KR', { + minimumFractionDigits: format.decimalPlaces ?? 0, + maximumFractionDigits: format.decimalPlaces ?? 10, + useGrouping: format.thousandSeparator ?? false, + }); + if (format.prefix) displayValue = format.prefix + displayValue; + if (format.suffix) displayValue = displayValue + format.suffix; + } + } + + return ( + + + + + ); + })}
- {getColumnLabel(key)} - {String(value)}
+ {label || getColumnLabel(key)} + {displayValue}
)} - ); - })} + ); + })} ) : (
@@ -2045,33 +2246,52 @@ export const SplitPanelLayoutComponent: React.FC // 상세 모드: 단일 객체를 상세 정보로 표시 (() => { const rightColumns = componentConfig.rightPanel?.columns; - let displayEntries: [string, any][] = []; + let displayEntries: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { + console.log("🔍 [디버깅] 상세 모드 표시 로직:"); + console.log(" 📋 rightData 전체:", rightData); + console.log(" 📋 rightData keys:", Object.keys(rightData)); + console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`)); + // 설정된 컬럼만 표시 displayEntries = rightColumns - .map((col) => [col.name, rightData[col.name]] as [string, any]) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + .map((col) => { + // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) + let value = rightData[col.name]; + console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); + + if (value === undefined && col.name.includes('.')) { + const columnName = col.name.split('.').pop(); + value = rightData[columnName || '']; + console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); + } + + return [col.name, value, col.label] as [string, any, string]; + }) + .filter(([key, value]) => { + const filtered = value === null || value === undefined || value === ""; + if (filtered) { + console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); + } + return !filtered; + }); - console.log("🔍 상세 모드 표시 로직:"); - console.log( - " ✅ 설정된 컬럼 사용:", - rightColumns.map((c) => c.name), - ); + console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); } else { // 설정 없으면 모든 컬럼 표시 - displayEntries = Object.entries(rightData).filter( - ([_, value]) => value !== null && value !== undefined && value !== "", - ); + displayEntries = Object.entries(rightData) + .filter(([_, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, value, ""] as [string, any, string]); console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); } return (
- {displayEntries.map(([key, value]) => ( + {displayEntries.map(([key, value, label]) => (
- {getColumnLabel(key)} + {label || getColumnLabel(key)}
{String(value)}
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 57ab7c33..f59a16e6 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -4,13 +4,14 @@ import React, { useState, useMemo, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // Accordion 제거 - 단순 섹션으로 변경 -import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react"; +import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { SplitPanelLayoutConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; @@ -24,6 +25,174 @@ interface SplitPanelLayoutConfigPanelProps { screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용) } +/** + * 그룹핑 기준 컬럼 선택 컴포넌트 + */ +const GroupByColumnsSelector: React.FC<{ + tableName?: string; + selectedColumns: string[]; + onChange: (columns: string[]) => void; +}> = ({ tableName, selectedColumns, onChange }) => { + const [columns, setColumns] = useState([]); // ColumnTypeInfo 타입 + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { + setColumns([]); + return; + } + + const loadColumns = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data && response.data.columns) { + setColumns(response.data.columns); + } + } catch (error) { + console.error("컬럼 정보 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadColumns(); + }, [tableName]); + + const toggleColumn = (columnName: string) => { + const newSelection = selectedColumns.includes(columnName) + ? selectedColumns.filter((c) => c !== columnName) + : [...selectedColumns, columnName]; + onChange(newSelection); + }; + + if (!tableName) { + return ( +
+

+ 먼저 우측 패널의 테이블을 선택하세요 +

+
+ ); + } + + return ( +
+ + {loading ? ( +
+

로딩 중...

+
+ ) : columns.length === 0 ? ( +
+

컬럼을 찾을 수 없습니다

+
+ ) : ( +
+ {columns.map((col) => ( +
+ toggleColumn(col.columnName)} + /> + +
+ ))} +
+ )} +

+ 선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"} +
+ 같은 값을 가진 모든 레코드를 함께 불러옵니다 +

+
+ ); +}; + +/** + * 화면 선택 Combobox 컴포넌트 + */ +const ScreenSelector: React.FC<{ + value?: number; + onChange: (screenId?: number) => void; +}> = ({ value, onChange }) => { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadScreens = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const response = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens(response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode }))); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }; + loadScreens(); + }, []); + + const selectedScreen = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + onChange(screen.screenId === value ? undefined : screen.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+ ); +}; + /** * SplitPanelLayout 설정 패널 */ @@ -39,6 +208,9 @@ export const SplitPanelLayoutConfigPanel: React.FC>({}); const [loadingColumns, setLoadingColumns] = useState>({}); const [allTables, setAllTables] = useState([]); // 조인 모드용 전체 테이블 목록 + // 엔티티 참조 테이블 컬럼 + type EntityRefTable = { tableName: string; columns: ColumnInfo[] }; + const [entityReferenceTables, setEntityReferenceTables] = useState>({}); // 관계 타입 const relationshipType = config.rightPanel?.relation?.type || "detail"; @@ -158,10 +330,16 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: columns })); + + // 🆕 엔티티 타입 컬럼의 참조 테이블 컬럼도 로드 + await loadEntityReferenceColumns(tableName, columns); } catch (error) { console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] })); @@ -169,6 +347,59 @@ export const SplitPanelLayoutConfigPanel: React.FC ({ ...prev, [tableName]: false })); } }; + + // 🆕 엔티티 참조 테이블의 컬럼 로드 + const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => { + const entityColumns = columns.filter( + col => (col.input_type === 'entity' || col.webType === 'entity') && col.referenceTable + ); + + if (entityColumns.length === 0) { + return; + } + + console.log(`🔗 테이블 ${sourceTableName}의 엔티티 참조 ${entityColumns.length}개 발견:`, + entityColumns.map(c => `${c.columnName} -> ${c.referenceTable}`) + ); + + const referenceTableData: Array<{tableName: string, columns: ColumnInfo[]}> = []; + + // 각 참조 테이블의 컬럼 로드 + for (const entityCol of entityColumns) { + const refTableName = entityCol.referenceTable!; + + // 이미 로드했으면 스킵 + if (referenceTableData.some(t => t.tableName === refTableName)) continue; + + try { + const refColumnsResponse = await tableTypeApi.getColumns(refTableName); + const refColumns: ColumnInfo[] = (refColumnsResponse || []).map((col: any) => ({ + tableName: col.tableName || refTableName, + columnName: col.columnName || col.column_name, + columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type || col.dbType, + input_type: col.inputType || col.input_type, + })); + + referenceTableData.push({ tableName: refTableName, columns: refColumns }); + console.log(` ✅ 참조 테이블 ${refTableName} 컬럼 ${refColumns.length}개 로드됨`); + } catch (error) { + console.error(` ❌ 참조 테이블 ${refTableName} 컬럼 로드 실패:`, error); + } + } + + // 참조 테이블 정보 저장 + setEntityReferenceTables(prev => ({ + ...prev, + [sourceTableName]: referenceTableData + })); + + console.log(`✅ [엔티티 참조] ${sourceTableName}의 참조 테이블 저장 완료:`, { + sourceTableName, + referenceTableCount: referenceTableData.length, + referenceTables: referenceTableData.map(t => `${t.tableName}(${t.columns.length}개)`), + }); + }; // 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드 useEffect(() => { @@ -253,17 +484,21 @@ export const SplitPanelLayoutConfigPanel: React.FC { - const tableName = config.leftPanel?.tableName || screenTableName; - return tableName ? loadedTableColumns[tableName] || [] : []; - }, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]); + return leftTableName ? loadedTableColumns[leftTableName] || [] : []; + }, [loadedTableColumns, leftTableName]); + // 우측 테이블명 + const rightTableName = config.rightPanel?.tableName || ""; + // 우측 테이블 컬럼 (로드된 컬럼 사용) const rightTableColumns = useMemo(() => { - const tableName = config.rightPanel?.tableName; - return tableName ? loadedTableColumns[tableName] || [] : []; - }, [loadedTableColumns, config.rightPanel?.tableName]); + return rightTableName ? loadedTableColumns[rightTableName] || [] : []; + }, [loadedTableColumns, rightTableName]); // 테이블 데이터 로딩 상태 확인 if (!tables || tables.length === 0) { @@ -737,6 +972,41 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 순서 변경 버튼 */} +
+ + +
+
@@ -745,7 +1015,7 @@ export const SplitPanelLayoutConfigPanel: React.FC - {col.name || "컬럼 선택"} + {col.label || col.name || "컬럼 선택"} @@ -753,35 +1023,78 @@ export const SplitPanelLayoutConfigPanel: React.FC 컬럼을 찾을 수 없습니다. - - {leftTableColumns.map((column) => ( - { - const newColumns = [...(config.leftPanel?.columns || [])]; - newColumns[index] = { - ...newColumns[index], - name: value, - label: column.columnLabel || value, - }; - updateLeftPanel({ columns: newColumns }); - }} - className="text-xs" - > - - {column.columnLabel || column.columnName} - - ({column.columnName}) - - +
+ {/* 기본 테이블 컬럼 */} + + {leftTableColumns.map((column) => ( + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateLeftPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + {/* 🆕 엔티티 참조 테이블 컬럼 */} + {leftTableName && entityReferenceTables[leftTableName]?.map((refTable) => ( + + {refTable.columns.map((column) => { + const fullColumnName = `${refTable.tableName}.${column.columnName}`; + return ( + { + const newColumns = [...(config.leftPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || column.columnName, + }; + updateLeftPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs pl-6" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ); + })} + ))} - +
@@ -1133,6 +1446,44 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 요약 표시 설정 (LIST 모드에서만) */} + {config.rightPanel?.displayMode === "list" && ( +
+ + +
+ + { + const value = parseInt(e.target.value) || 3; + updateRightPanel({ summaryColumnCount: value }); + }} + className="bg-white" + /> +

+ 접기 전에 표시할 컬럼 개수 (기본: 3개) +

+
+ +
+
+ +

컬럼명 표시 여부

+
+ { + updateRightPanel({ summaryShowLabel: checked as boolean }); + }} + /> +
+
+ )} + {/* 컬럼 매핑 - 조인 모드에서만 표시 */} {relationshipType !== "detail" && (
@@ -1304,6 +1655,41 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 순서 변경 버튼 */} +
+ + +
+
@@ -1312,7 +1698,7 @@ export const SplitPanelLayoutConfigPanel: React.FC - {col.name || "컬럼 선택"} + {col.label || col.name || "컬럼 선택"} @@ -1320,35 +1706,78 @@ export const SplitPanelLayoutConfigPanel: React.FC 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - const newColumns = [...(config.rightPanel?.columns || [])]; - newColumns[index] = { - ...newColumns[index], - name: value, - label: column.columnLabel || value, - }; - updateRightPanel({ columns: newColumns }); - }} - className="text-xs" - > - - {column.columnLabel || column.columnName} - - ({column.columnName}) - - +
+ {/* 기본 테이블 컬럼 */} + + {rightTableColumns.map((column) => ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || value, + }; + updateRightPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ))} + + + {/* 🆕 엔티티 참조 테이블 컬럼 */} + {rightTableName && entityReferenceTables[rightTableName]?.map((refTable) => ( + + {refTable.columns.map((column) => { + const fullColumnName = `${refTable.tableName}.${column.columnName}`; + return ( + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + name: value, + label: column.columnLabel || column.columnName, + }; + updateRightPanel({ columns: newColumns }); + // Popover 닫기 + document.body.click(); + }} + className="text-xs pl-6" + > + + {column.columnLabel || column.columnName} + + ({column.columnName}) + + + ); + })} + ))} - +
@@ -1431,6 +1860,150 @@ export const SplitPanelLayoutConfigPanel: React.FC
)} + + {/* LIST 모드: 볼드 설정 */} + {!isTableMode && ( +
+ + +
+ )} + + {/* 🆕 숫자 타입 포맷 설정 */} + {(() => { + // 컬럼 타입 확인 + const column = rightTableColumns.find(c => c.columnName === col.name); + const isNumeric = column && ['numeric', 'decimal', 'integer', 'bigint', 'double precision', 'real'].includes(column.dataType?.toLowerCase() || ''); + + if (!isNumeric) return null; + + return ( +
+ + +
+ {/* 천 단위 구분자 */} + + + {/* 소수점 자릿수 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + decimalPlaces: e.target.value ? parseInt(e.target.value) : undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ +
+ {/* 접두사 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + prefix: e.target.value || undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+ + {/* 접미사 */} +
+ + { + const newColumns = [...(config.rightPanel?.columns || [])]; + newColumns[index] = { + ...newColumns[index], + format: { + ...newColumns[index].format, + suffix: e.target.value || undefined, + }, + }; + updateRightPanel({ columns: newColumns }); + }} + className="h-7 text-xs" + /> +
+
+ + {/* 미리보기 */} + {(col.format?.thousandSeparator || col.format?.prefix || col.format?.suffix || col.format?.decimalPlaces !== undefined) && ( +
+

미리보기:

+

+ {col.format?.prefix || ''} + {(1234567.89).toLocaleString('ko-KR', { + minimumFractionDigits: col.format?.decimalPlaces ?? 0, + maximumFractionDigits: col.format?.decimalPlaces ?? 10, + useGrouping: col.format?.thousandSeparator ?? false, + })} + {col.format?.suffix || ''} +

+
+ )} +
+ ); + })()}
); }) @@ -1700,6 +2273,272 @@ export const SplitPanelLayoutConfigPanel: React.FC
+ {/* 우측 패널 중복 제거 */} +
+
+
+

중복 데이터 제거

+

+ 같은 값을 가진 데이터를 하나로 통합하여 표시 +

+
+ { + if (checked) { + updateRightPanel({ + deduplication: { + enabled: true, + groupByColumn: "", + keepStrategy: "latest", + sortColumn: "start_date", + }, + }); + } else { + updateRightPanel({ deduplication: undefined }); + } + }} + /> +
+ + {config.rightPanel?.deduplication?.enabled && ( +
+ {/* 중복 제거 기준 컬럼 */} +
+ + +

+ 이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다 +

+
+ + {/* 유지 전략 */} +
+ + +

+ {config.rightPanel?.deduplication?.keepStrategy === "latest" && "가장 최근에 추가된 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "earliest" && "가장 먼저 추가된 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "current_date" && "오늘 날짜 기준으로 유효한 기간의 데이터를 표시합니다"} + {config.rightPanel?.deduplication?.keepStrategy === "base_price" && "기준단가(base_price)로 체크된 데이터를 표시합니다"} +

+
+ + {/* 정렬 기준 컬럼 (latest/earliest만) */} + {(config.rightPanel?.deduplication?.keepStrategy === "latest" || + config.rightPanel?.deduplication?.keepStrategy === "earliest") && ( +
+ + +

+ 이 컬럼의 값으로 최신/최초를 판단합니다 (보통 날짜 컬럼) +

+
+ )} +
+ )} +
+ + {/* 🆕 우측 패널 수정 버튼 설정 */} +
+
+
+

수정 버튼 설정

+

+ 우측 리스트의 수정 버튼 동작 방식 설정 +

+
+ { + updateRightPanel({ + editButton: { + enabled: checked, + mode: config.rightPanel?.editButton?.mode || "auto", + buttonLabel: config.rightPanel?.editButton?.buttonLabel, + buttonVariant: config.rightPanel?.editButton?.buttonVariant, + }, + }); + }} + /> +
+ + {(config.rightPanel?.editButton?.enabled ?? true) && ( +
+ {/* 수정 모드 */} +
+ + +

+ {config.rightPanel?.editButton?.mode === "modal" + ? "지정한 화면을 모달로 열어 데이터를 수정합니다" + : "현재 위치에서 직접 데이터를 수정합니다"} +

+
+ + {/* 모달 화면 선택 (modal 모드일 때만) */} + {config.rightPanel?.editButton?.mode === "modal" && ( +
+ + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + modalScreenId: screenId, + }, + }) + } + /> +

+ 수정 버튼 클릭 시 열릴 화면을 선택하세요 +

+
+ )} + + {/* 버튼 라벨 */} +
+ + + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + buttonLabel: e.target.value, + enabled: config.rightPanel?.editButton?.enabled ?? true, + mode: config.rightPanel?.editButton?.mode || "auto", + }, + }) + } + className="h-8 text-xs" + placeholder="수정" + /> +
+ + {/* 버튼 스타일 */} +
+ + +
+ + {/* 🆕 그룹핑 기준 컬럼 설정 (modal 모드일 때만 표시) */} + {config.rightPanel?.editButton?.mode === "modal" && ( + { + updateRightPanel({ + editButton: { + ...config.rightPanel?.editButton!, + groupByColumns: columns, + enabled: config.rightPanel?.editButton?.enabled ?? true, + mode: config.rightPanel?.editButton?.mode || "auto", + }, + }); + }} + /> + )} +
+ )} +
+ {/* 레이아웃 설정 */}
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 95d98085..e5471566 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -21,6 +21,14 @@ export interface SplitPanelLayoutConfig { width?: number; sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) + format?: { + type?: "number" | "currency" | "date" | "text"; // 포맷 타입 + thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") + decimalPlaces?: number; // 소수점 자릿수 + prefix?: string; // 접두사 (예: "₩", "$") + suffix?: string; // 접미사 (예: "원", "개") + dateFormat?: string; // 날짜 포맷 (type: "date") + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -69,12 +77,23 @@ export interface SplitPanelLayoutConfig { showAdd?: boolean; showEdit?: boolean; // 수정 버튼 showDelete?: boolean; // 삭제 버튼 + summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3) + summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true) columns?: Array<{ name: string; label: string; width?: number; sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) + bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드) + format?: { + type?: "number" | "currency" | "date" | "text"; // 포맷 타입 + thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") + decimalPlaces?: number; // 소수점 자릿수 + prefix?: string; // 접두사 (예: "₩", "$") + suffix?: string; // 접미사 (예: "원", "개") + dateFormat?: string; // 날짜 포맷 (type: "date") + }; }>; // 추가 모달에서 입력받을 컬럼 설정 addModalColumns?: Array<{ @@ -113,6 +132,24 @@ export interface SplitPanelLayoutConfig { // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; + + // 🆕 중복 제거 설정 + deduplication?: { + enabled: boolean; // 중복 제거 활성화 + groupByColumn: string; // 중복 제거 기준 컬럼 (예: "item_id") + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; // 어떤 행을 유지할지 + sortColumn?: string; // keepStrategy가 latest/earliest일 때 정렬 기준 컬럼 + }; + + // 🆕 수정 버튼 설정 + editButton?: { + enabled: boolean; // 수정 버튼 표시 여부 (기본: true) + mode: "auto" | "modal"; // auto: 자동 편집 (인라인), modal: 커스텀 모달 + modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) + buttonLabel?: string; // 버튼 라벨 (기본: "수정") + buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline") + groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) + }; }; // 레이아웃 설정 diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index a1dbd99a..195b9b61 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -446,9 +446,29 @@ export interface DataTableFilter { export interface ColumnFilter { id: string; columnName: string; // 필터링할 컬럼명 - operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null"; - value: string | string[]; // 필터 값 (in/not_in은 배열) - valueType: "static" | "category" | "code"; // 값 타입 + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" + | "is_not_null" + | "greater_than" + | "less_than" + | "greater_than_or_equal" + | "less_than_or_equal" + | "between" + | "date_range_contains"; // 날짜 범위 포함 (start_date <= value <= end_date) + value: string | string[]; // 필터 값 (in/not_in은 배열, date_range_contains는 비교할 날짜) + valueType: "static" | "category" | "code" | "dynamic"; // 값 타입 (dynamic: 현재 날짜 등) + // date_range_contains 전용 설정 + rangeConfig?: { + startColumn: string; // 시작일 컬럼명 + endColumn: string; // 종료일 컬럼명 + }; } /**