diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index dd589fdd..be3a16a3 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => { const result = await screenManagementService.getScreensByCompany( targetCompanyCode, parseInt(page as string), - parseInt(size as string) + parseInt(size as string), + searchTerm as string // 검색어 전달 ); res.json({ diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index 5193977a..c696d5de 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); @@ -338,6 +393,86 @@ router.get( } ); +/** + * 그룹화된 데이터 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, + userCompany: req.user?.companyCode, + userId: req.user?.userId, + }); + + // UPSERT 수행 + const result = await dataService.upsertGroupedRecords( + tableName, + parentKeys, + records, + req.user?.companyCode, + req.user?.userId + ); + + 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", + }); + } + } +); + /** * 레코드 생성 API * POST /api/data/{tableName} @@ -523,6 +658,46 @@ router.post( } ); +/** + * 그룹 삭제 API + * POST /api/data/:tableName/delete-group + */ +router.post( + "/:tableName/delete-group", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const filterConditions = req.body; + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + }); + } + + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + + const result = await dataService.deleteGroupRecords(tableName, filterConditions); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log(`✅ 그룹 삭제: ${result.data?.deleted}개`); + return res.json(result); + } catch (error: any) { + console.error("그룹 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "그룹 삭제 실패", + error: error.message, + }); + } + } +); + router.delete( "/:tableName/:id", authenticateToken, diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index bd7f74e1..d9b13475 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -14,7 +14,9 @@ * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; +import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 +import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 interface GetTableDataParams { tableName: string; @@ -53,6 +55,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 +473,13 @@ class DataService { } /** - * 레코드 상세 조회 + * 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회) */ async getRecordDetail( tableName: string, - id: string | number + id: string | number, + enableEntityJoin: boolean = false, + groupByColumns: string[] = [] ): Promise> { try { // 테이블 접근 검증 @@ -401,6 +502,108 @@ 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", + }; + } + + // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[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 = normalizedRows[0][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); + + const normalizedGroupRows = normalizeDates(groupResult.rows); + console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`); + + return { + success: true, + data: normalizedGroupRows, // 🔧 배열로 반환! + }; + } + } + + return { + success: true, + data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 + }; + } + } + + // 기본 쿼리 (Entity Join 없음) const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; const result = await query(queryText, [id]); @@ -427,7 +630,7 @@ class DataService { } /** - * 조인된 데이터 조회 + * 조인된 데이터 조회 (🆕 Entity 조인 지원) */ async getJoinedData( leftTable: string, @@ -436,7 +639,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 +662,162 @@ 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); + + // 🔧 날짜 타입 타임존 문제 해결 + const normalizeDates = (rows: any[]) => { + return rows.map(row => { + const normalized: any = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + normalized[key] = `${year}-${month}-${day}`; + } else { + normalized[key] = value; + } + } + return normalized; + }); + }; + + const normalizedRows = normalizeDates(result.rows); + console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`); + + // 🆕 중복 제거 처리 + let finalData = normalizedRows; + if (deduplication?.enabled && deduplication.groupByColumn) { + console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + finalData = this.deduplicateData(normalizedRows, deduplication); + console.log(`✅ 중복 제거 완료: ${normalizedRows.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 +868,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 +1103,284 @@ class DataService { }; } } + + /** + * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + */ + async deleteGroupRecords( + tableName: string, + filterConditions: Record + ): Promise> { + try { + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; + } + + const whereConditions: string[] = []; + const whereValues: any[] = []; + let paramIndex = 1; + + for (const [key, value] of Object.entries(filterConditions)) { + whereConditions.push(`"${key}" = $${paramIndex}`); + whereValues.push(value); + paramIndex++; + } + + if (whereConditions.length === 0) { + return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" }; + } + + const whereClause = whereConditions.join(" AND "); + const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; + + console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + + const result = await pool.query(deleteQuery, whereValues); + + console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`); + + return { success: true, data: { deleted: result.rowCount || 0 } }; + } catch (error) { + console.error("그룹 삭제 오류:", error); + return { + success: false, + message: "그룹 삭제 실패", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 그룹화된 데이터 UPSERT + * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 + * - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행 + * - 각 레코드의 모든 필드 조합을 고유 키로 사용 + */ + async upsertGroupedRecords( + tableName: string, + parentKeys: Record, + records: Array>, + userCompany?: string, + userId?: string + ): Promise> { + try { + // 테이블 접근 권한 검증 + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; + } + + // Primary Key 감지 + const pkColumns = await this.getPrimaryKeyColumns(tableName); + if (!pkColumns || pkColumns.length === 0) { + return { + success: false, + message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, + error: "PRIMARY_KEY_NOT_FOUND", + }; + } + const pkColumn = pkColumns[0]; // 첫 번째 PK 사용 + + 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; + + // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 + const normalizeDateValue = (value: any): any => { + if (value == null) return value; + + // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value.split('T')[0]; // YYYY-MM-DD 만 추출 + } + + return value; + }; + + // 새 레코드 처리 (INSERT or UPDATE) + for (const newRecord of records) { + // 날짜 필드 정규화 + const normalizedRecord: Record = {}; + for (const [key, value] of Object.entries(newRecord)) { + normalizedRecord[key] = normalizeDateValue(value); + } + + // 전체 레코드 데이터 (parentKeys + normalizedRecord) + const fullRecord = { ...parentKeys, ...normalizedRecord }; + + // 고유 키: parentKeys 제외한 나머지 필드들 + const uniqueFields = Object.keys(normalizedRecord); + + // 기존 레코드에서 일치하는 것 찾기 + const existingRecord = existingRecords.rows.find((existing) => { + return uniqueFields.every((field) => { + const existingValue = existing[field]; + const newValue = normalizedRecord[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: 기존 레코드가 없으면 삽입 + + // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) + const recordWithMeta: Record = { + ...fullRecord, + id: uuidv4(), // 새 ID 생성 + created_date: "NOW()", + updated_date: "NOW()", + }; + + // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { + recordWithMeta.company_code = userCompany; + } + + // writer가 없으면 userId 사용 + if (!recordWithMeta.writer && userId) { + recordWithMeta.writer = userId; + } + + const insertFields = Object.keys(recordWithMeta).filter(key => + recordWithMeta[key] !== "NOW()" + ); + const insertPlaceholders: string[] = []; + const insertValues: any[] = []; + let insertParamIndex = 1; + + for (const field of Object.keys(recordWithMeta)) { + if (recordWithMeta[field] === "NOW()") { + insertPlaceholders.push("NOW()"); + } else { + insertPlaceholders.push(`$${insertParamIndex}`); + insertValues.push(recordWithMeta[field]); + insertParamIndex++; + } + } + + const insertQuery = ` + INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")}) + VALUES (${insertPlaceholders.join(", ")}) + `; + + console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues }); + + 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..3283ea09 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, @@ -200,6 +200,25 @@ export class EntityJoinService { } } + /** + * 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식 + */ + private formatDateColumn( + tableAlias: string, + columnName: string, + dataType?: string + ): string { + // date, timestamp 타입이면 TO_CHAR로 변환 + if ( + dataType && + (dataType.includes("date") || dataType.includes("timestamp")) + ) { + return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`; + } + // 기본은 TEXT 캐스팅 + return `${tableAlias}.${columnName}::TEXT`; + } + /** * Entity 조인이 포함된 SQL 쿼리 생성 */ @@ -210,13 +229,30 @@ export class EntityJoinService { whereClause: string = "", orderBy: string = "", limit?: number, - offset?: number + offset?: number, + columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 ): { query: string; aliasMap: Map } { try { - // 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지) - const baseColumns = selectColumns - .map((col) => `main.${col}::TEXT AS ${col}`) - .join(", "); + // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) + // 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해 + // jsonb_build_object를 사용하여 명시적으로 변환 + let baseColumns: string; + if (selectColumns.length === 1 && selectColumns[0] === "*") { + // main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환 + // PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지 + baseColumns = `main.*`; + logger.info( + `⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요` + ); + } else { + baseColumns = selectColumns + .map((col) => { + const dataType = columnTypes?.get(col); + const formattedCol = this.formatDateColumn("main", col, dataType); + return `${formattedCol} AS ${col}`; + }) + .join(", "); + } // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) // 별칭 매핑 생성 (JOIN 절과 동일한 로직) @@ -255,7 +291,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 +304,55 @@ 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` + ); + + // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) + // 예: customer_code, item_number 등 + // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); } 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) { // 조인 테이블 컬럼은 조인 별칭 사용 @@ -336,8 +365,20 @@ export class EntityJoinService { .join(` || '${separator}' || `); resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`); + + // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + if ( + isJoinTableColumn && + !displayColumns.includes(config.referenceColumn) + ) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` + ); + } } - + // 모든 resultColumns를 반환 return resultColumns.join(", "); }) @@ -356,13 +397,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 +465,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 +619,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/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index c2036cbd..6c3a3430 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -98,7 +98,8 @@ export class ScreenManagementService { async getScreensByCompany( companyCode: string, page: number = 1, - size: number = 20 + size: number = 20, + searchTerm?: string // 검색어 추가 ): Promise> { const offset = (page - 1) * size; @@ -111,6 +112,16 @@ export class ScreenManagementService { params.push(companyCode); } + // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) + if (searchTerm && searchTerm.trim() !== "") { + whereConditions.push(`( + screen_name ILIKE $${params.length + 1} OR + screen_code ILIKE $${params.length + 1} OR + table_name ILIKE $${params.length + 1} + )`); + params.push(`%${searchTerm.trim()}%`); + } + const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) @@ -2101,55 +2112,109 @@ export class ScreenManagementService { } /** - * 화면에 연결된 모달 화면들을 자동 감지 - * 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출 + * 화면에 연결된 모달/화면들을 재귀적으로 자동 감지 + * - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId + * - 조건부 컨테이너: sections[].screenId (조건별 화면 할당) + * - 중첩된 화면들도 모두 감지 (재귀) */ async detectLinkedModalScreens( screenId: number ): Promise<{ screenId: number; screenName: string; screenCode: string }[]> { - // 화면의 모든 레이아웃 조회 - const layouts = await query( - `SELECT layout_id, properties - FROM screen_layouts - WHERE screen_id = $1 - AND component_type = 'component' - AND properties IS NOT NULL`, - [screenId] - ); + console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`); + + const allLinkedScreenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + const queue: number[] = [screenId]; // BFS 큐 - const linkedScreenIds = new Set(); + // BFS로 연결된 모든 화면 탐색 + while (queue.length > 0) { + const currentScreenId = queue.shift()!; + + // 이미 방문한 화면은 스킵 (순환 참조 방지) + if (visited.has(currentScreenId)) { + console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`); + continue; + } + + visited.add(currentScreenId); + console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`); - // 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인 - for (const layout of layouts) { - try { - const properties = layout.properties; - - // 버튼 컴포넌트인지 확인 - if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { - const action = properties?.componentConfig?.action; + // 현재 화면의 모든 레이아웃 조회 + const layouts = await query( + `SELECT layout_id, properties + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties IS NOT NULL`, + [currentScreenId] + ); + + console.log(` 📦 레이아웃 개수: ${layouts.length}`); + + // 각 레이아웃에서 연결된 화면 ID 확인 + for (const layout of layouts) { + try { + const properties = layout.properties; - // popup, modal, edit 액션이고 targetScreenId가 있는 경우 - // edit 액션도 수정 폼 모달을 열기 때문에 포함 - if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) { - const targetScreenId = parseInt(action.targetScreenId); - if (!isNaN(targetScreenId)) { - linkedScreenIds.add(targetScreenId); - console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`); + // 1. 버튼 컴포넌트의 액션 확인 + if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { + const action = properties?.componentConfig?.action; + + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; + if (modalActionTypes.includes(action?.type) && action?.targetScreenId) { + const targetScreenId = parseInt(action.targetScreenId); + if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (targetScreenId !== screenId) { + allLinkedScreenIds.add(targetScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(targetScreenId)) { + queue.push(targetScreenId); + console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`); + } + } } } + + // 2. conditional-container 컴포넌트의 sections 확인 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const sectionScreenId = parseInt(section.screenId); + if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) { + // 메인 화면이 아닌 경우에만 추가 + if (sectionScreenId !== screenId) { + allLinkedScreenIds.add(sectionScreenId); + } + // 아직 방문하지 않은 화면이면 큐에 추가 + if (!visited.has(sectionScreenId)) { + queue.push(sectionScreenId); + console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`); + } + } + } + } + } + } catch (error) { + console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error); } - } catch (error) { - // JSON 파싱 오류 등은 무시하고 계속 진행 - console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error); } } + console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`); + console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`); + console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`); + // 감지된 화면 ID들의 정보 조회 - if (linkedScreenIds.size === 0) { + if (allLinkedScreenIds.size === 0) { + console.log(`ℹ️ 연결된 화면이 없습니다.`); return []; } - const screenIds = Array.from(linkedScreenIds); + const screenIds = Array.from(allLinkedScreenIds); const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", "); const linkedScreens = await query( @@ -2161,6 +2226,11 @@ export class ScreenManagementService { screenIds ); + console.log(`\n📋 최종 감지된 화면 목록:`); + linkedScreens.forEach((s: any) => { + console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`); + }); + return linkedScreens.map((s) => ({ screenId: s.screen_id, screenName: s.screen_name, @@ -2430,23 +2500,23 @@ export class ScreenManagementService { for (const layout of layouts) { try { const properties = layout.properties; + let needsUpdate = false; - // 버튼 컴포넌트인지 확인 + // 1. 버튼 컴포넌트의 targetScreenId 업데이트 if ( properties?.componentType === "button" || properties?.componentType?.startsWith("button-") ) { const action = properties?.componentConfig?.action; - // targetScreenId가 있는 액션 (popup, modal, edit) + // targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData) + const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; if ( - (action?.type === "popup" || - action?.type === "modal" || - action?.type === "edit") && + modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const oldScreenId = parseInt(action.targetScreenId); - console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); + console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { @@ -2456,31 +2526,63 @@ export class ScreenManagementService { // properties 업데이트 properties.componentConfig.action.targetScreenId = newScreenId.toString(); + needsUpdate = true; - // 데이터베이스 업데이트 - await query( - `UPDATE screen_layouts - SET properties = $1 - WHERE layout_id = $2`, - [JSON.stringify(properties), layout.layout_id] - ); - - updateCount++; console.log( - `🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` + `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); } } } + + // 2. conditional-container 컴포넌트의 sections[].screenId 업데이트 + if (properties?.componentType === "conditional-container") { + const sections = properties?.componentConfig?.sections || []; + + for (const section of sections) { + if (section?.screenId) { + const oldScreenId = parseInt(section.screenId); + console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`); + + // 매핑에 있으면 업데이트 + if (screenIdMapping.has(oldScreenId)) { + const newScreenId = screenIdMapping.get(oldScreenId)!; + console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`); + + // section.screenId 업데이트 + section.screenId = newScreenId; + needsUpdate = true; + + console.log( + `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})` + ); + } else { + console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); + } + } + } + } + + // 3. 업데이트가 필요한 경우 DB 저장 + if (needsUpdate) { + await query( + `UPDATE screen_layouts + SET properties = $1 + WHERE layout_id = $2`, + [JSON.stringify(properties), layout.layout_id] + ); + updateCount++; + console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`); + } } catch (error) { console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error); // 개별 레이아웃 오류는 무시하고 계속 진행 } } - console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`); + console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`); return updateCount; } } 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 e8c85c37..cf0a5edb 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -221,6 +221,125 @@ 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)); + } + + // 🔧 날짜 필드 정규화 (타임존 제거) + const normalizeDates = (data: any): any => { + if (Array.isArray(data)) { + return data.map(normalizeDates); + } + + if (typeof data !== 'object' || data === null) { + return data; + } + + const normalized: any = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + // ISO 날짜 형식 감지: YYYY-MM-DD만 추출 + const before = value; + const after = value.split('T')[0]; + console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`); + normalized[key] = after; + } else { + normalized[key] = value; + } + } + return normalized; + }; + + console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); + const normalizedData = normalizeDates(response.data); + console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); + setFormData(normalizedData); + + // 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 || []; @@ -268,6 +387,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; const handleClose = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + if (typeof window !== "undefined") { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("mode"); + currentUrl.searchParams.delete("editId"); + currentUrl.searchParams.delete("tableName"); + currentUrl.searchParams.delete("groupByColumns"); + window.history.pushState({}, "", currentUrl.toString()); + console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); + } + setModalState({ isOpen: false, screenId: null, diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 63ec2210..116fa0df 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; +import { useAuth } from "@/hooks/useAuth"; import { DropdownMenu, DropdownMenuContent, @@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & { }; export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { + const { user } = useAuth(); + const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*"; + const [activeTab, setActiveTab] = useState("active"); const [screens, setScreens] = useState([]); const [deletedScreens, setDeletedScreens] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); // 초기 로딩 + const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지) const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [selectedCompanyCode, setSelectedCompanyCode] = useState("all"); + const [companies, setCompanies] = useState([]); + const [loadingCompanies, setLoadingCompanies] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCopyOpen, setIsCopyOpen] = useState(false); const [screenToCopy, setScreenToCopy] = useState(null); + // 검색어 디바운스를 위한 타이머 ref + const debounceTimer = useRef(null); + + // 첫 로딩 여부를 추적 (한 번만 true) + const isFirstLoad = useRef(true); + // 삭제 관련 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [screenToDelete, setScreenToDelete] = useState(null); @@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [previewFormData, setPreviewFormData] = useState>({}); - // 화면 목록 로드 (실제 API) + // 최고 관리자인 경우 회사 목록 로드 + useEffect(() => { + if (isSuperAdmin) { + loadCompanies(); + } + }, [isSuperAdmin]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const { apiClient } = await import("@/lib/api/client"); // named export + const response = await apiClient.get("/admin/companies"); + const data = response.data.data || response.data || []; + setCompanies(data.map((c: any) => ({ + companyCode: c.company_code || c.companyCode, + companyName: c.company_name || c.companyName, + }))); + } catch (error) { + console.error("회사 목록 조회 실패:", error); + } finally { + setLoadingCompanies(false); + } + }; + + // 검색어 디바운스 처리 (150ms 지연 - 빠른 응답) + useEffect(() => { + // 이전 타이머 취소 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // 새 타이머 설정 + debounceTimer.current = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 150); + + // 클린업 + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [searchTerm]); + + // 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용 useEffect(() => { let abort = false; const load = async () => { try { - setLoading(true); + // 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true + if (isFirstLoad.current) { + setLoading(true); + isFirstLoad.current = false; // 첫 로딩 완료 표시 + } else { + setIsSearching(true); + } + if (activeTab === "active") { - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + console.log("🔍 화면 목록 API 호출:", params); // 디버깅용 + const resp = await screenApi.getScreens(params); + console.log("✅ 화면 목록 응답:", resp); // 디버깅용 + if (abort) return; setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); @@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); if (activeTab === "active") { setScreens([]); } else { @@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } setTotalPages(1); } finally { - if (!abort) setLoading(false); + if (!abort) { + setLoading(false); + setIsSearching(false); + } } }; load(); return () => { abort = true; }; - }, [currentPage, searchTerm, activeTab]); + }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]); const filteredScreens = screens; // 서버 필터 기준 사용 // 화면 목록 다시 로드 const reloadScreens = async () => { try { - setLoading(true); - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + setIsSearching(true); + const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm }; + + // 최고 관리자이고 특정 회사를 선택한 경우 + if (isSuperAdmin && selectedCompanyCode !== "all") { + params.companyCode = selectedCompanyCode; + } + + const resp = await screenApi.getScreens(params); setScreens(resp.data || []); setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); } catch (e) { - // console.error("화면 목록 조회 실패", e); + console.error("화면 목록 조회 실패", e); } finally { - setLoading(false); + setIsSearching(false); } }; @@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 검색 및 필터 */}
-
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - disabled={activeTab === "trash"} - /> +
+ {/* 최고 관리자 전용: 회사 필터 */} + {isSuperAdmin && ( +
+ +
+ )} + + {/* 검색 입력 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + disabled={activeTab === "trash"} + /> + {/* 검색 중 인디케이터 */} + {isSearching && ( +
+
+
+ )} +
+
- {/* 컬럼 선택 */} -
- - -
+ {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} + {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..8436dcf4 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,98 @@ export const dataApi = { const response = await apiClient.delete(`/data/${tableName}/${id}`); return response.data; // success, message 포함된 전체 응답 반환 }, + + /** + * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }) + */ + deleteGroupRecords: async ( + tableName: string, + filterConditions: Record + ): Promise<{ success: boolean; deleted?: number; message?: string; error?: string }> => { + try { + console.log(`🗑️ [dataApi] 그룹 삭제 요청:`, { tableName, filterConditions }); + + const response = await apiClient.post(`/data/${tableName}/delete-group`, filterConditions); + + console.log(`✅ [dataApi] 그룹 삭제 성공:`, response.data); + return response.data; + } catch (error: any) { + console.error(`❌ [dataApi] 그룹 삭제 실패:`, error); + return { + success: false, + error: error.response?.data?.message || error.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 { + console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", { + tableName, + tableNameType: typeof tableName, + tableNameValue: JSON.stringify(tableName), + parentKeys, + recordsCount: records.length, + }); + + const requestBody = { + tableName, + parentKeys, + records, + }; + console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2)); + + const response = await apiClient.post('/data/upsert-grouped', requestBody); + return response.data; + } catch (error: any) { + console.error("❌ [dataApi.upsertGroupedRecords] 에러:", { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + message: error.message, + }); + 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/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1ae66e0b..112a285c 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -393,6 +393,16 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + // 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등) + const componentConfigs: Record = {}; + if (allComponents && Array.isArray(allComponents)) { + for (const comp of allComponents) { + if (comp.id && comp.componentConfig) { + componentConfigs[comp.id] = comp.componentConfig; + } + } + } + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 @@ -418,7 +428,9 @@ export const ButtonPrimaryComponent: React.FC = ({ // 플로우 선택된 데이터 정보 추가 flowSelectedData, flowSelectedStepId, - }; + // 🆕 컴포넌트별 설정 (parentDataMapping 등) + componentConfigs, + } as ButtonActionContext; // 확인이 필요한 액션인지 확인 if (confirmationRequiredActions.includes(processedConfig.action.type)) { diff --git a/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx b/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx new file mode 100644 index 00000000..92ac2e42 --- /dev/null +++ b/frontend/lib/registry/components/selected-items-detail-input/CalculationBuilder.tsx @@ -0,0 +1,429 @@ +"use client"; + +import React, { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, X, Calculator } from "lucide-react"; +import { CalculationNode, CalculationStep, AdditionalFieldDefinition } from "./types"; + +interface CalculationBuilderProps { + steps: CalculationStep[]; + availableFields: AdditionalFieldDefinition[]; + onChange: (steps: CalculationStep[]) => void; +} + +export const CalculationBuilder: React.FC = ({ + steps, + availableFields, + onChange, +}) => { + const [previewValues, setPreviewValues] = useState>({}); + + // 새 단계 추가 + const addStep = () => { + const newStep: CalculationStep = { + id: `step_${Date.now()}`, + label: `단계 ${steps.length + 1}`, + expression: { + type: "field", + fieldName: "", + }, + }; + onChange([...steps, newStep]); + }; + + // 단계 삭제 + const removeStep = (stepId: string) => { + onChange(steps.filter((s) => s.id !== stepId)); + }; + + // 단계 업데이트 + const updateStep = (stepId: string, updates: Partial) => { + onChange( + steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s)) + ); + }; + + // 간단한 표현식 렌더링 + const renderSimpleExpression = (step: CalculationStep) => { + return ( +
+
+ {/* 왼쪽 항 */} + + + {step.expression.type === "constant" && ( + { + updateStep(step.id, { + expression: { + ...step.expression, + value: parseFloat(e.target.value) || 0, + }, + }); + }} + className="h-8 w-24 text-xs" + placeholder="값" + /> + )} +
+ + {/* 연산 추가 버튼 */} + {step.expression.type !== "operation" && ( + + )} + + {/* 연산식 */} + {step.expression.type === "operation" && ( +
+ {renderOperationExpression(step)} +
+ )} +
+ ); + }; + + // 연산식 렌더링 + const renderOperationExpression = (step: CalculationStep) => { + if (step.expression.type !== "operation") return null; + + return ( +
+ {/* 왼쪽 항 */} +
+ {renderNodeLabel(step.expression.left)} +
+ + {/* 연산자 */} + + + {/* 오른쪽 항 */} + + + {step.expression.right?.type === "constant" && ( + { + updateStep(step.id, { + expression: { + ...step.expression, + right: { + ...step.expression.right!, + value: parseFloat(e.target.value) || 0, + }, + }, + }); + }} + className="h-7 w-24 text-xs" + placeholder="값" + /> + )} +
+ ); + }; + + // 노드 라벨 표시 + const renderNodeLabel = (node?: CalculationNode): string => { + if (!node) return ""; + + switch (node.type) { + case "field": + const field = availableFields.find((f) => f.name === node.fieldName); + return field?.label || node.fieldName || "필드"; + case "constant": + return String(node.value || 0); + case "previous": + return "이전 결과"; + case "operation": + const left = renderNodeLabel(node.left); + const right = renderNodeLabel(node.right); + const op = node.operator === "*" ? "×" : node.operator === "/" ? "÷" : node.operator; + return `(${left} ${op} ${right})`; + default: + return ""; + } + }; + + // 함수 적용 UI + const renderFunctionStep = (step: CalculationStep) => { + if (step.expression.type !== "function") return null; + + return ( +
+ + + {(step.expression.functionName === "round" || + step.expression.functionName === "floor" || + step.expression.functionName === "ceil") && ( + <> + 단위: + + + )} +
+ ); + }; + + return ( +
+
+ + +
+ + {steps.length === 0 ? ( + + + +

+ 계산 단계를 추가하여 계산식을 만드세요 +

+
+
+ ) : ( +
+ {steps.map((step, idx) => ( + + +
+ + {step.label || `단계 ${idx + 1}`} + +
+ updateStep(step.id, { label: e.target.value })} + placeholder={`단계 ${idx + 1}`} + className="h-6 w-24 text-xs" + /> + +
+
+
+ + {step.expression.type === "function" + ? renderFunctionStep(step) + : renderSimpleExpression(step)} + + {/* 함수 적용 버튼 */} + {step.expression.type !== "function" && ( + + )} + +
+ ))} +
+ )} + + {/* 미리보기 */} + {steps.length > 0 && ( + + +
+ 계산식: +
+ {steps.map((step, idx) => ( +
+ {idx + 1}. {renderNodeLabel(step.expression)} +
+ ))} +
+
+
+
+ )} +
+ ); +}; + 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 904fc4be..ddb35db6 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,111 @@ 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]; + + // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) + if (fieldValue === undefined || fieldValue === null) { + // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 + if (field.defaultValue !== undefined) { + fieldValue = field.defaultValue; + } else if (field.type === "checkbox") { + fieldValue = false; // checkbox는 기본값 false + } else { + // 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨) + return; + } + } + + // 🔧 날짜 타입이면 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,25 +358,225 @@ export const SelectedItemsDetailInputComponent: React.FC[] => { + const allRecords: Record[] = []; + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + + itemsList.forEach((item) => { + // 각 그룹의 엔트리 배열들을 준비 + const groupEntriesArrays: GroupEntry[][] = groups.map(group => item.fieldGroups[group.id] || []); + + // Cartesian Product 재귀 함수 + const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record) => { + if (currentIndex === arrays.length) { + // 모든 그룹을 순회했으면 조합 완성 + allRecords.push({ ...currentCombination }); + return; + } + + const currentGroupEntries = arrays[currentIndex]; + if (currentGroupEntries.length === 0) { + // 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 + cartesian(arrays, currentIndex + 1, currentCombination); + return; + } + + // 현재 그룹의 각 엔트리마다 재귀 + currentGroupEntries.forEach(entry => { + const newCombination = { ...currentCombination }; + + // 현재 그룹의 필드들을 조합에 추가 + const groupFields = additionalFields.filter(f => f.groupId === groups[currentIndex].id); + groupFields.forEach(field => { + if (entry[field.name] !== undefined) { + newCombination[field.name] = entry[field.name]; + } + }); + + cartesian(arrays, currentIndex + 1, newCombination); + }); + }; + + // 재귀 시작 + cartesian(groupEntriesArrays, 0, {}); + }); + + console.log("🔀 [generateCartesianProduct] 생성된 레코드:", { + count: allRecords.length, + records: allRecords, + }); + + return allRecords; + }, [componentConfig.fieldGroups, componentConfig.additionalFields]); // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) useEffect(() => { - const handleSaveRequest = () => { - if (items.length > 0 && onFormDataChange) { - const dataToSave = { [component.id || "selected_items"]: items }; - console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave); - onFormDataChange(dataToSave); + const handleSaveRequest = async (event: Event) => { + // component.id를 문자열로 안전하게 변환 + const componentKey = String(component.id || "selected_items"); + + console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { + itemsCount: items.length, + hasOnFormDataChange: !!onFormDataChange, + componentId: component.id, + componentIdType: typeof component.id, + componentKey, + }); + + 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 모드로 저장 시작"); + console.log("📋 [SelectedItemsDetailInput] componentConfig:", { + targetTable: componentConfig.targetTable, + parentDataMapping: componentConfig.parentDataMapping, + fieldGroups: componentConfig.fieldGroups, + additionalFields: componentConfig.additionalFields, + }); + + // 부모 키 추출 (parentDataMapping에서) + const parentKeys: Record = {}; + + // formData 또는 items[0].originalData에서 부모 데이터 가져오기 + // formData가 배열이면 첫 번째 항목 사용 + let sourceData: any = formData; + if (Array.isArray(formData) && formData.length > 0) { + sourceData = formData[0]; + } else if (!formData) { + sourceData = items[0]?.originalData || {}; + } + + console.log("📦 [SelectedItemsDetailInput] 부모 데이터 소스:", { + formDataType: Array.isArray(formData) ? "배열" : typeof formData, + sourceData, + sourceDataKeys: Object.keys(sourceData), + parentDataMapping: componentConfig.parentDataMapping, + }); + + console.log("🔍 [SelectedItemsDetailInput] sourceData 전체 내용 (JSON):", JSON.stringify(sourceData, null, 2)); + + componentConfig.parentDataMapping.forEach((mapping) => { + const value = sourceData[mapping.sourceField]; + if (value !== undefined && value !== null) { + parentKeys[mapping.targetField] = value; + } else { + console.warn(`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); + } + }); + + console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); + + // items를 Cartesian Product로 변환 + const records = generateCartesianProduct(items); + + console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", { + parentKeys, + recordCount: records.length, + records, + }); + + // targetTable 검증 + if (!componentConfig.targetTable) { + console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!"); + window.dispatchEvent(new CustomEvent("formSaveError", { + detail: { message: "대상 테이블이 설정되지 않았습니다." }, + })); + return; + } + + console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable); + console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", { + tableName: componentConfig.targetTable, + tableNameType: typeof componentConfig.targetTable, + tableNameLength: componentConfig.targetTable?.length, + parentKeys, + recordsCount: records.length, + }); + + // 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, + firstItem: items[0], + }); + + // ✅ CustomEvent의 detail에 데이터 첨부 + if (event instanceof CustomEvent && event.detail) { + // context.formData에 직접 추가 + event.detail.formData[componentKey] = items; + console.log("✅ [SelectedItemsDetailInput] context.formData에 데이터 직접 추가 완료"); + } + + // 기존 onFormDataChange도 호출 (호환성) + if (onFormDataChange) { + onFormDataChange(componentKey, items); + } } }; // 저장 버튼 클릭 시 데이터 수집 - 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, generateCartesianProduct]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -342,6 +647,14 @@ export const SelectedItemsDetailInputComponent: React.FC { + console.log("📝 [handleFieldChange] 필드 값 변경:", { + itemId, + groupId, + entryId, + fieldName, + value, + }); + setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -357,6 +670,12 @@ 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 설정대로 렌더링 @@ -800,6 +1134,15 @@ export const SelectedItemsDetailInputComponent: React.FC - {displayItem.label}{formattedValue} + {displayItem.label}{finalValue} ); } @@ -1173,7 +1535,7 @@ export const SelectedItemsDetailInputComponent: React.FC 0 && (
-
입력된 품번 ({editingItem.details.length}개)
+
입력된 항목 ({editingItem.details.length}개)
{editingItem.details.map((detail, idx) => (
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} @@ -1527,7 +1889,7 @@ export const SelectedItemsDetailInputComponent: React.FC 0 && (
-
입력된 품번 ({editingItem.details.length}개)
+
입력된 항목 ({editingItem.details.length}개)
{editingItem.details.map((detail, idx) => (
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"} @@ -1595,15 +1957,9 @@ export const SelectedItemsDetailInputComponent: React.FC
- {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")} -
- {/* 입력된 값 표시 */} - {item.additionalData && Object.keys(item.additionalData).length > 0 && ( -
- 품번: {item.additionalData.customer_item_name} / 품명: {item.additionalData.customer_item_code} -
- )} + {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
+
+
+ +

+ 이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑합니다. +

+ +
+ {(config.parentDataMapping || []).map((mapping, index) => ( + +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { + ...updated[index], + sourceTable: currentValue, + sourceField: "", // 테이블 변경 시 필드 초기화 + }; + handleChange("parentDataMapping", updated); + + // 테이블 선택 시 컬럼 로드 + if (currentValue) { + loadMappingSourceColumns(currentValue, index); + } + }} + className="text-xs" + > + + {table.displayName || table.tableName} + + ))} + + + + + +

+ 품목, 거래처, 사용자 등 데이터를 가져올 테이블을 선택하세요 +

+
+ + {/* 원본 필드 */} +
+ + + + + + + + + + {!mapping.sourceTable ? ( + 소스 테이블을 먼저 선택하세요 + ) : !mappingSourceColumns[index] || mappingSourceColumns[index].length === 0 ? ( + 컬럼 로딩 중... + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {mappingSourceColumns[index].map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], sourceField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + + {col.dataType} + + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 저장 필드 (현재 화면 테이블 컬럼) */} +
+ + + + + + + + + + {targetTableColumns.length === 0 ? ( + 저장 테이블을 먼저 선택하세요 + ) : ( + <> + 컬럼을 찾을 수 없습니다. + + {targetTableColumns.map((col) => { + const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase(); + return ( + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], targetField: col.columnName }; + handleChange("parentDataMapping", updated); + }} + className="text-xs" + > + +
+ {col.columnLabel || col.columnName} + {col.dataType && ( + {col.dataType} + )} +
+
+ ); + })} +
+ + )} +
+
+
+
+
+ + {/* 기본값 (선택사항) */} +
+ + { + const updated = [...(config.parentDataMapping || [])]; + updated[index] = { ...updated[index], defaultValue: e.target.value }; + handleChange("parentDataMapping", updated); + }} + placeholder="값이 없을 때 사용할 기본값" + className="h-7 text-xs" + /> +
+ + {/* 삭제 버튼 */} + +
+
+ ))} +
+ + {(config.parentDataMapping || []).length === 0 && ( +

+ 매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요. +

+ )} + + {/* 예시 */} +
+

💡 예시

+
+

매핑 1: 거래처 ID

+

• 소스 테이블: customer_mng

+

• 원본 필드: id → 저장 필드: customer_id

+ +

매핑 2: 품목 ID

+

• 소스 테이블: item_info

+

• 원본 필드: id → 저장 필드: item_id

+ +

매핑 3: 품목 기준단가

+

• 소스 테이블: item_info

+

• 원본 필드: standard_price → 저장 필드: base_price

+
+
+
+ {/* 사용 예시 */}

💡 사용 예시

diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index 0e6120c6..88d02c8e 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -58,37 +58,81 @@ export interface FieldGroup { displayItems?: DisplayItem[]; } +/** + * 🆕 계산식 노드 타입 + */ +export type CalculationNodeType = "field" | "constant" | "operation" | "function" | "previous"; + +export interface CalculationNode { + type: CalculationNodeType; + // field: 필드명 + fieldName?: string; + // constant: 상수값 + value?: number; + // operation: 연산 + operator?: "+" | "-" | "*" | "/" | "%" | "^"; + left?: CalculationNode; + right?: CalculationNode; + // function: 함수 + functionName?: "round" | "floor" | "ceil" | "abs" | "max" | "min"; + params?: CalculationNode[]; +} + +/** + * 🆕 계산 단계 + */ +export interface CalculationStep { + id: string; + label: string; + expression: CalculationNode; +} + /** * 🆕 자동 계산 설정 */ export interface AutoCalculationConfig { /** 계산 대상 필드명 (예: calculated_price) */ targetField: string; - /** 계산에 사용할 입력 필드들 */ - inputFields: { - basePrice: string; // 기본 단가 필드명 - discountType: string; // 할인 방식 필드명 - discountValue: string; // 할인값 필드명 - roundingType: string; // 반올림 방식 필드명 - roundingUnit: string; // 반올림 단위 필드명 + /** 🆕 계산 방식 */ + mode: "template" | "custom"; + + /** 템플릿 모드 (기존 방식) */ + inputFields?: { + basePrice: string; + discountType: string; + discountValue: string; + roundingType: string; + roundingUnit: string; }; - /** 계산 함수 타입 */ - calculationType: "price" | "custom"; - /** 🆕 카테고리 값 → 연산 매핑 */ + calculationType?: "price" | "custom"; valueMapping?: { - /** 할인 방식 매핑 */ discountType?: { - [valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" } + [valueCode: string]: "none" | "rate" | "amount"; }; - /** 반올림 방식 매핑 */ roundingType?: { [valueCode: string]: "none" | "round" | "floor" | "ceil"; }; - /** 반올림 단위 매핑 (숫자로 변환) */ roundingUnit?: { - [valueCode: string]: number; // 예: { "10": 10, "100": 100 } + [valueCode: string]: number; }; }; + + /** 커스텀 모드 (계산식 빌더) */ + calculationSteps?: CalculationStep[]; +} + +/** + * 🆕 부모 화면 데이터 매핑 설정 + */ +export interface ParentDataMapping { + /** 소스 테이블명 (필수) */ + sourceTable: string; + /** 소스 테이블의 필드명 */ + sourceField: string; + /** 저장할 테이블의 필드명 */ + targetField: string; + /** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */ + defaultValue?: any; } /** @@ -130,6 +174,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig { */ targetTable?: string; + /** + * 🆕 부모 화면 데이터 매핑 + * 이전 화면(예: 거래처 테이블)에서 넘어온 데이터를 저장 테이블의 필드에 자동 매핑 + * 예: { sourceField: "id", targetField: "customer_id" } + */ + parentDataMapping?: ParentDataMapping[]; + /** * 🆕 자동 계산 설정 * 특정 필드가 변경되면 다른 필드를 자동으로 계산 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 5d2d621f..21a5bb0f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -60,6 +60,18 @@ export const SplitPanelLayoutComponent: React.FC const resizable = componentConfig.resizable ?? true; const minLeftWidth = componentConfig.minLeftWidth || 200; const minRightWidth = componentConfig.minRightWidth || 300; + + // 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동) + const shouldShowField = (fieldName: string): boolean => { + const lower = fieldName.toLowerCase(); + + // 기본 제외: id, 비밀번호, 토큰, 회사코드 + if (lower === "id" || lower === "company_code" || lower === "company_name") return false; + if (lower.includes("password") || lower.includes("token")) return false; + + // 나머지는 모두 표시! + return true; + }; // TableOptions Context const { registerTable, unregisterTable } = useTableOptions(); @@ -369,9 +381,18 @@ export const SplitPanelLayoutComponent: 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 +409,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 +778,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 () => { @@ -888,8 +991,50 @@ export const SplitPanelLayoutComponent: React.FC try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); + + // 🔍 중복 제거 설정 디버깅 + console.log("🔍 중복 제거 디버깅:", { + panel: deleteModalPanel, + dataFilter: componentConfig.rightPanel?.dataFilter, + deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, + enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, + }); - const result = await dataApi.deleteRecord(tableName, primaryKey); + let result; + + // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { + const deduplication = componentConfig.rightPanel.dataFilter.deduplication; + const groupByColumn = deduplication.groupByColumn; + + if (groupByColumn && deleteModalItem[groupByColumn]) { + const groupValue = deleteModalItem[groupByColumn]; + console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); + + // groupByColumn 값으로 필터링하여 삭제 + const filterConditions: Record = { + [groupByColumn]: groupValue, + }; + + // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join.leftColumn; + const rightColumn = componentConfig.rightPanel.join.rightColumn; + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + } + + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + + // 그룹 삭제 API 호출 + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + // 단일 레코드 삭제 + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } else { + // 단일 레코드 삭제 + result = await dataApi.deleteRecord(tableName, primaryKey); + } if (result.success) { toast({ @@ -1557,13 +1702,13 @@ export const SplitPanelLayoutComponent: React.FC value: item[leftColumn], }); - // 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등) + // 추가로 다른 의미있는 필드 1-2개 표시 (동적) const additionalKeys = Object.keys(item).filter( (k) => k !== "id" && k !== "ID" && k !== leftColumn && - (k.includes("name") || k.includes("title") || k.includes("desc")), + shouldShowField(k), ); if (additionalKeys.length > 0) { @@ -1792,7 +1937,7 @@ export const SplitPanelLayoutComponent: React.FC label: rightColumnLabels[col.name] || col.label || col.name, })) : Object.keys(filteredData[0] || {}) - .filter((key) => !key.toLowerCase().includes("password")) + .filter((key) => shouldShowField(key)) .slice(0, 5) .map((key) => ({ name: key, @@ -1850,16 +1995,20 @@ export const SplitPanelLayoutComponent: React.FC {!isDesignMode && (
- + {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )}
{/* 수정 버튼 */} - {!isDesignMode && ( - + + {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} + )} {/* 삭제 버튼 */} {!isDesignMode && ( @@ -2011,22 +2245,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 +2300,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)}
@@ -2275,9 +2549,9 @@ export const SplitPanelLayoutComponent: React.FC
)); } else { - // 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외) + // 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외) return Object.entries(editModalFormData) - .filter(([key]) => key !== "company_code" && key !== "company_name") + .filter(([key]) => shouldShowField(key)) .map(([key, value]) => (