diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index b7ba4983..c696d5de 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -393,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} @@ -579,76 +659,40 @@ router.post( ); /** - * 그룹화된 데이터 UPSERT API - * POST /api/data/upsert-grouped - * - * 요청 본문: - * { - * tableName: string, - * parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" }, - * records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ] - * } + * 그룹 삭제 API + * POST /api/data/:tableName/delete-group */ router.post( - "/upsert-grouped", + "/:tableName/delete-group", authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { tableName, parentKeys, records } = req.body; + const { tableName } = req.params; + const filterConditions = 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, - }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); - // UPSERT 수행 - const result = await dataService.upsertGroupedRecords( - tableName, - parentKeys, - records - ); + const result = await dataService.deleteGroupRecords(tableName, filterConditions); 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); + 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 instanceof Error ? error.message : "Unknown error", + message: "그룹 삭제 실패", + error: error.message, }); } } diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index dd40432e..d9b13475 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -16,6 +16,7 @@ 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; @@ -530,7 +531,27 @@ class DataService { }; } - console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]); + // 🔧 날짜 타입 타임존 문제 해결: 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) { @@ -542,7 +563,7 @@ class DataService { let paramIndex = 1; for (const col of groupByColumns) { - const value = baseRecord[col]; + const value = normalizedRows[0][col]; if (value !== undefined && value !== null) { groupConditions.push(`main."${col}" = $${paramIndex}`); groupValues.push(value); @@ -565,18 +586,19 @@ class DataService { const groupResult = await pool.query(groupQuery, groupValues); - console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`); + const normalizedGroupRows = normalizeDates(groupResult.rows); + console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`); return { success: true, - data: groupResult.rows, // 🔧 배열로 반환! + data: normalizedGroupRows, // 🔧 배열로 반환! }; } } return { success: true, - data: result.rows[0], // 그룹핑 없으면 단일 레코드 + data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 }; } } @@ -755,14 +777,33 @@ class DataService { const result = await pool.query(finalQuery, values); - console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`); + // 🔧 날짜 타입 타임존 문제 해결 + 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 = result.rows; + let finalData = normalizedRows; if (deduplication?.enabled && deduplication.groupByColumn) { console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); - finalData = this.deduplicateData(result.rows, deduplication); - console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`); + finalData = this.deduplicateData(normalizedRows, deduplication); + console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`); } return { @@ -1063,6 +1104,53 @@ 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)와 레코드 배열을 받아 @@ -1072,27 +1160,27 @@ class DataService { async upsertGroupedRecords( tableName: string, parentKeys: Record, - records: Array> + records: Array>, + userCompany?: string, + userId?: string ): Promise> { try { // 테이블 접근 권한 검증 - if (!this.canAccessTable(tableName)) { - return { - success: false, - message: `테이블 '${tableName}'에 접근할 수 없습니다.`, - error: "ACCESS_DENIED", - }; + const validation = await this.validateTableAccess(tableName); + if (!validation.valid) { + return validation.error!; } // Primary Key 감지 - const pkColumn = await this.detectPrimaryKey(tableName); - if (!pkColumn) { + 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, @@ -1125,19 +1213,37 @@ class DataService { 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) { - // 전체 레코드 데이터 (parentKeys + newRecord) - const fullRecord = { ...parentKeys, ...newRecord }; + // 날짜 필드 정규화 + 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(newRecord); + const uniqueFields = Object.keys(normalizedRecord); // 기존 레코드에서 일치하는 것 찾기 const existingRecord = existingRecords.rows.find((existing) => { return uniqueFields.every((field) => { const existingValue = existing[field]; - const newValue = newRecord[field]; + const newValue = normalizedRecord[field]; // null/undefined 처리 if (existingValue == null && newValue == null) return true; @@ -1180,15 +1286,49 @@ class DataService { console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); } else { // INSERT: 기존 레코드가 없으면 삽입 - const insertFields = Object.keys(fullRecord); - const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`); - const insertValues = Object.values(fullRecord); + + // 🆕 자동 필드 추가 (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}" (${insertFields.map(f => `"${f}"`).join(", ")}) + 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++; diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 88aca52d..3283ea09 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -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,19 +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 타입 오류 방지) - // "*"는 특별 처리: AS 없이 그냥 main.*만 - const baseColumns = selectColumns - .map((col) => { - if (col === "*") { - return "main.*"; - } - return `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 절과 동일한 로직) @@ -303,6 +333,13 @@ export class EntityJoinService { 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}` @@ -328,6 +365,18 @@ 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를 반환 diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 953b6fc1..cf0a5edb 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -297,10 +297,38 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2)); } - setFormData(response.data); + // 🔧 날짜 필드 정규화 (타임존 제거) + 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 호출 완료"); + console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); } else { console.error("❌ 수정 데이터 로드 실패:", response.error); toast.error("데이터를 불러올 수 없습니다."); @@ -359,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/lib/api/data.ts b/frontend/lib/api/data.ts index 72002ad1..8436dcf4 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -169,6 +169,31 @@ export const dataApi = { 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 테이블명 @@ -207,13 +232,30 @@ export const dataApi = { records: Array> ): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => { try { - const response = await apiClient.post('/data/upsert-grouped', { + 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/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 82abcf20..581c0273 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -257,18 +257,31 @@ export const SelectedItemsDetailInputComponent: React.FC { let fieldValue = record[field.name]; - if (fieldValue !== undefined && fieldValue !== null) { - // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) - if (field.type === "date" || field.type === "datetime") { - const dateStr = String(fieldValue); - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (match) { - const [, year, month, day] = match; - fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) - } + + // 🔧 값이 없으면 기본값 사용 (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; } - entryData[field.name] = fieldValue; } + + // 🔧 날짜 타입이면 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; }); // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) @@ -347,6 +360,59 @@ 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 = async (event: Event) => { @@ -377,17 +443,40 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; // formData 또는 items[0].originalData에서 부모 데이터 가져오기 - const sourceData = 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}`); } }); @@ -402,10 +491,28 @@ export const SelectedItemsDetailInputComponent: React.FC { window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); }; - }, [items, component.id, onFormDataChange, componentConfig, formData]); + }, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]); // 스타일 계산 const componentStyle: React.CSSProperties = { @@ -1027,6 +1134,15 @@ export const SelectedItemsDetailInputComponent: React.FC - {displayItem.label}{formattedValue} + {displayItem.label}{finalValue} ); } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ea80a1f9..532ed3a5 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -979,8 +979,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({