diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index f826a86a..7e1108c3 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -3214,6 +3215,16 @@ "@types/node": "*" } }, + "node_modules/@types/bwip-js": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz", + "integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e9ce3729..b1bfa319 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 98606f51..48b55d18 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -231,7 +231,7 @@ export const deleteFormData = async ( try { const { id } = req.params; const { companyCode, userId } = req.user as any; - const { tableName } = req.body; + const { tableName, screenId } = req.body; if (!tableName) { return res.status(400).json({ @@ -240,7 +240,16 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 + // screenId를 숫자로 변환 (문자열로 전달될 수 있음) + const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined; + + await dynamicFormService.deleteFormData( + id, + tableName, + companyCode, + userId, + parsedScreenId // screenId 추가 (제어관리 실행용) + ); res.json({ success: true, diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index fbb88750..013b2034 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,6 +30,7 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 + deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -139,6 +140,24 @@ export class EntityJoinController { } } + // 🆕 중복 제거 설정 처리 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined = undefined; + if (deduplication) { + try { + parsedDeduplication = + typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; + logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); + } catch (error) { + logger.warn("중복 제거 설정 파싱 오류:", error); + parsedDeduplication = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -156,13 +175,26 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 + deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); + // 🆕 중복 제거 처리 (결과 데이터에 적용) + let finalData = result; + if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { + logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); + const originalCount = result.data.length; + finalData = { + ...result, + data: this.deduplicateData(result.data, parsedDeduplication), + }; + logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); + } + res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: result, + data: finalData, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -537,6 +569,98 @@ export class EntityJoinController { }); } } + + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + 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((r) => r.base_price === true || r.base_price === "true") || rows[0]; + break; + + case "current_date": + // 오늘 날짜 기준 유효 기간 내 행 선택 + const today = new Date().toISOString().split("T")[0]; + selectedRow = rows.find((r) => { + const startDate = r.start_date; + const endDate = r.end_date; + if (!startDate) return true; + if (startDate <= today && (!endDate || endDate >= today)) return true; + return false; + }) || rows[0]; + break; + + default: + selectedRow = rows[0]; + } + + if (selectedRow) { + result.push(selectedRow); + } + } + + return result; + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f9d88d92..574f1cf8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,10 +1,262 @@ import express from "express"; import { dataService } from "../services/dataService"; +import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +// ================================ +// 마스터-디테일 엑셀 API +// ================================ + +/** + * 마스터-디테일 관계 정보 조회 + * GET /api/data/master-detail/relation/:screenId + */ +router.get( + "/master-detail/relation/:screenId", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId } = req.params; + + if (!screenId || isNaN(parseInt(screenId))) { + return res.status(400).json({ + success: false, + message: "유효한 screenId가 필요합니다.", + }); + } + + console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`); + + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.json({ + success: true, + data: null, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + console.log(`✅ 마스터-디테일 관계 발견:`, { + masterTable: relation.masterTable, + detailTable: relation.detailTable, + joinKey: relation.masterKeyColumn, + }); + + return res.json({ + success: true, + data: relation, + }); + } catch (error: any) { + console.error("마스터-디테일 관계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * POST /api/data/master-detail/download + */ +router.post( + "/master-detail/download", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, filters } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!screenId) { + return res.status(400).json({ + success: false, + message: "screenId가 필요합니다.", + }); + } + + console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. JOIN 데이터 조회 + const data = await masterDetailExcelService.getJoinedData( + relation, + companyCode, + filters + ); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`); + + return res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error("마스터-디테일 다운로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 다운로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 업로드 + * POST /api/data/master-detail/upload + */ +router.post( + "/master-detail/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, data } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!screenId || !data || !Array.isArray(data)) { + return res.status(400).json({ + success: false, + message: "screenId와 data 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. 데이터 업로드 + const result = await masterDetailExcelService.uploadJoinedData( + relation, + data, + companyCode, + userId + ); + + console.log(`✅ 마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * + * POST /api/data/master-detail/upload-simple + */ +router.post( + "/master-detail/upload-simple", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!screenId || !detailData || !Array.isArray(detailData)) { + return res.status(400).json({ + success: false, + message: "screenId와 detailData 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`); + console.log(` 마스터 필드 값:`, masterFieldValues); + console.log(` 채번 규칙 ID:`, numberingRuleId); + console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음"); + + // 업로드 실행 + const result = await masterDetailExcelService.uploadSimple( + parseInt(screenId), + detailData, + masterFieldValues || {}, + numberingRuleId, + companyCode, + userId, + afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성) + afterUploadFlows // 업로드 후 제어 실행 (다중) + ); + + console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 간단 모드 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +// ================================ +// 기존 데이터 API +// ================================ + /** * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 8337ed74..89d96859 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1192,12 +1192,18 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + * @param id 삭제할 레코드 ID + * @param tableName 테이블명 + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param screenId 화면 ID (제어관리 실행용, 선택사항) */ async deleteFormData( id: string | number, tableName: string, companyCode?: string, - userId?: string + userId?: string, + screenId?: number ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { @@ -1310,14 +1316,19 @@ export class DynamicFormService { const recordCompanyCode = deletedRecord?.company_code || companyCode || "*"; - await this.executeDataflowControlIfConfigured( - 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) - tableName, - deletedRecord, - "delete", - userId || "system", - recordCompanyCode - ); + // screenId가 전달되지 않으면 제어관리를 실행하지 않음 + if (screenId && screenId > 0) { + await this.executeDataflowControlIfConfigured( + screenId, + tableName, + deletedRecord, + "delete", + userId || "system", + recordCompanyCode + ); + } else { + console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")"); + } } } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1662,10 +1673,16 @@ export class DynamicFormService { !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); - // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 + // 버튼 컴포넌트이고 제어관리가 활성화된 경우 + // triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete + const buttonActionType = properties?.componentConfig?.action?.type; + const isMatchingAction = + (triggerType === "delete" && buttonActionType === "delete") || + ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + if ( properties?.componentType === "button-primary" && - properties?.componentConfig?.action?.type === "save" && + isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts new file mode 100644 index 00000000..4b1a7218 --- /dev/null +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -0,0 +1,868 @@ +/** + * 마스터-디테일 엑셀 처리 서비스 + * + * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 + * 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다. + */ + +import { query, queryOne, transaction, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** + * 마스터-디테일 관계 정보 + */ +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no) + detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no) + masterColumns: ColumnInfo[]; + detailColumns: ColumnInfo[]; +} + +/** + * 컬럼 정보 + */ +export interface ColumnInfo { + name: string; + label: string; + inputType: string; + isFromMaster: boolean; +} + +/** + * 분할 패널 설정 + */ +export interface SplitPanelConfig { + leftPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + }; + rightPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + relation?: { + type: string; + foreignKey: string; + leftColumn: string; + }; + }; +} + +/** + * 엑셀 다운로드 결과 + */ +export interface ExcelDownloadData { + headers: string[]; // 컬럼 라벨들 + columns: string[]; // 컬럼명들 + data: Record[]; + masterColumns: string[]; // 마스터 컬럼 목록 + detailColumns: string[]; // 디테일 컬럼 목록 + joinKey: string; // 조인 키 +} + +/** + * 엑셀 업로드 결과 + */ +export interface ExcelUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MasterDetailExcelService { + + /** + * 화면 ID로 분할 패널 설정 조회 + */ + async getSplitPanelConfig(screenId: number): Promise { + try { + logger.info(`분할 패널 설정 조회: screenId=${screenId}`); + + // screen_layouts에서 split-panel-layout 컴포넌트 찾기 + const result = await queryOne( + `SELECT properties->>'componentConfig' as config + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties->>'componentType' = 'split-panel-layout' + LIMIT 1`, + [screenId] + ); + + if (!result || !result.config) { + logger.info(`분할 패널 없음: screenId=${screenId}`); + return null; + } + + const config = typeof result.config === "string" + ? JSON.parse(result.config) + : result.config; + + logger.info(`분할 패널 설정 발견:`, { + leftTable: config.leftPanel?.tableName, + rightTable: config.rightPanel?.tableName, + relation: config.rightPanel?.relation, + }); + + return { + leftPanel: config.leftPanel, + rightPanel: config.rightPanel, + }; + } catch (error: any) { + logger.error(`분할 패널 설정 조회 실패: ${error.message}`); + return null; + } + } + + /** + * column_labels에서 Entity 관계 정보 조회 + * 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기 + */ + async getEntityRelation( + detailTable: string, + masterTable: string + ): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> { + try { + logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`); + + const result = await queryOne( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [detailTable, masterTable] + ); + + if (!result) { + logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`); + return null; + } + + logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`); + + return { + detailFkColumn: result.column_name, + masterKeyColumn: result.reference_column, + }; + } catch (error: any) { + logger.error(`Entity 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 테이블의 컬럼 라벨 정보 조회 + */ + async getColumnLabels(tableName: string): Promise> { + try { + const result = await query( + `SELECT column_name, column_label + FROM column_labels + WHERE table_name = $1`, + [tableName] + ); + + const labelMap = new Map(); + for (const row of result) { + labelMap.set(row.column_name, row.column_label || row.column_name); + } + + return labelMap; + } catch (error: any) { + logger.error(`컬럼 라벨 조회 실패: ${error.message}`); + return new Map(); + } + } + + /** + * 마스터-디테일 관계 정보 조합 + */ + async getMasterDetailRelation( + screenId: number + ): Promise { + try { + // 1. 분할 패널 설정 조회 + const splitPanel = await this.getSplitPanelConfig(screenId); + if (!splitPanel) { + return null; + } + + const masterTable = splitPanel.leftPanel.tableName; + const detailTable = splitPanel.rightPanel.tableName; + + if (!masterTable || !detailTable) { + logger.warn("마스터 또는 디테일 테이블명 없음"); + return null; + } + + // 2. 분할 패널의 relation 정보가 있으면 우선 사용 + let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; + let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; + + // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회 + if (!masterKeyColumn || !detailFkColumn) { + const entityRelation = await this.getEntityRelation(detailTable, masterTable); + if (entityRelation) { + masterKeyColumn = entityRelation.masterKeyColumn; + detailFkColumn = entityRelation.detailFkColumn; + } + } + + if (!masterKeyColumn || !detailFkColumn) { + logger.warn("조인 키 정보를 찾을 수 없음"); + return null; + } + + // 4. 컬럼 라벨 정보 조회 + const masterLabels = await this.getColumnLabels(masterTable); + const detailLabels = await this.getColumnLabels(detailTable); + + // 5. 마스터 컬럼 정보 구성 + const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({ + name: col.name, + label: masterLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: true, + })); + + // 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외) + const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns + .filter(col => col.name !== detailFkColumn) // FK 컬럼 제외 + .map(col => ({ + name: col.name, + label: detailLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: false, + })); + + logger.info(`마스터-디테일 관계 구성 완료:`, { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumnCount: masterColumns.length, + detailColumnCount: detailColumns.length, + }); + + return { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumns, + detailColumns, + }; + } catch (error: any) { + logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용) + */ + async getJoinedData( + relation: MasterDetailRelation, + companyCode: string, + filters?: Record + ): Promise { + try { + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 조인 컬럼과 일반 컬럼 분리 + // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) + const entityJoins: Array<{ + refTable: string; + refColumn: string; + sourceColumn: string; + alias: string; + displayColumn: string; + }> = []; + + // SELECT 절 구성 + const selectParts: string[] = []; + let aliasIndex = 0; + + // 마스터 컬럼 처리 + for (const col of masterColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + // FK를 못 찾으면 NULL로 처리 + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`m."${col.name}"`); + } + } + + // 디테일 컬럼 처리 + for (const col of detailColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`d."${col.name}"`); + } + } + + const selectClause = selectParts.join(", "); + + // 엔티티 조인 절 구성 + const entityJoinClauses = entityJoins.map(ej => + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + ).join("\n "); + + // WHERE 절 구성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (최고 관리자 제외) + if (companyCode && companyCode !== "*") { + whereConditions.push(`m.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 추가 필터 적용 + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null && value !== "") { + // 조인 컬럼인지 확인 + if (key.includes(".")) continue; + // 마스터 테이블 컬럼인지 확인 + const isMasterCol = masterColumns.some(c => c.name === key); + const tableAlias = isMasterCol ? "m" : "d"; + whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`); + params.push(value); + paramIndex++; + } + } + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // JOIN 쿼리 실행 + const sql = ` + SELECT ${selectClause} + FROM "${masterTable}" m + LEFT JOIN "${detailTable}" d + ON m."${masterKeyColumn}" = d."${detailFkColumn}" + AND m.company_code = d.company_code + ${entityJoinClauses} + ${whereClause} + ORDER BY m."${masterKeyColumn}", d.id + `; + + logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); + + const data = await query(sql, params); + + // 헤더 및 컬럼 정보 구성 + const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)]; + const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)]; + + logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`); + + return { + headers, + columns, + data, + masterColumns: masterColumns.map(c => c.name), + detailColumns: detailColumns.map(c => c.name), + joinKey: masterKeyColumn, + }; + } catch (error: any) { + logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기 + */ + private async findForeignKeyColumn( + sourceTable: string, + referenceTable: string + ): Promise<{ sourceColumn: string; referenceColumn: string } | null> { + try { + const result = await query<{ column_name: string; reference_column: string }>( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND reference_table = $2 + AND input_type = 'entity' + LIMIT 1`, + [sourceTable, referenceTable] + ); + + if (result.length > 0) { + return { + sourceColumn: result[0].column_name, + referenceColumn: result[0].reference_column, + }; + } + return null; + } catch (error) { + logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error); + return null; + } + } + + /** + * 마스터-디테일 데이터 업로드 (엑셀 업로드용) + * + * 처리 로직: + * 1. 엑셀 데이터를 마스터 키로 그룹화 + * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT + * 3. 해당 마스터 키의 기존 디테일 삭제 + * 4. 새 디테일 데이터 INSERT + */ + async uploadJoinedData( + relation: MasterDetailRelation, + data: Record[], + companyCode: string, + userId?: string + ): Promise { + const result: ExcelUploadResult = { + success: false, + masterInserted: 0, + masterUpdated: 0, + detailInserted: 0, + detailDeleted: 0, + errors: [], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 1. 데이터를 마스터 키로 그룹화 + const groupedData = new Map[]>(); + + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + + // 2. 각 그룹 처리 + for (const [masterKey, rows] of groupedData.entries()) { + try { + // 2a. 마스터 데이터 추출 (첫 번째 행에서) + const masterData: Record = {}; + for (const col of masterColumns) { + if (rows[0][col.name] !== undefined) { + masterData[col.name] = rows[0][col.name]; + } + } + + // 회사 코드, 작성자 추가 + masterData.company_code = companyCode; + if (userId) { + masterData.writer = userId; + } + + // 2b. 마스터 UPSERT + const existingMaster = await client.query( + `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + // UPDATE + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}, updated_date = NOW() + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // INSERT + const insertCols = Object.keys(masterData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.masterInserted++; + } + + // 2c. 기존 디테일 삭제 + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; + + // 2d. 새 디테일 INSERT + for (const row of rows) { + const detailData: Record = {}; + + // FK 컬럼 추가 + detailData[detailFkColumn] = masterKey; + detailData.company_code = companyCode; + if (userId) { + detailData.writer = userId; + } + + // 디테일 컬럼 데이터 추출 + for (const col of detailColumns) { + if (row[col.name] !== undefined) { + detailData[col.name] = row[col.name]; + } + } + + const insertCols = Object.keys(detailData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => detailData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.detailInserted++; + } + } catch (error: any) { + result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); + logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0; + + logger.info(`마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + detailDeleted: result.detailDeleted, + errors: result.errors.length, + }); + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 마스터-디테일 간단 모드 업로드 + * + * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 + * 채번 규칙을 통해 마스터 키 자동 생성 + * + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional) + */ + async uploadSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId: string | undefined, + companyCode: string, + userId: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise<{ + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + }> { + const result: { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + } = { + success: false, + masterInserted: 0, + detailInserted: 0, + generatedKey: "", + errors: [] as string[], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. 마스터-디테일 관계 정보 조회 + const relation = await this.getMasterDetailRelation(screenId); + if (!relation) { + throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다."); + } + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation; + + // 2. 채번 처리 + let generatedKey: string; + + if (numberingRuleId) { + // 채번 규칙으로 키 생성 + generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode); + } else { + // 채번 규칙 없으면 마스터 필드에서 키 값 사용 + generatedKey = masterFieldValues[masterKeyColumn]; + if (!generatedKey) { + throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`); + } + } + + result.generatedKey = generatedKey; + logger.info(`채번 결과: ${generatedKey}`); + + // 3. 마스터 레코드 생성 + const masterData: Record = { + ...masterFieldValues, + [masterKeyColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 마스터 컬럼명 목록 구성 + const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined); + const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`); + const masterValues = masterCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${masterPlaceholders.join(", ")}, NOW())`, + masterValues + ); + result.masterInserted = 1; + logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`); + + // 4. 디테일 레코드들 생성 + for (const row of detailData) { + try { + const detailRowData: Record = { + ...row, + [detailFkColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 빈 값 필터링 및 id 제외 + const detailCols = Object.keys(detailRowData).filter(k => + k !== "id" && + detailRowData[k] !== undefined && + detailRowData[k] !== null && + detailRowData[k] !== "" + ); + const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`); + const detailValues = detailCols.map(k => detailRowData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${detailPlaceholders.join(", ")}, NOW())`, + detailValues + ); + result.detailInserted++; + } catch (error: any) { + result.errors.push(`디테일 행 처리 실패: ${error.message}`); + logger.error(`디테일 행 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.detailInserted > 0; + + logger.info(`마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + // 업로드 후 제어 실행 (단일 또는 다중) + const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0 + ? afterUploadFlows // 다중 제어 + : afterUploadFlowId + ? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성) + : []; + + if (flowsToExecute.length > 0 && result.success) { + try { + const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); + + // 마스터 데이터를 제어에 전달 + const masterData = { + ...masterFieldValues, + [relation!.masterKeyColumn]: result.generatedKey, + company_code: companyCode, + }; + + const controlResults: any[] = []; + + // 순서대로 제어 실행 + for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) { + logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`); + + const controlResult = await NodeFlowExecutionService.executeFlow( + parseInt(flow.flowId), + { + sourceData: [masterData], + dataSourceType: "formData", + buttonId: "excel-upload-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: masterData, + } + ); + + controlResults.push({ + flowId: flow.flowId, + order: flow.order, + success: controlResult.success, + message: controlResult.message, + executedNodes: controlResult.nodes?.length || 0, + }); + } + + result.controlResult = { + success: controlResults.every(r => r.success), + executedFlows: controlResults.length, + results: controlResults, + }; + + logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult); + } catch (controlError: any) { + logger.error(`업로드 후 제어 실행 실패:`, controlError); + result.controlResult = { + success: false, + message: `제어 실행 실패: ${controlError.message}`, + }; + } + } + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용) + */ + private async generateNumberWithRule( + client: any, + ruleId: string, + companyCode: string + ): Promise { + try { + // 기존 numberingRuleService를 사용하여 코드 할당 + const { numberingRuleService } = await import("./numberingRuleService"); + const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + + logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`); + + return generatedCode; + } catch (error: any) { + logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`); + throw error; + } + } +} + +export const masterDetailExcelService = new MasterDetailExcelService(); + diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index baa1f02c..bfd628ce 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -969,21 +969,56 @@ export class NodeFlowExecutionService { const insertedData = { ...data }; console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { + + // 🔥 채번 규칙 서비스 동적 import + const { numberingRuleService } = await import("./numberingRuleService"); + + for (const mapping of fieldMappings) { fields.push(mapping.targetField); - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : data[mapping.sourceField]; - - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` - ); + let value: any; + + // 🔥 값 생성 유형에 따른 처리 + const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source"); + + if (valueType === "autoGenerate" && mapping.numberingRuleId) { + // 자동 생성 (채번 규칙) + const companyCode = context.buttonContext?.companyCode || "*"; + try { + value = await numberingRuleService.allocateCode( + mapping.numberingRuleId, + companyCode + ); + console.log( + ` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})` + ); + } catch (error: any) { + logger.error(`채번 규칙 적용 실패: ${error.message}`); + console.error( + ` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}` + ); + throw new Error( + `채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}` + ); + } + } else if (valueType === "static" || mapping.staticValue !== undefined) { + // 고정값 + value = mapping.staticValue; + console.log( + ` 📌 고정값: ${mapping.targetField} = ${value}` + ); + } else { + // 소스 필드 + value = data[mapping.sourceField]; + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + } + values.push(value); // 🔥 삽입된 값을 데이터에 반영 insertedData[mapping.targetField] = value; - }); + } // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) const hasWriterMapping = fieldMappings.some( @@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService { } }); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, paramIndex ); @@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService { return deletedDataArray; } - // 🆕 context-data 모드: 개별 삭제 (PK 자동 추가) + // 🆕 context-data 모드: 개별 삭제 console.log("🎯 context-data 모드: 개별 삭제 시작"); for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, 1 ); @@ -2865,10 +2916,11 @@ export class NodeFlowExecutionService { if (fieldValue === null || fieldValue === undefined || fieldValue === "") { logger.info( - `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환` + `⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)` ); - // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true - return operator === "NOT_EXISTS_IN"; + // 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환 + // 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨 + return false; } try { diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7df10fdb..70ed6205 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2761,33 +2761,64 @@ export class TableManagementService { ); for (const additionalColumn of options.additionalJoinColumns) { - // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기) - const baseJoinConfig = joinConfigs.find( + // 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기 + let baseJoinConfig = joinConfigs.find( (config) => config.sourceColumn === additionalColumn.sourceColumn ); + // 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때) + // 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응 + if (!baseJoinConfig && (additionalColumn as any).referenceTable) { + baseJoinConfig = joinConfigs.find( + (config) => config.referenceTable === (additionalColumn as any).referenceTable + ); + if (baseJoinConfig) { + logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`); + } + } + if (baseJoinConfig) { - // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) - // sourceColumn을 제거한 나머지 부분이 실제 컬럼명 - const sourceColumn = baseJoinConfig.sourceColumn; // dept_code - const joinAlias = additionalColumn.joinAlias; // dept_code_company_name - const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name + // joinAlias에서 실제 컬럼명 추출 + const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) + const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) + + // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 + // customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거) + // 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거) + let actualColumnName: string; + + // 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출 + const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id) + if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) { + // 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, ""); + } else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) { + // 실제 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, ""); + } else { + // 어느 것도 아니면 원본 사용 + actualColumnName = originalJoinAlias; + } + + // 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반) + const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`; logger.info(`🔍 조인 컬럼 상세 분석:`, { sourceColumn, - joinAlias, + frontendSourceColumn, + originalJoinAlias, + correctedJoinAlias, actualColumnName, - referenceTable: additionalColumn.sourceTable, + referenceTable: (additionalColumn as any).referenceTable, }); // 🚨 기본 Entity 조인과 중복되지 않도록 체크 const isBasicEntityJoin = - additionalColumn.joinAlias === - `${baseJoinConfig.sourceColumn}_name`; + correctedJoinAlias === `${sourceColumn}_name`; if (isBasicEntityJoin) { logger.info( - `⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀` + `⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀` ); continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } @@ -2795,14 +2826,14 @@ export class TableManagementService { // 추가 조인 컬럼 설정 생성 const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, - sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) referenceTable: (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (dept_info) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name) + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name) + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) separator: " - ", // 기본 구분자 }; @@ -3769,6 +3800,15 @@ export class TableManagementService { const cacheableJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = []; + // 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요) + const companySpecificTables = [ + "supplier_mng", + "customer_mng", + "item_info", + "dept_info", + // 필요시 추가 + ]; + for (const config of joinConfigs) { // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 if (config.referenceTable === "table_column_category_values") { @@ -3777,6 +3817,13 @@ export class TableManagementService { continue; } + // 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시) + if (companySpecificTables.includes(config.referenceTable)) { + dbJoins.push(config); + console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 867b6f85..6785eac8 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -34,6 +34,35 @@ import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; import { EditableSpreadsheet } from "./EditableSpreadsheet"; +// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정) +export interface MasterDetailExcelConfig { + // 테이블 정보 + masterTable?: string; + detailTable?: string; + masterKeyColumn?: string; + detailFkColumn?: string; + // 채번 + numberingRuleId?: string; + // 업로드 전 사용자가 선택할 마스터 테이블 필드 + masterSelectFields?: Array<{ + columnName: string; + columnLabel: string; + required: boolean; + inputType: "entity" | "date" | "text" | "select"; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + }>; + // 엑셀에서 매핑할 디테일 테이블 필드 + detailExcelFields?: Array<{ + columnName: string; + columnLabel: string; + required: boolean; + }>; + masterDefaults?: Record; + detailDefaults?: Record; +} + export interface ExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -42,6 +71,19 @@ export interface ExcelUploadModalProps { keyColumn?: string; onSuccess?: () => void; userId?: string; + // 마스터-디테일 지원 + screenId?: number; + isMasterDetail?: boolean; + masterDetailRelation?: { + masterTable: string; + detailTable: string; + masterKeyColumn: string; + detailFkColumn: string; + masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + }; + // 🆕 마스터-디테일 엑셀 업로드 설정 + masterDetailExcelConfig?: MasterDetailExcelConfig; } interface ColumnMapping { @@ -57,6 +99,10 @@ export const ExcelUploadModal: React.FC = ({ keyColumn, onSuccess, userId = "guest", + screenId, + isMasterDetail = false, + masterDetailRelation, + masterDetailExcelConfig, }) => { const [currentStep, setCurrentStep] = useState(1); @@ -79,6 +125,116 @@ export const ExcelUploadModal: React.FC = ({ // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); + // 🆕 마스터-디테일 모드: 마스터 필드 입력값 + const [masterFieldValues, setMasterFieldValues] = useState>({}); + const [entitySearchData, setEntitySearchData] = useState>({}); + const [entitySearchLoading, setEntitySearchLoading] = useState>({}); + const [entityDisplayColumns, setEntityDisplayColumns] = useState>({}); + + // 🆕 엔티티 참조 데이터 로드 + useEffect(() => { + console.log("🔍 엔티티 데이터 로드 체크:", { + masterSelectFields: masterDetailExcelConfig?.masterSelectFields, + open, + isMasterDetail, + }); + + if (!masterDetailExcelConfig?.masterSelectFields) return; + + const loadEntityData = async () => { + const { apiClient } = await import("@/lib/api/client"); + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + + for (const field of masterDetailExcelConfig.masterSelectFields!) { + console.log("🔍 필드 처리:", field); + + if (field.inputType === "entity") { + setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true })); + try { + let refTable = field.referenceTable; + console.log("🔍 초기 refTable:", refTable); + + let displayCol = field.displayColumn; + + // referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회 + if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) { + console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable); + const colResponse = await apiClient.get( + `/table-management/tables/${masterDetailExcelConfig.masterTable}/columns` + ); + console.log("🔍 컬럼 조회 응답:", colResponse.data); + + if (colResponse.data?.success && colResponse.data?.data?.columns) { + const colInfo = colResponse.data.data.columns.find( + (c: any) => (c.columnName || c.column_name) === field.columnName + ); + console.log("🔍 찾은 컬럼 정보:", colInfo); + if (colInfo) { + if (!refTable) { + refTable = colInfo.referenceTable || colInfo.reference_table; + console.log("🔍 DB에서 가져온 refTable:", refTable); + } + if (!displayCol) { + displayCol = colInfo.displayColumn || colInfo.display_column; + console.log("🔍 DB에서 가져온 displayColumn:", displayCol); + } + } + } + } + + // displayColumn 저장 (Select 렌더링 시 사용) + if (displayCol) { + setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol })); + } + + if (refTable) { + console.log("🔍 엔티티 데이터 조회:", refTable); + const response = await DynamicFormApi.getTableData(refTable, { + page: 1, + pageSize: 1000, + }); + console.log("🔍 엔티티 데이터 응답:", response); + // getTableData는 { success, data: [...] } 형식으로 반환 + const rows = response.data?.rows || response.data; + if (response.success && rows && Array.isArray(rows)) { + setEntitySearchData((prev) => ({ + ...prev, + [field.columnName]: rows, + })); + console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개"); + } + } else { + console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName); + } + } catch (error) { + console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error); + } finally { + setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false })); + } + } + } + }; + + if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) { + loadEntityData(); + } + }, [open, isMasterDetail, masterDetailExcelConfig]); + + // 마스터-디테일 모드에서 마스터 필드 입력 여부 확인 + const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig; + const hasMasterSelectFields = isSimpleMasterDetailMode && + (masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0; + + // 마스터 필드가 모두 입력되었는지 확인 + const isMasterFieldsValid = () => { + if (!hasMasterSelectFields) return true; + return masterDetailExcelConfig!.masterSelectFields!.every((field) => { + if (!field.required) return true; + const value = masterFieldValues[field.columnName]; + return value !== undefined && value !== null && value !== ""; + }); + }; + // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; @@ -184,50 +340,138 @@ export const ExcelUploadModal: React.FC = ({ const loadTableSchema = async () => { try { - console.log("🔍 테이블 스키마 로드 시작:", { tableName }); + console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode }); - const response = await getTableSchema(tableName); + let allColumns: TableColumn[] = []; - console.log("📊 테이블 스키마 응답:", response); + // 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택) + if (isSimpleMasterDetailMode && masterDetailRelation) { + const { detailTable, detailFkColumn } = masterDetailRelation; + + console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable }); - if (response.success && response.data) { - // 자동 생성 컬럼 제외 - const filteredColumns = response.data.columns.filter( - (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) - ); - console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns); - setSystemColumns(filteredColumns); - - // 기존 매핑 템플릿 조회 - console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns }); - const mappingResponse = await findMappingByColumns(tableName, excelColumns); - - if (mappingResponse.success && mappingResponse.data) { - // 저장된 매핑 템플릿이 있으면 자동 적용 - console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data); - const savedMappings = mappingResponse.data.columnMappings; - - const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ - excelColumn: col, - systemColumn: savedMappings[col] || null, - })); - setColumnMappings(appliedMappings); - setIsAutoMappingLoaded(true); - - const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; - toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`); - } else { - // 매핑 템플릿이 없으면 초기 상태로 설정 - console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조"); - const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ - excelColumn: col, - systemColumn: null, - })); - setColumnMappings(initialMappings); - setIsAutoMappingLoaded(false); + // 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택) + const detailResponse = await getTableSchema(detailTable); + if (detailResponse.success && detailResponse.data) { + // 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체 + const configuredFields = masterDetailExcelConfig?.detailExcelFields; + + const detailCols = detailResponse.data.columns + .filter((col) => { + // 자동 생성 컬럼, FK 컬럼 제외 + if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false; + if (col.name === detailFkColumn) return false; + + // 설정된 필드가 있으면 해당 필드만 + if (configuredFields && configuredFields.length > 0) { + return configuredFields.some((f) => f.columnName === col.name); + } + return true; + }) + .map((col) => { + // 설정에서 라벨 찾기 + const configField = configuredFields?.find((f) => f.columnName === col.name); + return { + ...col, + label: configField?.columnLabel || col.label || col.name, + originalName: col.name, + sourceTable: detailTable, + }; + }); + allColumns = detailCols; } + + console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length); + } + // 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기 + else if (isMasterDetail && masterDetailRelation) { + const { masterTable, detailTable, detailFkColumn } = masterDetailRelation; + + console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable }); + + // 마스터 테이블 스키마 + const masterResponse = await getTableSchema(masterTable); + if (masterResponse.success && masterResponse.data) { + const masterCols = masterResponse.data.columns + .filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) + .map((col) => ({ + ...col, + // 유니크 키를 위해 테이블명 접두사 추가 + name: `${masterTable}.${col.name}`, + label: `[마스터] ${col.label || col.name}`, + originalName: col.name, + sourceTable: masterTable, + })); + allColumns = [...allColumns, ...masterCols]; + } + + // 디테일 테이블 스키마 (FK 컬럼 제외) + const detailResponse = await getTableSchema(detailTable); + if (detailResponse.success && detailResponse.data) { + const detailCols = detailResponse.data.columns + .filter((col) => + !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && + col.name !== detailFkColumn // FK 컬럼 제외 + ) + .map((col) => ({ + ...col, + // 유니크 키를 위해 테이블명 접두사 추가 + name: `${detailTable}.${col.name}`, + label: `[디테일] ${col.label || col.name}`, + originalName: col.name, + sourceTable: detailTable, + })); + allColumns = [...allColumns, ...detailCols]; + } + + console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length); } else { - console.error("❌ 테이블 스키마 로드 실패:", response); + // 기존 단일 테이블 모드 + const response = await getTableSchema(tableName); + + console.log("📊 테이블 스키마 응답:", response); + + if (response.success && response.data) { + // 자동 생성 컬럼 제외 + allColumns = response.data.columns.filter( + (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) + ); + } else { + console.error("❌ 테이블 스키마 로드 실패:", response); + return; + } + } + + console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns); + setSystemColumns(allColumns); + + // 기존 매핑 템플릿 조회 + console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns }); + const mappingResponse = await findMappingByColumns(tableName, excelColumns); + + if (mappingResponse.success && mappingResponse.data) { + // 저장된 매핑 템플릿이 있으면 자동 적용 + console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data); + const savedMappings = mappingResponse.data.columnMappings; + + const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: savedMappings[col] || null, + })); + setColumnMappings(appliedMappings); + setIsAutoMappingLoaded(true); + + const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; + toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`); + } else { + // 매핑 템플릿이 없으면 초기 상태로 설정 + console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조"); + const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: null, + })); + setColumnMappings(initialMappings); + setIsAutoMappingLoaded(false); } } catch (error) { console.error("❌ 테이블 스키마 로드 실패:", error); @@ -239,18 +483,35 @@ export const ExcelUploadModal: React.FC = ({ const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); + // [마스터], [디테일] 접두사 제거 후 비교 + const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, ""); - // 1. 먼저 라벨로 매칭 시도 - let matchedSystemCol = systemColumns.find( - (sysCol) => - sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol - ); + // 1. 먼저 라벨로 매칭 시도 (접두사 제거 후) + let matchedSystemCol = systemColumns.find((sysCol) => { + if (!sysCol.label) return false; + // [마스터], [디테일] 접두사 제거 후 비교 + const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, ""); + return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol; + }); // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 if (!matchedSystemCol) { - matchedSystemCol = systemColumns.find( - (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol - ); + matchedSystemCol = systemColumns.find((sysCol) => { + // 마스터-디테일 모드: originalName이 있으면 사용 + const originalName = (sysCol as any).originalName; + const colName = originalName || sysCol.name; + return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol; + }); + } + + // 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교 + if (!matchedSystemCol) { + matchedSystemCol = systemColumns.find((sysCol) => { + // 테이블.컬럼 형식에서 컬럼만 추출 + const nameParts = sysCol.name.split("."); + const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol; + }); } return { @@ -285,6 +546,12 @@ export const ExcelUploadModal: React.FC = ({ return; } + // 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사 + if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) { + toast.error("마스터 정보를 모두 입력해주세요."); + return; + } + // 1단계 → 2단계 전환 시: 빈 헤더 열 제외 if (currentStep === 1) { // 빈 헤더가 아닌 열만 필터링 @@ -344,7 +611,12 @@ export const ExcelUploadModal: React.FC = ({ const mappedRow: Record = {}; columnMappings.forEach((mapping) => { if (mapping.systemColumn) { - mappedRow[mapping.systemColumn] = row[mapping.excelColumn]; + // 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출 + let colName = mapping.systemColumn; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + mappedRow[colName] = row[mapping.excelColumn]; } }); return mappedRow; @@ -364,60 +636,96 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); - let successCount = 0; - let failCount = 0; + // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) + if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 간단 모드 업로드:", { + masterDetailRelation, + masterFieldValues, + numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + }); - for (const row of filteredData) { - try { - if (uploadMode === "insert") { - const formData = { screenId: 0, tableName, data: row }; - const result = await DynamicFormApi.saveFormData(formData); - if (result.success) { - successCount++; - } else { - failCount++; - } - } - } catch (error) { - failCount++; - } - } - - if (successCount > 0) { - toast.success( - `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( + screenId, + filteredData, + masterFieldValues, + masterDetailExcelConfig?.numberingRuleId || undefined, + masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 + masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 ); - // 매핑 템플릿 저장 (UPSERT - 자동 저장) - try { - const mappingsToSave: Record = {}; - columnMappings.forEach((mapping) => { - mappingsToSave[mapping.excelColumn] = mapping.systemColumn; - }); - - console.log("💾 매핑 템플릿 저장 중...", { - tableName, - excelColumns, - mappingsToSave, - }); - const saveResult = await saveMappingTemplate( - tableName, - excelColumns, - mappingsToSave + if (uploadResult.success && uploadResult.data) { + const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` + + (errors?.length > 0 ? ` (오류: ${errors.length}건)` : "") ); - if (saveResult.success) { - console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data); - } else { - console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error); + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); + } + } + // 🆕 마스터-디테일 기존 모드 처리 + else if (isMasterDetail && screenId && masterDetailRelation) { + console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); + + const uploadResult = await DynamicFormApi.uploadMasterDetailData( + screenId, + filteredData + ); + + if (uploadResult.success && uploadResult.data) { + const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data; + + toast.success( + `마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` + + (errors.length > 0 ? ` (오류: ${errors.length}건)` : "") + ); + + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); + } + } else { + // 기존 단일 테이블 업로드 로직 + let successCount = 0; + let failCount = 0; + + for (const row of filteredData) { + try { + if (uploadMode === "insert") { + const formData = { screenId: 0, tableName, data: row }; + const result = await DynamicFormApi.saveFormData(formData); + if (result.success) { + successCount++; + } else { + failCount++; + } + } + } catch (error) { + failCount++; } - } catch (error) { - console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error); } - onSuccess?.(); - } else { - toast.error("업로드에 실패했습니다."); + if (successCount > 0) { + toast.success( + `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + ); + + // 매핑 템플릿 저장 + await saveMappingTemplateInternal(); + + onSuccess?.(); + } else { + toast.error("업로드에 실패했습니다."); + } } } catch (error) { console.error("❌ 엑셀 업로드 실패:", error); @@ -427,6 +735,35 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 매핑 템플릿 저장 헬퍼 함수 + const saveMappingTemplateInternal = async () => { + try { + const mappingsToSave: Record = {}; + columnMappings.forEach((mapping) => { + mappingsToSave[mapping.excelColumn] = mapping.systemColumn; + }); + + console.log("💾 매핑 템플릿 저장 중...", { + tableName, + excelColumns, + mappingsToSave, + }); + const saveResult = await saveMappingTemplate( + tableName, + excelColumns, + mappingsToSave + ); + + if (saveResult.success) { + console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data); + } else { + console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error); + } + } catch (error) { + console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error); + } + }; + // 모달 닫기 시 초기화 useEffect(() => { if (!open) { @@ -441,6 +778,8 @@ export const ExcelUploadModal: React.FC = ({ setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); + // 🆕 마스터-디테일 모드 초기화 + setMasterFieldValues({}); } }, [open]); @@ -461,9 +800,21 @@ export const ExcelUploadModal: React.FC = ({ 엑셀 데이터 업로드 + {isMasterDetail && ( + + 마스터-디테일 + + )} - 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. + {isMasterDetail && masterDetailRelation ? ( + <> + 마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다. + 마스터 데이터는 중복 입력 시 병합됩니다. + + ) : ( + "엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요." + )} @@ -518,6 +869,87 @@ export const ExcelUploadModal: React.FC = ({ {/* 1단계: 파일 선택 & 미리보기 (통합) */} {currentStep === 1 && (
+ {/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */} + {hasMasterSelectFields && ( +
+ {masterDetailExcelConfig?.masterSelectFields?.map((field) => ( +
+ + {field.inputType === "entity" ? ( + + ) : field.inputType === "date" ? ( + + setMasterFieldValues((prev) => ({ + ...prev, + [field.columnName]: e.target.value, + })) + } + className="h-9 w-full rounded-md border px-3 text-xs" + /> + ) : ( + + setMasterFieldValues((prev) => ({ + ...prev, + [field.columnName]: e.target.value, + })) + } + placeholder={field.columnLabel} + className="h-9 w-full rounded-md border px-3 text-xs" + /> + )} +
+ ))} +
+ )} + {/* 파일 선택 영역 */}
+ {/* 🆕 소스 필드 - Combobox */}
- + + {sourceFields.length > 0 ? ( + { + const newState = [...sourceFieldsOpenState]; + newState[index] = open; + setSourceFieldsOpenState(newState); + }} + > + + + + + + + + 필드를 찾을 수 없습니다. + + { + handleConditionChange(index, "sourceField", undefined); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs text-gray-400 sm:text-sm" + > + + 없음 (정적 값 사용) + + {sourceFields.map((field) => ( + { + handleConditionChange(index, "sourceField", currentValue); + const newState = [...sourceFieldsOpenState]; + newState[index] = false; + setSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ ) : ( +
+ 연결된 소스 노드가 없습니다 +
+ )} +

소스 데이터에서 값을 가져올 필드

+
+ + {/* 정적 값 */} +
+ handleConditionChange(index, "value", e.target.value)} - placeholder="비교 값" + value={condition.staticValue || condition.value || ""} + onChange={(e) => { + handleConditionChange(index, "staticValue", e.target.value || undefined); + handleConditionChange(index, "value", e.target.value); + }} + placeholder="비교할 고정 값" className="mt-1 h-8 text-xs" /> +

소스 필드가 비어있을 때 사용됩니다

diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 437487e9..c68ff8d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from "react"; -import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react"; +import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -18,6 +18,8 @@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; +import { getNumberingRules } from "@/lib/api/numberingRule"; +import type { NumberingRuleConfig } from "@/types/numbering-rule"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -89,6 +91,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); + // 🔥 채번 규칙 관련 상태 + const [numberingRules, setNumberingRules] = useState([]); + const [numberingRulesLoading, setNumberingRulesLoading] = useState(false); + const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState([]); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); @@ -128,8 +135,33 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP useEffect(() => { setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false)); }, [fieldMappings.length]); + // 🔥 채번 규칙 로딩 (자동 생성 사용 시) + useEffect(() => { + const loadNumberingRules = async () => { + setNumberingRulesLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setNumberingRules(response.data); + console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`); + } else { + console.error("❌ 채번 규칙 로딩 실패:", response.error); + setNumberingRules([]); + } + } catch (error) { + console.error("❌ 채번 규칙 로딩 오류:", error); + setNumberingRules([]); + } finally { + setNumberingRulesLoading(false); + } + }; + + loadNumberingRules(); + }, []); + // 🔥 외부 테이블 변경 시 컬럼 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { @@ -540,6 +572,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP sourceField: null, targetField: "", staticValue: undefined, + valueType: "source" as const, // 🔥 기본값: 소스 필드 }, ]; setFieldMappings(newMappings); @@ -548,6 +581,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열 초기화 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleRemoveMapping = (index: number) => { @@ -558,6 +592,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // Combobox 열림 상태 배열도 업데이트 setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false)); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -586,6 +621,24 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP targetField: value, targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, }; + } else if (field === "valueType") { + // 🔥 값 생성 유형 변경 시 관련 필드 초기화 + newMappings[index] = { + ...newMappings[index], + valueType: value, + // 유형 변경 시 다른 유형의 값 초기화 + ...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }), + ...(value !== "static" && { staticValue: undefined }), + ...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }), + }; + } else if (field === "numberingRuleId") { + // 🔥 채번 규칙 선택 시 이름도 함께 저장 + const selectedRule = numberingRules.find((r) => r.ruleId === value); + newMappings[index] = { + ...newMappings[index], + numberingRuleId: value, + numberingRuleName: selectedRule?.ruleName, + }; } else { newMappings[index] = { ...newMappings[index], @@ -1165,54 +1218,203 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- {/* 소스 필드 입력/선택 */} + {/* 🔥 값 생성 유형 선택 */}
- - {hasRestAPISource ? ( - // REST API 소스인 경우: 직접 입력 + +
+ + + +
+
+ + {/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */} + {(mapping.valueType === "source" || !mapping.valueType) && ( +
+ + {hasRestAPISource ? ( + // REST API 소스인 경우: 직접 입력 + handleMappingChange(index, "sourceField", e.target.value || null)} + placeholder="필드명 입력 (예: userId, userName)" + className="mt-1 h-8 text-xs" + /> + ) : ( + // 일반 소스인 경우: Combobox 선택 + { + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = open; + setMappingSourceFieldsOpenState(newState); + }} + > + + + + + + + + + 필드를 찾을 수 없습니다. + + + {sourceFields.map((field) => ( + { + handleMappingChange(index, "sourceField", currentValue || null); + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = false; + setMappingSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+
+ )} + {hasRestAPISource && ( +

API 응답 JSON의 필드명을 입력하세요

+ )} +
+ )} + + {/* 🔥 고정값 입력 (valueType === "static" 일 때) */} + {mapping.valueType === "static" && ( +
+ handleMappingChange(index, "sourceField", e.target.value || null)} - placeholder="필드명 입력 (예: userId, userName)" + value={mapping.staticValue || ""} + onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)} + placeholder="고정값 입력" className="mt-1 h-8 text-xs" /> - ) : ( - // 일반 소스인 경우: Combobox 선택 +
+ )} + + {/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */} + {mapping.valueType === "autoGenerate" && ( +
+ { - const newState = [...mappingSourceFieldsOpenState]; + const newState = [...mappingNumberingRulesOpenState]; newState[index] = open; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} > @@ -1222,37 +1424,36 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP align="start" > - + - 필드를 찾을 수 없습니다. + 채번 규칙을 찾을 수 없습니다. - {sourceFields.map((field) => ( + {numberingRules.map((rule) => ( { - handleMappingChange(index, "sourceField", currentValue || null); - const newState = [...mappingSourceFieldsOpenState]; + handleMappingChange(index, "numberingRuleId", currentValue); + const newState = [...mappingNumberingRulesOpenState]; newState[index] = false; - setMappingSourceFieldsOpenState(newState); + setMappingNumberingRulesOpenState(newState); }} className="text-xs sm:text-sm" >
- {field.label || field.name} - {field.label && field.label !== field.name && ( - - {field.name} - - )} + {rule.ruleName} + + {rule.ruleId} + {rule.tableName && ` - ${rule.tableName}`} +
))} @@ -1261,11 +1462,13 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- )} - {hasRestAPISource && ( -

API 응답 JSON의 필드명을 입력하세요

- )} -
+ {numberingRules.length === 0 && !numberingRulesLoading && ( +

+ 등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요. +

+ )} +
+ )}
@@ -1400,18 +1603,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- - {/* 정적 값 */} -
- - handleMappingChange(index, "staticValue", e.target.value || undefined)} - placeholder="소스 필드 대신 고정 값 사용" - className="mt-1 h-8 text-xs" - /> -

소스 필드가 비어있을 때만 사용됩니다

-
))} @@ -1428,9 +1619,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP {/* 안내 */}
- ✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다. -
- 💡 소스 필드가 없으면 정적 값이 사용됩니다. +

테이블과 필드는 실제 데이터베이스에서 조회됩니다.

+

값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)

diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index c1b644cc..b3c94ade 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -671,9 +671,11 @@ export const EditModal: React.FC = ({ className }) => { console.log("🗑️ 품목 삭제:", deletedItem); try { + // screenId 전달하여 제어관리 실행 가능하도록 함 const response = await dynamicFormApi.deleteFormDataFromTable( deletedItem.id, screenData.screenInfo.tableName, + modalState.screenId || screenData.screenInfo?.id, ); if (response.success) { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index f786d1d1..9a0ffa8d 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1676,7 +1676,8 @@ export const InteractiveScreenViewer: React.FC = ( try { // console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); - const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); + // screenId 전달하여 제어관리 실행 가능하도록 함 + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id); if (result.success) { alert("삭제되었습니다."); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index a97d78b3..8d8c4df9 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; @@ -175,9 +176,7 @@ export const ButtonConfigPanel: React.FC = ({ }; const updateBlock = (id: string, updates: Partial) => { - const updatedBlocks = titleBlocks.map((block) => - block.id === id ? { ...block, ...updates } : block - ); + const updatedBlocks = titleBlocks.map((block) => (block.id === id ? { ...block, ...updates } : block)); setTitleBlocks(updatedBlocks); onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks); }; @@ -225,45 +224,45 @@ export const ButtonConfigPanel: React.FC = ({ const fetchAllTables = async () => { try { const response = await apiClient.get("/table-management/tables"); - + if (response.data.success && response.data.data) { const tables = response.data.data.map((table: any) => ({ name: table.tableName, label: table.displayName || table.tableName, })); setAvailableTables(tables); - console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length); + console.log("✅ 전체 테이블 목록 로드 성공:", tables.length); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } }; - + fetchAllTables(); }, []); // 🆕 특정 테이블의 컬럼 로드 const loadTableColumns = async (tableName: string) => { if (!tableName || tableColumnsMap[tableName]) return; - + try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data); - + if (response.data.success) { // data가 배열인지 확인 let columnData = response.data.data; - + // data.columns 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.columns) { columnData = columnData.columns; } - + // data.data 형태일 수도 있음 if (!Array.isArray(columnData) && columnData?.data) { columnData = columnData.data; } - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => { const name = col.name || col.columnName; @@ -286,7 +285,7 @@ export const ButtonConfigPanel: React.FC = ({ useEffect(() => { const sourceTable = config.action?.dataTransfer?.sourceTable; const targetTable = config.action?.dataTransfer?.targetTable; - + const loadColumns = async () => { if (sourceTable) { try { @@ -295,7 +294,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -308,7 +307,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("소스 테이블 컬럼 로드 실패:", error); } } - + if (targetTable) { try { const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); @@ -316,7 +315,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -330,7 +329,7 @@ export const ButtonConfigPanel: React.FC = ({ } } }; - + loadColumns(); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); @@ -371,45 +370,43 @@ export const ButtonConfigPanel: React.FC = ({ const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 let sourceTableName: string | null = null; - + console.log("[openModalWithData] 컬럼 로드 시작:", { allComponentsCount: allComponents.length, currentTableName, targetScreenId: config.action?.targetScreenId, }); - + // 모든 컴포넌트 타입 로그 allComponents.forEach((comp, idx) => { const compType = comp.componentType || (comp as any).componentConfig?.type; - console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`); + console.log( + ` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || "N/A"}`, + ); }); - + for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; - + // 분할 패널 타입들 (다양한 경로에서 테이블명 추출) if (compType === "split-panel-layout" || compType === "screen-split-panel") { - sourceTableName = compConfig?.leftPanel?.tableName || - compConfig?.leftTableName || - compConfig?.tableName; + sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.leftTableName || compConfig?.tableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); break; } } - + // split-panel-layout2 타입 (새로운 분할 패널) if (compType === "split-panel-layout2") { - sourceTableName = compConfig?.leftPanel?.tableName || - compConfig?.tableName || - compConfig?.leftTableName; + sourceTableName = compConfig?.leftPanel?.tableName || compConfig?.tableName || compConfig?.leftTableName; if (sourceTableName) { console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); break; } } - + // 테이블 리스트 타입 if (compType === "table-list") { sourceTableName = compConfig?.tableName; @@ -418,7 +415,7 @@ export const ButtonConfigPanel: React.FC = ({ break; } } - + // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) if (!sourceTableName && compConfig?.tableName) { sourceTableName = compConfig.tableName; @@ -426,13 +423,13 @@ export const ButtonConfigPanel: React.FC = ({ break; } } - + // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) if (!sourceTableName && currentTableName) { sourceTableName = currentTableName; console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); } - + if (!sourceTableName) { console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); } @@ -445,11 +442,18 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, - label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, + label: + col.displayName || + col.label || + col.columnLabel || + col.display_name || + col.name || + col.columnName || + col.column_name, })); setModalSourceColumns(columns); console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); @@ -467,22 +471,29 @@ export const ButtonConfigPanel: React.FC = ({ // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); - + if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); - + if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { let columnData = columnResponse.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName || col.column_name, - label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, + label: + col.displayName || + col.label || + col.columnLabel || + col.display_name || + col.name || + col.columnName || + col.column_name, })); setModalTargetColumns(columns); console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); @@ -513,12 +524,12 @@ export const ButtonConfigPanel: React.FC = ({ page: 1, size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기 }; - + // 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달 if (currentScreenCompanyCode) { params.companyCode = currentScreenCompanyCode; } - + const response = await apiClient.get("/screen-management/screens", { params, }); @@ -698,8 +709,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 모달 열기 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "modal" && ( -
-

모달 설정

+
+

모달 설정

@@ -727,7 +738,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.modalDescription", newValue); }} /> -

모달 제목 아래에 표시됩니다

+

모달 제목 아래에 표시됩니다

@@ -784,15 +795,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -807,7 +818,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -823,10 +836,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 데이터 전달 + 모달 열기 액션 설정 */} {component.componentConfig?.action?.type === "openModalWithData" && (
-

데이터 전달 + 모달 설정

-

- TableList에서 선택된 데이터를 다음 모달로 전달합니다 -

+

데이터 전달 + 모달 설정

+

TableList에서 선택된 데이터를 다음 모달로 전달합니다

@@ -856,23 +869,11 @@ export const ButtonConfigPanel: React.FC = ({
- - @@ -882,7 +883,7 @@ export const ButtonConfigPanel: React.FC = ({ {/* 블록 목록 */}
{titleBlocks.length === 0 ? ( -
+
텍스트나 필드를 추가하여 제목을 구성하세요
) : ( @@ -914,7 +915,7 @@ export const ButtonConfigPanel: React.FC = ({
{/* 블록 타입 표시 */} -
+
{block.type === "text" ? ( ) : ( @@ -949,15 +950,15 @@ export const ButtonConfigPanel: React.FC = ({ className="h-7 w-full justify-between text-xs" > {block.tableName - ? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName) + ? availableTables.find((t) => t.name === block.tableName)?.label || block.tableName : "테이블 선택"} - { @@ -991,11 +992,13 @@ export const ButtonConfigPanel: React.FC = ({ {table.label} - ({table.name}) + + ({table.name}) + ))} @@ -1020,15 +1023,16 @@ export const ButtonConfigPanel: React.FC = ({ className="h-7 w-full justify-between text-xs" > {block.value - ? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value) + ? tableColumnsMap[block.tableName]?.find((c) => c.name === block.value) + ?.label || block.value : "컬럼 선택"} - { @@ -1064,11 +1068,13 @@ export const ButtonConfigPanel: React.FC = ({ {col.label} - ({col.name}) + + ({col.name}) + ))} @@ -1107,17 +1113,19 @@ export const ButtonConfigPanel: React.FC = ({ {/* 미리보기 */} {titleBlocks.length > 0 && ( -
+
미리보기: {generateTitlePreview()}
)} -

- • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")
- • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)
- • 순서 변경: ↑↓ 버튼으로 자유롭게 배치
- • 데이터가 없으면 "표시 라벨"이 대신 표시됩니다 +

+ • 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ") +
+ • 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격) +
+ • 순서 변경: ↑↓ 버튼으로 자유롭게 배치 +
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다

@@ -1175,15 +1183,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1198,7 +1206,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1207,7 +1217,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요

@@ -1231,7 +1241,7 @@ export const ButtonConfigPanel: React.FC = ({ 매핑 추가
-

+

소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑) @@ -1239,41 +1249,36 @@ export const ButtonConfigPanel: React.FC = ({ {/* 컬럼 로드 상태 표시 */} {modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? ( -

+
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
) : ( -
+
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
)} {(config.action?.fieldMappings || []).length === 0 ? (
-

- 매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다. -

+

매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.

) : (
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
+
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
- + setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} > -
@@ -1416,8 +1418,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 수정 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "edit" && ( -
-

수정 설정

+
+

수정 설정

@@ -1453,15 +1455,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1476,7 +1478,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1485,7 +1489,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다

@@ -1524,7 +1528,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> -

비워두면 기본 제목이 표시됩니다

+

비워두면 기본 제목이 표시됩니다

@@ -1540,7 +1544,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> -

비워두면 설명이 표시되지 않습니다

+

비워두면 설명이 표시되지 않습니다

@@ -1580,8 +1584,9 @@ export const ButtonConfigPanel: React.FC = ({ {localInputs.groupByColumn} {currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label && - currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== localInputs.groupByColumn && ( - + currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label !== + localInputs.groupByColumn && ( + ({currentTableColumns.find((col) => col.name === localInputs.groupByColumn)?.label}) )} @@ -1605,14 +1610,14 @@ export const ButtonConfigPanel: React.FC = ({
{currentTableColumns.length === 0 ? ( -
+
{currentTableName ? "컬럼을 불러오는 중..." : "테이블이 설정되지 않았습니다"}
) : ( <> {/* 선택 해제 옵션 */}
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: "" })); onUpdateProperty("componentConfig.action.groupByColumns", undefined); @@ -1620,7 +1625,9 @@ export const ButtonConfigPanel: React.FC = ({ setGroupByColumnSearch(""); }} > - + 선택 안 함
{/* 컬럼 목록 */} @@ -1628,15 +1635,12 @@ export const ButtonConfigPanel: React.FC = ({ .filter((col) => { if (!groupByColumnSearch) return true; const search = groupByColumnSearch.toLowerCase(); - return ( - col.name.toLowerCase().includes(search) || - col.label.toLowerCase().includes(search) - ); + return col.name.toLowerCase().includes(search) || col.label.toLowerCase().includes(search); }) .map((col) => (
{ setLocalInputs((prev) => ({ ...prev, groupByColumn: col.name })); onUpdateProperty("componentConfig.action.groupByColumns", [col.name]); @@ -1645,12 +1649,15 @@ export const ButtonConfigPanel: React.FC = ({ }} >
{col.name} {col.label !== col.name && ( - {col.label} + {col.label} )}
@@ -1661,9 +1668,7 @@ export const ButtonConfigPanel: React.FC = ({
-

- 여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다 -

+

여러 행을 하나의 그룹으로 묶어서 수정할 때 사용합니다

)} @@ -1671,7 +1676,7 @@ export const ButtonConfigPanel: React.FC = ({ {/* 복사 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "copy" && (
-

복사 설정 (품목코드 자동 초기화)

+

복사 설정 (품목코드 자동 초기화)

@@ -1707,15 +1712,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(modalSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); @@ -1730,7 +1735,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1739,7 +1746,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다

@@ -1777,7 +1784,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> -

비워두면 기본 제목이 표시됩니다

+

비워두면 기본 제목이 표시됩니다

@@ -1793,7 +1800,7 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> -

비워두면 설명이 표시되지 않습니다

+

비워두면 설명이 표시되지 않습니다

@@ -1852,9 +1859,7 @@ export const ButtonConfigPanel: React.FC = ({ - - 컬럼을 찾을 수 없습니다. - + 컬럼을 찾을 수 없습니다. {tableColumns.map((column) => ( = ({ {/* 페이지 이동 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "navigate" && ( -
-

페이지 이동 설정

+
+

페이지 이동 설정

@@ -1923,15 +1928,15 @@ export const ButtonConfigPanel: React.FC = ({ {(() => { const filteredScreens = filterScreens(navSearchTerm); if (screensLoading) { - return
화면 목록을 불러오는 중...
; + return
화면 목록을 불러오는 중...
; } if (filteredScreens.length === 0) { - return
검색 결과가 없습니다.
; + return
검색 결과가 없습니다.
; } return filteredScreens.map((screen, index) => (
{ onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setNavScreenOpen(false); @@ -1946,7 +1951,9 @@ export const ButtonConfigPanel: React.FC = ({ />
{screen.name} - {screen.description && {screen.description}} + {screen.description && ( + {screen.description} + )}
)); @@ -1955,7 +1962,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다

@@ -1973,15 +1980,15 @@ export const ButtonConfigPanel: React.FC = ({ }} className="h-6 w-full px-2 py-0 text-xs" /> -

URL을 입력하면 화면 선택보다 우선 적용됩니다

+

URL을 입력하면 화면 선택보다 우선 적용됩니다

)} {/* 엑셀 다운로드 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "excel_download" && ( -
-

엑셀 다운로드 설정

+
+

엑셀 다운로드 설정

@@ -1992,7 +1999,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)} className="h-8 text-xs" /> -

확장자(.xlsx)는 자동으로 추가됩니다

+

확장자(.xlsx)는 자동으로 추가됩니다

@@ -2019,48 +2026,13 @@ export const ButtonConfigPanel: React.FC = ({ {/* 엑셀 업로드 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "excel_upload" && ( -
-

📤 엑셀 업로드 설정

- -
- - -
- - {(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && ( -
- - onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} - className="h-8 text-xs" - /> -

UPDATE/UPSERT 시 기준이 되는 컬럼명

-
- )} -
+ )} {/* 바코드 스캔 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "barcode_scan" && ( -
-

📷 바코드 스캔 설정

+
+

📷 바코드 스캔 설정

@@ -2106,8 +2078,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 코드 병합 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "code_merge" && ( -
-

🔀 코드 병합 설정

+
+

🔀 코드 병합 설정

@@ -2128,7 +2100,7 @@ export const ButtonConfigPanel: React.FC = ({
-

영향받을 테이블과 행 수를 미리 확인합니다

+

영향받을 테이블과 행 수를 미리 확인합니다

= ({ {/* 운행알림 및 종료 설정 */} {(component.componentConfig?.action?.type || "save") === "operation_control" && ( -
-

🚗 운행알림 및 종료 설정

+
+

🚗 운행알림 및 종료 설정

@@ -2202,7 +2172,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)} className="h-8 text-xs" /> -

변경할 DB 컬럼

+

변경할 DB 컬럼

{/* 🆕 키 필드 설정 (레코드 식별용) */}
-
레코드 식별 설정
+
레코드 식별 설정
@@ -2278,7 +2248,7 @@ export const ButtonConfigPanel: React.FC = ({
-

버튼 클릭 시 즉시 DB에 저장

+

버튼 클릭 시 즉시 DB에 저장

= ({ onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)} className="h-8 text-xs" /> -

입력하면 변경 전 확인 창이 표시됩니다

+

입력하면 변경 전 확인 창이 표시됩니다

@@ -2327,7 +2297,7 @@ export const ButtonConfigPanel: React.FC = ({
-

상태 변경과 함께 현재 GPS 좌표를 수집합니다

+

상태 변경과 함께 현재 GPS 좌표를 수집합니다

= ({ onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)} />
- + {config.action?.updateWithGeolocation && (
- + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value) + } className="h-8 text-xs" />
- + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value) + } className="h-8 text-xs" />
@@ -2364,7 +2342,9 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value) + } className="h-8 text-xs" />
@@ -2373,7 +2353,9 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value) + } className="h-8 text-xs" />
@@ -2389,7 +2371,7 @@ export const ButtonConfigPanel: React.FC = ({
-

10초마다 위치를 경로 테이블에 저장합니다

+

10초마다 위치를 경로 테이블에 저장합니다

= ({ onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithTracking", checked)} />
- + {config.action?.updateWithTracking && (
- +
- + {config.action?.updateTrackingMode === "start" && (
@@ -2423,17 +2407,22 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="10" value={(config.action?.updateTrackingInterval || 10000) / 1000} - onChange={(e) => onUpdateProperty("componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.updateTrackingInterval", + parseInt(e.target.value) * 1000 || 10000, + ) + } className="h-8 text-xs" min={5} max={300} /> -

5초 ~ 300초 사이로 설정 (기본: 10초)

+

5초 ~ 300초 사이로 설정 (기본: 10초)

)} - +

- {config.action?.updateTrackingMode === "start" + {config.action?.updateTrackingMode === "start" ? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다." : "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."}

@@ -2442,13 +2431,13 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 버튼 활성화 조건 설정 */}
-
버튼 활성화 조건
- +
버튼 활성화 조건
+ {/* 출발지/도착지 필수 체크 */}
-

선택하지 않으면 버튼 비활성화

+

선택하지 않으면 버튼 비활성화

= ({ onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value) + } className="h-8 text-xs" />
@@ -2486,7 +2477,7 @@ export const ButtonConfigPanel: React.FC = ({
-

특정 상태일 때만 버튼 활성화

+

특정 상태일 때만 버튼 활성화

= ({ ))} -

- 상태를 조회할 테이블 (기본: vehicles) -

+

상태를 조회할 테이블 (기본: vehicles)

@@ -2526,7 +2515,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)} className="h-8 text-xs" /> -

+

현재 로그인 사용자 ID로 조회할 필드 (기본: user_id)

@@ -2538,9 +2527,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)} className="h-8 text-xs" /> -

- 상태 값이 저장된 컬럼명 (기본: status) -

+

상태 값이 저장된 컬럼명 (기본: status)

@@ -2565,9 +2552,7 @@ export const ButtonConfigPanel: React.FC = ({ onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)} className="h-8 text-xs" /> -

- 여러 상태값은 쉼표(,)로 구분 -

+

여러 상태값은 쉼표(,)로 구분

)} @@ -2580,8 +2565,7 @@ export const ButtonConfigPanel: React.FC = ({ - 운행 시작: status를 "active"로 + 연속 추적 시작
- 운행 종료: status를 "completed"로 + 연속 추적 종료 -
- - 공차등록: status를 "inactive"로 + 1회성 위치정보 수집 +
- 공차등록: status를 "inactive"로 + 1회성 위치정보 수집

@@ -2589,8 +2573,8 @@ export const ButtonConfigPanel: React.FC = ({ {/* 데이터 전달 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "transferData" && ( -
-

📦 데이터 전달 설정

+
+

📦 데이터 전달 설정

{/* 소스 컴포넌트 선택 (Combobox) */}
@@ -2599,7 +2583,9 @@ export const ButtonConfigPanel: React.FC = ({ -

- 테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트 -

+

테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트

@@ -2655,12 +2641,15 @@ export const ButtonConfigPanel: React.FC = ({ 같은 화면의 컴포넌트 분할 패널 반대편 화면 - 다른 화면 (구현 예정) + + 다른 화면 (구현 예정) + {config.action?.dataTransfer?.targetType === "splitPanel" && ( -

- 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 + 전달됩니다.

)}
@@ -2673,7 +2662,9 @@ export const ButtonConfigPanel: React.FC = ({ -

- 테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트 -

+

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

)} {/* 분할 패널 반대편 타겟 설정 */} {config.action?.dataTransfer?.targetType === "splitPanel" && (
- + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)} + onChange={(e) => + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value) + } placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" className="h-8 text-xs" /> -

+

반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.

@@ -2752,32 +2743,34 @@ export const ButtonConfigPanel: React.FC = ({ 병합 (Merge) -

- 기존 데이터를 어떻게 처리할지 선택 -

+

기존 데이터를 어떻게 처리할지 선택

-

데이터 전달 후 소스의 선택을 해제합니다

+

데이터 전달 후 소스의 선택을 해제합니다

onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)} + onCheckedChange={(checked) => + onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) + } />
-

데이터 전달 전 확인 다이얼로그를 표시합니다

+

데이터 전달 전 확인 다이얼로그를 표시합니다

onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)} + onCheckedChange={(checked) => + onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) + } />
@@ -2806,7 +2799,12 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="0" value={config.action?.dataTransfer?.validation?.minSelection || ""} - onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.minSelection", + parseInt(e.target.value) || 0, + ) + } className="h-8 w-20 text-xs" />
@@ -2819,7 +2817,12 @@ export const ButtonConfigPanel: React.FC = ({ type="number" placeholder="제한없음" value={config.action?.dataTransfer?.validation?.maxSelection || ""} - onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)} + onChange={(e) => + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.maxSelection", + parseInt(e.target.value) || undefined, + ) + } className="h-8 w-20 text-xs" />
@@ -2828,7 +2831,7 @@ export const ButtonConfigPanel: React.FC = ({
-

+

조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다

@@ -2859,8 +2862,8 @@ export const ButtonConfigPanel: React.FC = ({ .filter((comp: any) => { const type = comp.componentType || comp.type || ""; // 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입 - return ["conditional-container", "select-basic", "select", "combobox"].some( - (t) => type.includes(t) + return ["conditional-container", "select-basic", "select", "combobox"].some((t) => + type.includes(t), ); }) .map((comp: any) => { @@ -2870,14 +2873,14 @@ export const ButtonConfigPanel: React.FC = ({
{compLabel} - ({compType}) + ({compType})
); })} -

+

조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)

@@ -2901,9 +2904,7 @@ export const ButtonConfigPanel: React.FC = ({ }} className="h-8 text-xs" /> -

- 타겟 테이블에 저장될 필드명 -

+

타겟 테이블에 저장될 필드명

@@ -2911,18 +2912,14 @@ export const ButtonConfigPanel: React.FC = ({ {/* 필드 매핑 규칙 */}
- + {/* 소스/타겟 테이블 선택 */}
-
- +
-
- + {/* 필드 매핑 규칙 */}
@@ -3031,26 +3024,24 @@ export const ButtonConfigPanel: React.FC = ({ 매핑 추가
-

+

소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.

- - {(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? ( + + {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
-

- 먼저 소스 테이블과 타겟 테이블을 선택하세요. -

+

먼저 소스 테이블과 타겟 테이블을 선택하세요.

) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
-

+

매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.

) : (
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
+
{/* 소스 필드 선택 (Combobox) */}
= ({ onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} > - @@ -3075,10 +3063,14 @@ export const ButtonConfigPanel: React.FC = ({ placeholder="컬럼 검색..." className="h-8 text-xs" value={mappingSourceSearch[index] || ""} - onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))} + onValueChange={(value) => + setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) + } /> - 컬럼을 찾을 수 없습니다 + + 컬럼을 찾을 수 없습니다 + {mappingSourceColumns.map((col) => ( = ({ {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -3110,9 +3102,9 @@ export const ButtonConfigPanel: React.FC = ({
- - - + + + {/* 타겟 필드 선택 (Combobox) */}
= ({ onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} > - @@ -3137,10 +3126,14 @@ export const ButtonConfigPanel: React.FC = ({ placeholder="컬럼 검색..." className="h-8 text-xs" value={mappingTargetSearch[index] || ""} - onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))} + onValueChange={(value) => + setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) + } /> - 컬럼을 찾을 수 없습니다 + + 컬럼을 찾을 수 없습니다 + {mappingTargetColumns.map((col) => ( = ({ {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -3172,12 +3165,12 @@ export const ButtonConfigPanel: React.FC = ({
- + + + + + + + 검색 결과 없음 + + { + updateMasterDetailConfig({ numberingRuleId: undefined }); + setNumberingRuleOpen(false); + }} + className="text-xs" + > + + 채번 없음 (수동 입력) + + {numberingRules + .filter((rule) => rule.table_name === masterTable || !rule.table_name) + .map((rule, idx) => { + const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); + const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)"; + return ( + { + updateMasterDetailConfig({ numberingRuleId: ruleId }); + setNumberingRuleOpen(false); + }} + className="text-xs" + > + + {ruleName} + + ); + })} + + + + + +

+ 마스터 테이블의 {relationInfo.masterKeyColumn} 값을 자동 생성합니다 +

+
+ )} + + {/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */} + {relationInfo && masterColumns.length > 0 && ( +
+ +

+ 엑셀 업로드 시 사용자가 직접 선택/입력할 마스터 테이블 필드를 선택하세요. +

+
+ {masterColumns + .filter((col) => col.columnName !== relationInfo.masterKeyColumn) // 채번으로 자동 생성되는 키는 제외 + .map((col) => { + const selectedFields = masterDetailConfig.masterSelectFields || []; + const isSelected = selectedFields.some((f: any) => f.columnName === col.columnName); + return ( +
+ { + const checked = e.target.checked; + let newFields = [...selectedFields]; + if (checked) { + newFields.push({ + columnName: col.columnName, + columnLabel: col.columnLabel, + inputType: col.inputType, + referenceTable: col.referenceTable, + referenceColumn: col.referenceColumn, + displayColumn: col.displayColumn, + required: true, + }); + } else { + newFields = newFields.filter((f: any) => f.columnName !== col.columnName); + } + updateMasterDetailConfig({ masterSelectFields: newFields }); + }} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ ); + })} +
+ {(masterDetailConfig.masterSelectFields?.length || 0) > 0 && ( +

+ 선택된 필드: {masterDetailConfig.masterSelectFields.length}개 +

+ )} + + {/* 엔티티 필드의 표시컬럼 설정 */} + {masterDetailConfig.masterSelectFields?.filter((f: any) => f.inputType === "entity").length > 0 && ( +
+ + {masterDetailConfig.masterSelectFields + .filter((f: any) => f.inputType === "entity") + .map((field: any) => { + const availableColumns = refTableColumns[field.referenceTable] || []; + return ( +
+ {field.columnLabel}: + +
+ ); + })} +

참조 테이블에서 사용자에게 표시할 컬럼을 선택하세요.

+
+ )} + + {/* 업로드 후 제어 실행 설정 */} + +
+ )} +
+ ); +}; + +/** + * 업로드 후 제어 실행 설정 컴포넌트 + * 여러 개의 제어를 순서대로 실행할 수 있도록 지원 + */ +const AfterUploadControlConfig: React.FC<{ + config: any; + onUpdateProperty: (path: string, value: any) => void; + masterDetailConfig: any; + updateMasterDetailConfig: (updates: any) => void; +}> = ({ masterDetailConfig, updateMasterDetailConfig }) => { + const [nodeFlows, setNodeFlows] = useState< + Array<{ flowId: number; flowName: string; flowDescription?: string }> + >([]); + const [flowSelectOpen, setFlowSelectOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 선택된 제어 목록 (배열로 관리) + const selectedFlows: Array<{ flowId: string; order: number }> = masterDetailConfig.afterUploadFlows || []; + + // 노드 플로우 목록 로드 + useEffect(() => { + const loadNodeFlows = async () => { + setIsLoading(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get("/dataflow/node-flows"); + if (response.data?.success && response.data?.data) { + setNodeFlows(response.data.data); + } + } catch (error) { + console.error("노드 플로우 목록 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadNodeFlows(); + }, []); + + // 제어 추가 + const addFlow = (flowId: string) => { + if (selectedFlows.some((f) => f.flowId === flowId)) return; + const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }]; + updateMasterDetailConfig({ afterUploadFlows: newFlows }); + setFlowSelectOpen(false); + }; + + // 제어 제거 + const removeFlow = (flowId: string) => { + const newFlows = selectedFlows + .filter((f) => f.flowId !== flowId) + .map((f, idx) => ({ ...f, order: idx + 1 })); + updateMasterDetailConfig({ afterUploadFlows: newFlows }); + }; + + // 순서 변경 (위로) + const moveUp = (index: number) => { + if (index === 0) return; + const newFlows = [...selectedFlows]; + [newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]]; + updateMasterDetailConfig({ + afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), + }); + }; + + // 순서 변경 (아래로) + const moveDown = (index: number) => { + if (index === selectedFlows.length - 1) return; + const newFlows = [...selectedFlows]; + [newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]]; + updateMasterDetailConfig({ + afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })), + }); + }; + + // 선택되지 않은 플로우만 필터링 + const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId))); + + return ( +
+ +

+ 엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요. +

+ + {/* 선택된 제어 목록 */} + {selectedFlows.length > 0 && ( +
+ {selectedFlows.map((selected, index) => { + const flow = nodeFlows.find((f) => String(f.flowId) === selected.flowId); + return ( +
+ {index + 1} + {flow?.flowName || `Flow ${selected.flowId}`} + + + +
+ ); + })} +
+ )} + + {/* 제어 추가 버튼 */} + + + + + + + + + 검색 결과 없음 + + {availableFlows.map((flow) => ( + addFlow(String(flow.flowId))} + className="text-xs" + > +
+ {flow.flowName} + {flow.flowDescription && ( + {flow.flowDescription} + )} +
+
+ ))} +
+
+
+
+
+ + {selectedFlows.length > 0 && ( +

+ 업로드 완료 후 위 순서대로 {selectedFlows.length}개의 제어가 실행됩니다. +

+ )} +
+ ); +}; + +/** + * 엑셀 업로드 설정 섹션 컴포넌트 + * 마스터-디테일 설정은 분할 패널 자동 감지 + */ +const ExcelUploadConfigSection: React.FC<{ + config: any; + onUpdateProperty: (path: string, value: any) => void; + allComponents: ComponentData[]; +}> = ({ config, onUpdateProperty, allComponents }) => { + return ( +
+

엑셀 업로드 설정

+ +
+ + +
+ + {(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && ( +
+ + onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)} + className="h-8 text-xs" + /> +

UPDATE/UPSERT 시 기준이 되는 컬럼명

+
+ )} + + {/* 마스터-디테일 설정 (분할 패널 자동 감지) */} + +
+ ); +}; diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index d2433c48..c9e4cf33 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -202,14 +202,19 @@ export class DynamicFormApi { * 실제 테이블에서 폼 데이터 삭제 * @param id 레코드 ID * @param tableName 테이블명 + * @param screenId 화면 ID (제어관리 실행용, 선택사항) * @returns 삭제 결과 */ - static async deleteFormDataFromTable(id: string | number, tableName: string): Promise> { + static async deleteFormDataFromTable( + id: string | number, + tableName: string, + screenId?: number + ): Promise> { try { - console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName }); + console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId }); await apiClient.delete(`/dynamic-form/${id}`, { - data: { tableName }, + data: { tableName, screenId }, }); console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공"); @@ -556,6 +561,192 @@ export class DynamicFormApi { }; } } + + // ================================ + // 마스터-디테일 엑셀 API + // ================================ + + /** + * 마스터-디테일 관계 정보 조회 + * @param screenId 화면 ID + * @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님) + */ + static async getMasterDetailRelation(screenId: number): Promise> { + try { + console.log("🔍 마스터-디테일 관계 조회:", screenId); + + const response = await apiClient.get(`/data/master-detail/relation/${screenId}`); + + return { + success: true, + data: response.data?.data || null, + message: response.data?.message || "조회 완료", + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 관계 조회 실패:", error); + return { + success: false, + data: null, + message: error.response?.data?.message || error.message, + }; + } + } + + /** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * @param screenId 화면 ID + * @param filters 필터 조건 + * @returns JOIN된 플랫 데이터 + */ + static async getMasterDetailDownloadData( + screenId: number, + filters?: Record + ): Promise> { + try { + console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters }); + + const response = await apiClient.post(`/data/master-detail/download`, { + screenId, + filters, + }); + + return { + success: true, + data: response.data?.data, + message: "데이터 조회 완료", + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 다운로드 실패:", error); + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } + } + + /** + * 마스터-디테일 엑셀 업로드 + * @param screenId 화면 ID + * @param data 엑셀에서 읽은 플랫 데이터 + * @returns 업로드 결과 + */ + static async uploadMasterDetailData( + screenId: number, + data: Record[] + ): Promise> { + try { + console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length }); + + const response = await apiClient.post(`/data/master-detail/upload`, { + screenId, + data, + }); + + return { + success: response.data?.success, + data: response.data?.data, + message: response.data?.message, + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 업로드 실패:", error); + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } + } + + /** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param afterUploadFlowId 업로드 후 실행할 제어 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 제어 목록 (optional) + * @returns 업로드 결과 + */ + static async uploadMasterDetailSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId?: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise> { + try { + console.log("📤 마스터-디테일 간단 모드 업로드:", { + screenId, + detailRowCount: detailData.length, + masterFieldValues, + numberingRuleId, + afterUploadFlows: afterUploadFlows?.length || 0, + }); + + const response = await apiClient.post(`/data/master-detail/upload-simple`, { + screenId, + detailData, + masterFieldValues, + numberingRuleId, + afterUploadFlowId, + afterUploadFlows, + }); + + return { + success: response.data?.success, + data: response.data?.data, + message: response.data?.message, + }; + } catch (error: any) { + console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error); + return { + success: false, + message: error.response?.data?.message || error.message, + }; + } + } +} + +// 마스터-디테일 관계 타입 +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; + detailFkColumn: string; + masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; + detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; +} + +// 마스터-디테일 다운로드 데이터 타입 +export interface MasterDetailDownloadData { + headers: string[]; + columns: string[]; + data: Record[]; + masterColumns: string[]; + detailColumns: string[]; + joinKey: string; +} + +// 마스터-디테일 업로드 결과 타입 +export interface MasterDetailUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; +} + +// 🆕 마스터-디테일 간단 모드 업로드 결과 타입 +export interface MasterDetailSimpleUploadResult { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; // 생성된 마스터 키 + errors?: string[]; } // 편의를 위한 기본 export diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index a3206df9..1e84588d 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -77,6 +77,12 @@ export const entityJoinApi = { filterColumn?: string; filterValue?: any; }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; // 🆕 중복 제거 설정 } = {}, ): Promise => { // 🔒 멀티테넌시: company_code 자동 필터링 활성화 @@ -99,6 +105,7 @@ export const entityJoinApi = { autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 + deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정 }, }); return response.data.data; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 9ca202ed..e28e1755 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인 // 🆕 연관 데이터 버튼 컴포넌트 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 -// 🆕 피벗 그리드 컴포넌트 -import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블 - /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/pivot-grid/PLAN.md b/frontend/lib/registry/components/pivot-grid/PLAN.md new file mode 100644 index 00000000..7b96ab38 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PLAN.md @@ -0,0 +1,159 @@ +# PivotGrid 컴포넌트 전체 구현 계획 + +## 개요 +DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현 + +## 현재 상태: ✅ 모든 기능 구현 완료! + +--- + +## 구현된 기능 목록 + +### 1. 기본 피벗 테이블 ✅ +- [x] 피벗 테이블 렌더링 +- [x] 행/열 확장/축소 +- [x] 합계/소계 표시 +- [x] 전체 확장/축소 버튼 + +### 2. 필드 패널 (드래그앤드롭) ✅ +- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터) +- [x] 각 영역에 배치된 필드 칩/태그 표시 +- [x] 필드 제거 버튼 (X) +- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용) +- [x] 영역 간 필드 이동 +- [x] 같은 영역 내 순서 변경 +- [x] 드래그 시 시각적 피드백 + +### 3. 필드 선택기 (모달) ✅ +- [x] 모달 열기/닫기 +- [x] 사용 가능한 필드 목록 +- [x] 필드 검색 기능 +- [x] 필드별 영역 선택 드롭다운 +- [x] 데이터 타입 아이콘 표시 +- [x] 집계 함수 선택 (데이터 영역) +- [x] 표시 모드 선택 (데이터 영역) + +### 4. 데이터 요약 (누계, % 모드) ✅ +- [x] 절대값 표시 (기본) +- [x] 행 총계 대비 % +- [x] 열 총계 대비 % +- [x] 전체 총계 대비 % +- [x] 행/열 방향 누계 +- [x] 이전 대비 차이 +- [x] 이전 대비 % 차이 + +### 5. 필터링 ✅ +- [x] 필터 팝업 컴포넌트 (FilterPopup) +- [x] 값 검색 기능 +- [x] 체크박스 기반 값 선택 +- [x] 포함/제외 모드 +- [x] 전체 선택/해제 +- [x] 선택된 항목 수 표시 + +### 6. Drill Down ✅ +- [x] 셀 더블클릭 시 상세 데이터 모달 +- [x] 원본 데이터 테이블 표시 +- [x] 검색 기능 +- [x] 정렬 기능 +- [x] 페이지네이션 +- [x] CSV/Excel 내보내기 + +### 7. Virtual Scrolling ✅ +- [x] useVirtualScroll 훅 (행) +- [x] useVirtualColumnScroll 훅 (열) +- [x] useVirtual2DScroll 훅 (행+열) +- [x] overscan 버퍼 지원 + +### 8. Excel 내보내기 ✅ +- [x] xlsx 라이브러리 사용 +- [x] 피벗 데이터 Excel 내보내기 +- [x] Drill Down 데이터 Excel 내보내기 +- [x] CSV 내보내기 (기본) +- [x] 스타일링 (헤더, 데이터, 총계) +- [x] 숫자 포맷 + +### 9. 차트 통합 ✅ +- [x] recharts 라이브러리 사용 +- [x] 막대 차트 +- [x] 누적 막대 차트 +- [x] 선 차트 +- [x] 영역 차트 +- [x] 파이 차트 +- [x] 범례 표시 +- [x] 커스텀 툴팁 +- [x] 차트 토글 버튼 + +### 10. 조건부 서식 (Conditional Formatting) ✅ +- [x] Color Scale (색상 그라데이션) +- [x] Data Bar (데이터 막대) +- [x] Icon Set (아이콘) +- [x] Cell Value (조건 기반 스타일) +- [x] ConfigPanel에서 설정 UI + +### 11. 상태 저장/복원 ✅ +- [x] usePivotState 훅 +- [x] localStorage/sessionStorage 지원 +- [x] 자동 저장 (디바운스) + +### 12. ConfigPanel 고도화 ✅ +- [x] 데이터 소스 설정 (테이블 선택) +- [x] 필드별 영역 설정 (행, 열, 데이터, 필터) +- [x] 총계 옵션 설정 +- [x] 스타일 설정 (테마, 교차 색상 등) +- [x] 내보내기 설정 (Excel/CSV) +- [x] 차트 설정 UI +- [x] 필드 선택기 설정 UI +- [x] 조건부 서식 설정 UI +- [x] 크기 설정 + +--- + +## 파일 구조 + +``` +pivot-grid/ +├── components/ +│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭) +│ ├── FieldChooser.tsx # 필드 선택기 모달 +│ ├── DrillDownModal.tsx # Drill Down 모달 +│ ├── FilterPopup.tsx # 필터 팝업 +│ ├── PivotChart.tsx # 차트 컴포넌트 +│ └── index.ts # 내보내기 +├── hooks/ +│ ├── useVirtualScroll.ts # 가상 스크롤 훅 +│ ├── usePivotState.ts # 상태 저장 훅 +│ └── index.ts # 내보내기 +├── utils/ +│ ├── aggregation.ts # 집계 함수 +│ ├── pivotEngine.ts # 피벗 엔진 +│ ├── exportExcel.ts # Excel 내보내기 +│ ├── conditionalFormat.ts # 조건부 서식 +│ └── index.ts # 내보내기 +├── types.ts # 타입 정의 +├── PivotGridComponent.tsx # 메인 컴포넌트 +├── PivotGridConfigPanel.tsx # 설정 패널 +├── PivotGridRenderer.tsx # 렌더러 +├── index.ts # 모듈 내보내기 +└── PLAN.md # 이 파일 +``` + +--- + +## 후순위 기능 (선택적) + +다음 기능들은 필요 시 추가 구현 가능: + +### 데이터 바인딩 확장 +- [ ] OLAP Data Source 연동 (복잡) +- [ ] GraphQL 연동 +- [ ] 실시간 데이터 업데이트 (WebSocket) + +### 고급 기능 +- [ ] 피벗 테이블 병합 (여러 데이터 소스) +- [ ] 계산 필드 (커스텀 수식) +- [ ] 데이터 정렬 옵션 강화 +- [ ] 그룹핑 옵션 (날짜 그룹핑 등) + +--- + +## 완료일: 2026-01-08 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index b81057a3..e7904a95 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -5,7 +5,7 @@ * 다차원 데이터 분석을 위한 피벗 테이블 */ -import React, { useState, useMemo, useCallback } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { cn } from "@/lib/utils"; import { PivotGridProps, @@ -15,8 +15,15 @@ import { PivotFlatRow, PivotCellValue, PivotGridState, + PivotAreaType, } from "./types"; import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { exportPivotToExcel } from "./utils/exportExcel"; +import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat"; +import { FieldPanel } from "./components/FieldPanel"; +import { FieldChooser } from "./components/FieldChooser"; +import { DrillDownModal } from "./components/DrillDownModal"; +import { PivotChart } from "./components/PivotChart"; import { ChevronRight, ChevronDown, @@ -25,6 +32,9 @@ import { RefreshCw, Maximize2, Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -79,13 +89,22 @@ interface DataCellProps { values: PivotCellValue[]; isTotal?: boolean; onClick?: () => void; + onDoubleClick?: () => void; + conditionalStyle?: CellFormatStyle; } const DataCell: React.FC = ({ values, isTotal = false, onClick, + onDoubleClick, + conditionalStyle, }) => { + // 조건부 서식 스타일 계산 + const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {}; + const hasDataBar = conditionalStyle?.dataBarWidth !== undefined; + const icon = conditionalStyle?.icon; + if (!values || values.length === 0) { return ( = ({ "px-2 py-1.5 text-right text-sm", isTotal && "bg-primary/5 font-medium" )} + style={cellStyle} + onClick={onClick} + onDoubleClick={onDoubleClick} > - @@ -105,14 +127,29 @@ const DataCell: React.FC = ({ return ( - {values[0].formattedValue} + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + ); } @@ -124,14 +161,28 @@ const DataCell: React.FC = ({ - {val.formattedValue} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + ))} @@ -142,7 +193,7 @@ const DataCell: React.FC = ({ export const PivotGridComponent: React.FC = ({ title, - fields = [], + fields: initialFields = [], totals = { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -157,24 +208,49 @@ export const PivotGridComponent: React.FC = ({ alternateRowColors: true, highlightTotals: true, }, + fieldChooser, + chart: chartConfig, allowExpandAll = true, height = "auto", maxHeight, exportConfig, data: externalData, onCellClick, + onCellDoubleClick, + onFieldDrop, onExpandChange, }) => { + // 디버깅 로그 + console.log("🔶 PivotGridComponent props:", { + title, + hasExternalData: !!externalData, + externalDataLength: externalData?.length, + initialFieldsLength: initialFields?.length, + }); // ==================== 상태 ==================== + const [fields, setFields] = useState(initialFields); const [pivotState, setPivotState] = useState({ expandedRowPaths: [], expandedColumnPaths: [], sortConfig: null, filterConfig: {}, }); - const [isFullscreen, setIsFullscreen] = useState(false); + const [showFieldPanel, setShowFieldPanel] = useState(true); + const [showFieldChooser, setShowFieldChooser] = useState(false); + const [drillDownData, setDrillDownData] = useState<{ + open: boolean; + cellData: PivotCellData | null; + }>({ open: false, cellData: null }); + const [showChart, setShowChart] = useState(chartConfig?.enabled || false); + + // 외부 fields 변경 시 동기화 + useEffect(() => { + if (initialFields.length > 0) { + setFields(initialFields); + } + }, [initialFields]); // 데이터 const data = externalData || []; @@ -205,6 +281,43 @@ export const PivotGridComponent: React.FC = ({ [fields] ); + const filterFields = useMemo( + () => + fields + .filter((f) => f.area === "filter" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + // 사용 가능한 필드 목록 (FieldChooser용) + const availableFields = useMemo(() => { + if (data.length === 0) return []; + + const sampleRow = data[0]; + return Object.keys(sampleRow).map((key) => { + const existingField = fields.find((f) => f.field === key); + const value = sampleRow[key]; + + // 데이터 타입 추론 + let dataType: "string" | "number" | "date" | "boolean" = "string"; + if (typeof value === "number") dataType = "number"; + else if (typeof value === "boolean") dataType = "boolean"; + else if (value instanceof Date) dataType = "date"; + else if (typeof value === "string") { + // 날짜 문자열 감지 + if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date"; + } + + return { + field: key, + caption: existingField?.caption || key, + dataType, + isSelected: existingField?.visible !== false, + currentArea: existingField?.area, + }; + }); + }, [data, fields]); + // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { @@ -212,16 +325,83 @@ export const PivotGridComponent: React.FC = ({ return null; } + const visibleFields = fields.filter((f) => f.visible !== false); + if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + return null; + } + return processPivotData( data, - fields, + visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + // 조건부 서식용 전체 값 수집 + const allCellValues = useMemo(() => { + if (!pivotResult) return new Map(); + + const valuesByField = new Map(); + + // 데이터 매트릭스에서 모든 값 수집 + pivotResult.dataMatrix.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 행 총계 값 수집 + pivotResult.grandTotals.row.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + // 열 총계 값 수집 + pivotResult.grandTotals.column.forEach((values) => { + values.forEach((val) => { + if (val.field && typeof val.value === "number" && !isNaN(val.value)) { + const existing = valuesByField.get(val.field) || []; + existing.push(val.value); + valuesByField.set(val.field, existing); + } + }); + }); + + return valuesByField; + }, [pivotResult]); + + // 조건부 서식 스타일 계산 헬퍼 + const getCellConditionalStyle = useCallback( + (value: number | undefined, field: string): CellFormatStyle => { + if (!style?.conditionalFormats || style.conditionalFormats.length === 0) { + return {}; + } + const allValues = allCellValues.get(field) || []; + return getConditionalStyle(value, field, style.conditionalFormats, allValues); + }, + [style?.conditionalFormats, allCellValues] + ); + // ==================== 이벤트 핸들러 ==================== + // 필드 변경 + const handleFieldsChange = useCallback( + (newFields: PivotFieldConfig[]) => { + setFields(newFields); + }, + [] + ); + // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { @@ -256,7 +436,6 @@ export const PivotGridComponent: React.FC = ({ if (!pivotResult) return; const allRowPaths: string[][] = []; - pivotResult.flatRows.forEach((row) => { if (row.hasChildren) { allRowPaths.push(row.path); @@ -296,6 +475,27 @@ export const PivotGridComponent: React.FC = ({ [onCellClick] ); + // 셀 더블클릭 (Drill Down) + const handleCellDoubleClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + // Drill Down 모달 열기 + setDrillDownData({ open: true, cellData }); + + // 외부 콜백 호출 + if (onCellDoubleClick) { + onCellDoubleClick(cellData); + } + }, + [onCellDoubleClick] + ); + // CSV 내보내기 const handleExportCSV = useCallback(() => { if (!pivotResult) return; @@ -354,6 +554,20 @@ export const PivotGridComponent: React.FC = ({ link.click(); }, [pivotResult, totals, title]); + // Excel 내보내기 + const handleExportExcel = useCallback(async () => { + if (!pivotResult) return; + + try { + await exportPivotToExcel(pivotResult, fields, totals, { + fileName: title || "pivot_export", + title: title, + }); + } catch (error) { + console.error("Excel 내보내기 실패:", error); + } + }, [pivotResult, fields, totals, title]); + // ==================== 렌더링 ==================== // 빈 상태 @@ -374,20 +588,51 @@ export const PivotGridComponent: React.FC = ({ } // 필드 미설정 - if (fields.length === 0) { + const hasActiveFields = fields.some( + (f) => f.visible !== false && f.area !== "filter" + ); + if (!hasActiveFields) { return (
- -

필드가 설정되지 않았습니다

-

- 행, 열, 데이터 영역에 필드를 배치해주세요 -

+ {/* 필드 패널 */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 안내 메시지 */} +
+ +

필드가 설정되지 않았습니다

+

+ 행, 열, 데이터 영역에 필드를 배치해주세요 +

+ +
+ + {/* 필드 선택기 모달 */} +
); } @@ -416,6 +661,14 @@ export const PivotGridComponent: React.FC = ({ maxHeight: isFullscreen ? "none" : maxHeight, }} > + {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} + setShowFieldPanel(!showFieldPanel)} + /> + {/* 헤더 툴바 */}
@@ -426,6 +679,30 @@ export const PivotGridComponent: React.FC = ({
+ {/* 필드 선택기 버튼 */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* 필드 패널 토글 */} + + {allowExpandAll && ( <> )} + {/* 내보내기 버튼들 */} + {exportConfig?.excel && ( + <> + + + + )} +
+ + {/* 차트 */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* 필드 선택기 모달 */} + + + {/* Drill Down 모달 */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + />
); }; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index a0e322d9..f3e9a976 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -431,14 +431,9 @@ const AreaFieldList: React.FC = ({ ) : ( availableColumns.map((col) => ( -
- {col.column_name} - {col.column_comment && ( - - ({col.column_comment}) - - )} -
+ {col.column_comment + ? `${col.column_name} (${col.column_comment})` + : col.column_name}
)) )} @@ -476,7 +471,8 @@ export const PivotGridConfigPanel: React.FC = ({ const loadTables = async () => { setLoadingTables(true); try { - const response = await apiClient.get("/api/table-management/list"); + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 + const response = await apiClient.get("/table-management/tables"); if (response.data.success) { setTables(response.data.data || []); } @@ -499,8 +495,9 @@ export const PivotGridConfigPanel: React.FC = ({ setLoadingColumns(true); try { + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 const response = await apiClient.get( - `/api/table-management/columns/${config.dataSource.tableName}` + `/table-management/tables/${config.dataSource.tableName}/columns` ); if (response.data.success) { setColumns(response.data.data || []); @@ -550,14 +547,9 @@ export const PivotGridConfigPanel: React.FC = ({ 선택 안 함 {tables.map((table) => ( -
- {table.table_name} - {table.table_comment && ( - - ({table.table_comment}) - - )} -
+ {table.table_comment + ? `${table.table_name} (${table.table_comment})` + : table.table_name}
))} @@ -717,6 +709,270 @@ export const PivotGridConfigPanel: React.FC = ({ + {/* 차트 설정 */} +
+ + +
+
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: v, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + }, + }) + } + /> +
+ + {config.chart?.enabled && ( +
+
+ + +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + height: Number(e.target.value), + }, + }) + } + className="h-8 text-xs" + /> +
+ +
+ + + updateConfig({ + chart: { + ...config.chart, + enabled: true, + type: config.chart?.type || "bar", + position: config.chart?.position || "bottom", + showLegend: v, + }, + }) + } + /> +
+
+ )} +
+
+ + + + {/* 필드 선택기 설정 */} +
+ + +
+
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, enabled: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + fieldChooser: { ...config.fieldChooser, allowSearch: v }, + }) + } + /> +
+
+
+ + + + {/* 조건부 서식 설정 */} +
+ + +
+
+ + r.type === "colorScale" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "colorScale" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "colorScale-1", + type: "colorScale" as const, + colorScale: { + minColor: "#ff6b6b", + midColor: "#ffd93d", + maxColor: "#6bcb77", + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "dataBar" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "dataBar" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "dataBar-1", + type: "dataBar" as const, + dataBar: { + color: "#3b82f6", + showValue: true, + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ +
+ + r.type === "iconSet" + ) || false + } + onCheckedChange={(v) => { + const existingFormats = config.style?.conditionalFormats || []; + const filtered = existingFormats.filter( + (r) => r.type !== "iconSet" + ); + updateConfig({ + style: { + ...config.style, + theme: config.style?.theme || "default", + headerStyle: config.style?.headerStyle || "default", + cellPadding: config.style?.cellPadding || "normal", + borderStyle: config.style?.borderStyle || "light", + conditionalFormats: v + ? [ + ...filtered, + { + id: "iconSet-1", + type: "iconSet" as const, + iconSet: { + type: "traffic", + thresholds: [33, 66], + }, + }, + ] + : filtered, + }, + }); + }} + /> +
+ + {config.style?.conditionalFormats && + config.style.conditionalFormats.length > 0 && ( +

+ {config.style.conditionalFormats.length}개의 조건부 서식이 + 적용됨 +

+ )} +
+
+ + + {/* 크기 설정 */}
diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 7c34192a..8e3563d9 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -6,6 +6,160 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition import { ComponentCategory } from "@/types/component"; import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; +import { PivotFieldConfig } from "./types"; + +// ==================== 샘플 데이터 (미리보기용) ==================== + +const SAMPLE_DATA = [ + { region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 }, + { region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 }, + { region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 }, + { region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 }, + { region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 }, + { region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 }, + { region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 }, + { region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 }, + { region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 }, + { region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 }, + { region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 }, + { region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 }, + { region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 }, + { region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 }, + { region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 }, + { region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 }, + { region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 }, + { region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 }, + { region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 }, + { region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 }, + { region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 }, + { region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 }, + { region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 }, + { region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 }, + { region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 }, + { region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 }, + { region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 }, + { region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 }, + { region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 }, + { region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 }, + { region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 }, + { region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 }, + { region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 }, + { region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 }, + { region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 }, + { region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 }, +]; + +const SAMPLE_FIELDS: PivotFieldConfig[] = [ + { + field: "region", + caption: "지역", + area: "row", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "product", + caption: "제품", + area: "row", + areaIndex: 1, + dataType: "string", + visible: true, + }, + { + field: "quarter", + caption: "분기", + area: "column", + areaIndex: 0, + dataType: "string", + visible: true, + }, + { + field: "sales", + caption: "매출", + area: "data", + areaIndex: 0, + dataType: "number", + summaryType: "sum", + format: { type: "number", precision: 0 }, + visible: true, + }, +]; + +/** + * PivotGrid 래퍼 컴포넌트 (디자인 모드에서 샘플 데이터 주입) + */ +const PivotGridWrapper: React.FC = (props) => { + // 컴포넌트 설정에서 값 추출 + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // 디버깅 로그 + console.log("🔷 PivotGridWrapper props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // 디자인 모드 판단: + // 1. isDesignMode === true + // 2. isInteractive === false (편집 모드) + // 3. 데이터가 없는 경우 + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 + const usePreviewData = isDesignMode || !hasValidData; + + // 최종 데이터/필드 결정 + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" + : (componentConfig.title || props.title); + + console.log("🔷 PivotGridWrapper final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // 총계 설정 + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + + return ( + + ); +}; /** * PivotGrid 컴포넌트 정의 @@ -17,13 +171,15 @@ const PivotGridDefinition = createComponentDefinition({ description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", category: ComponentCategory.DISPLAY, webType: "text", - component: PivotGridComponent, + component: PivotGridWrapper, // 래퍼 컴포넌트 사용 defaultConfig: { dataSource: { type: "table", tableName: "", }, - fields: [], + fields: SAMPLE_FIELDS, + // 미리보기용 샘플 데이터 + sampleData: SAMPLE_DATA, totals: { showRowGrandTotals: true, showColumnGrandTotals: true, @@ -61,9 +217,75 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = PivotGridDefinition; render(): React.ReactElement { + const props = this.props as any; + + // 컴포넌트 설정에서 값 추출 + const componentConfig = props.componentConfig || props.config || {}; + const configFields = componentConfig.fields || props.fields; + const configData = props.data; + + // 디버깅 로그 + console.log("🔷 PivotGridRenderer props:", { + isDesignMode: props.isDesignMode, + isInteractive: props.isInteractive, + hasComponentConfig: !!props.componentConfig, + hasConfig: !!props.config, + hasData: !!configData, + dataLength: configData?.length, + hasFields: !!configFields, + fieldsLength: configFields?.length, + }); + + // 디자인 모드 판단: + // 1. isDesignMode === true + // 2. isInteractive === false (편집 모드) + // 3. 데이터가 없는 경우 + const isDesignMode = props.isDesignMode === true || props.isInteractive === false; + const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; + + // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 + const usePreviewData = isDesignMode || !hasValidData; + + // 최종 데이터/필드 결정 + const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; + const finalTitle = usePreviewData + ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" + : (componentConfig.title || props.title); + + console.log("🔷 PivotGridRenderer final:", { + isDesignMode, + usePreviewData, + finalDataLength: finalData?.length, + finalFieldsLength: finalFields?.length, + }); + + // 총계 설정 + const totalsConfig = componentConfig.totals || props.totals || { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }; + return ( ); } diff --git a/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx new file mode 100644 index 00000000..994d782f --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/DrillDownModal.tsx @@ -0,0 +1,429 @@ +"use client"; + +/** + * DrillDownModal 컴포넌트 + * 피벗 셀 클릭 시 해당 셀의 상세 원본 데이터를 표시하는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotCellData, PivotFieldConfig } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Search, + Download, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ArrowUpDown, + ArrowUp, + ArrowDown, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface DrillDownModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cellData: PivotCellData | null; + data: any[]; // 전체 원본 데이터 + fields: PivotFieldConfig[]; + rowFields: PivotFieldConfig[]; + columnFields: PivotFieldConfig[]; +} + +interface SortConfig { + field: string; + direction: "asc" | "desc"; +} + +// ==================== 메인 컴포넌트 ==================== + +export const DrillDownModal: React.FC = ({ + open, + onOpenChange, + cellData, + data, + fields, + rowFields, + columnFields, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [sortConfig, setSortConfig] = useState(null); + + // 드릴다운 데이터 필터링 + const filteredData = useMemo(() => { + if (!cellData || !data) return []; + + // 행/열 경로에 해당하는 데이터 필터링 + let result = data.filter((row) => { + // 행 경로 매칭 + for (let i = 0; i < cellData.rowPath.length; i++) { + const field = rowFields[i]; + if (field && String(row[field.field]) !== cellData.rowPath[i]) { + return false; + } + } + + // 열 경로 매칭 + for (let i = 0; i < cellData.columnPath.length; i++) { + const field = columnFields[i]; + if (field && String(row[field.field]) !== cellData.columnPath[i]) { + return false; + } + } + + return true; + }); + + // 검색 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((row) => + Object.values(row).some((val) => + String(val).toLowerCase().includes(query) + ) + ); + } + + // 정렬 + if (sortConfig) { + result = [...result].sort((a, b) => { + const aVal = a[sortConfig.field]; + const bVal = b[sortConfig.field]; + + if (aVal === null || aVal === undefined) return 1; + if (bVal === null || bVal === undefined) return -1; + + let comparison = 0; + if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return sortConfig.direction === "asc" ? comparison : -comparison; + }); + } + + return result; + }, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]); + + // 페이지네이션 + const totalPages = Math.ceil(filteredData.length / pageSize); + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredData.slice(start, start + pageSize); + }, [filteredData, currentPage, pageSize]); + + // 표시할 컬럼 결정 + const displayColumns = useMemo(() => { + // 모든 필드의 field명 수집 + const fieldNames = new Set(); + + // fields에서 가져오기 + fields.forEach((f) => fieldNames.add(f.field)); + + // 데이터에서 추가 컬럼 가져오기 + if (data.length > 0) { + Object.keys(data[0]).forEach((key) => fieldNames.add(key)); + } + + return Array.from(fieldNames).map((fieldName) => { + const fieldConfig = fields.find((f) => f.field === fieldName); + return { + field: fieldName, + caption: fieldConfig?.caption || fieldName, + dataType: fieldConfig?.dataType || "string", + }; + }); + }, [fields, data]); + + // 정렬 토글 + const handleSort = (field: string) => { + setSortConfig((prev) => { + if (!prev || prev.field !== field) { + return { field, direction: "asc" }; + } + if (prev.direction === "asc") { + return { field, direction: "desc" }; + } + return null; + }); + }; + + // CSV 내보내기 + const handleExportCSV = () => { + if (filteredData.length === 0) return; + + const headers = displayColumns.map((c) => c.caption); + const rows = filteredData.map((row) => + displayColumns.map((c) => { + const val = row[c.field]; + if (val === null || val === undefined) return ""; + if (typeof val === "string" && val.includes(",")) { + return `"${val}"`; + } + return String(val); + }) + ); + + const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); + + const blob = new Blob(["\uFEFF" + csv], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`; + link.click(); + }; + + // 페이지 변경 + const goToPage = (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + // 경로 표시 + const pathDisplay = cellData + ? [ + ...(cellData.rowPath.length > 0 + ? [`행: ${cellData.rowPath.join(" > ")}`] + : []), + ...(cellData.columnPath.length > 0 + ? [`열: ${cellData.columnPath.join(" > ")}`] + : []), + ].join(" | ") + : ""; + + return ( + + + + 상세 데이터 + + {pathDisplay || "선택한 셀의 원본 데이터"} + + ({filteredData.length}건) + + + + + {/* 툴바 */} +
+
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 테이블 */} + +
+ + + + {displayColumns.map((col) => ( + handleSort(col.field)} + > +
+ {col.caption} + {sortConfig?.field === col.field ? ( + sortConfig.direction === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ))} +
+
+ + {paginatedData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + paginatedData.map((row, idx) => ( + + {displayColumns.map((col) => ( + + {formatCellValue(row[col.field], col.dataType)} + + ))} + + )) + )} + +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ {(currentPage - 1) * pageSize + 1} -{" "} + {Math.min(currentPage * pageSize, filteredData.length)} /{" "} + {filteredData.length}건 +
+ +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )} +
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function formatCellValue(value: any, dataType: string): string { + if (value === null || value === undefined) return "-"; + + if (dataType === "number") { + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString(); + } + + if (dataType === "date") { + try { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString("ko-KR"); + } + } catch { + // 변환 실패 시 원본 반환 + } + } + + return String(value); +} + +export default DrillDownModal; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx new file mode 100644 index 00000000..ec194a12 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -0,0 +1,441 @@ +"use client"; + +/** + * FieldChooser 컴포넌트 + * 사용 가능한 필드 목록을 표시하고 영역에 배치할 수 있는 모달 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Search, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + Plus, + Minus, + Type, + Hash, + Calendar, + ToggleLeft, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface AvailableField { + field: string; + caption: string; + dataType: "string" | "number" | "date" | "boolean"; + isSelected: boolean; + currentArea?: PivotAreaType; +} + +interface FieldChooserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + availableFields: AvailableField[]; + selectedFields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +// ==================== 영역 설정 ==================== + +const AREA_OPTIONS: { + value: PivotAreaType | "none"; + label: string; + icon: React.ReactNode; +}[] = [ + { value: "none", label: "사용 안함", icon: }, + { value: "filter", label: "필터", icon: }, + { value: "row", label: "행", icon: }, + { value: "column", label: "열", icon: }, + { value: "data", label: "데이터", icon: }, +]; + +const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, + { value: "countDistinct", label: "고유 개수" }, +]; + +const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [ + { value: "absoluteValue", label: "절대값" }, + { value: "percentOfRowTotal", label: "행 총계 %" }, + { value: "percentOfColumnTotal", label: "열 총계 %" }, + { value: "percentOfGrandTotal", label: "전체 총계 %" }, + { value: "runningTotalByRow", label: "행 누계" }, + { value: "runningTotalByColumn", label: "열 누계" }, + { value: "differenceFromPrevious", label: "이전 대비 차이" }, + { value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" }, +]; + +const DATA_TYPE_ICONS: Record = { + string: , + number: , + date: , + boolean: , +}; + +// ==================== 필드 아이템 ==================== + +interface FieldItemProps { + field: AvailableField; + config?: PivotFieldConfig; + onAreaChange: (area: PivotAreaType | "none") => void; + onSummaryChange?: (summary: AggregationType) => void; + onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void; +} + +const FieldItem: React.FC = ({ + field, + config, + onAreaChange, + onSummaryChange, + onDisplayModeChange, +}) => { + const currentArea = config?.area || "none"; + const isSelected = currentArea !== "none"; + + return ( +
+ {/* 데이터 타입 아이콘 */} +
+ {DATA_TYPE_ICONS[field.dataType] || } +
+ + {/* 필드명 */} +
+
{field.caption}
+
+ {field.field} +
+
+ + {/* 영역 선택 */} + + + {/* 집계 함수 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onSummaryChange && ( + + )} + + {/* 표시 모드 선택 (데이터 영역인 경우) */} + {currentArea === "data" && onDisplayModeChange && ( + + )} +
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const FieldChooser: React.FC = ({ + open, + onOpenChange, + availableFields, + selectedFields, + onFieldsChange, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">( + "all" + ); + + // 필터링된 필드 목록 + const filteredFields = useMemo(() => { + let result = availableFields; + + // 검색어 필터 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (f) => + f.caption.toLowerCase().includes(query) || + f.field.toLowerCase().includes(query) + ); + } + + // 선택 상태 필터 + if (filterType === "selected") { + result = result.filter((f) => + selectedFields.some((sf) => sf.field === f.field && sf.visible !== false) + ); + } else if (filterType === "unselected") { + result = result.filter( + (f) => + !selectedFields.some( + (sf) => sf.field === f.field && sf.visible !== false + ) + ); + } + + return result; + }, [availableFields, selectedFields, searchQuery, filterType]); + + // 필드 영역 변경 + const handleAreaChange = ( + field: AvailableField, + area: PivotAreaType | "none" + ) => { + const existingConfig = selectedFields.find((f) => f.field === field.field); + + if (area === "none") { + // 필드 제거 또는 숨기기 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, visible: false } : f + ); + onFieldsChange(newFields); + } + } else { + // 필드 추가 또는 영역 변경 + if (existingConfig) { + const newFields = selectedFields.map((f) => + f.field === field.field + ? { ...f, area, visible: true } + : f + ); + onFieldsChange(newFields); + } else { + // 새 필드 추가 + const newField: PivotFieldConfig = { + field: field.field, + caption: field.caption, + area, + dataType: field.dataType, + visible: true, + summaryType: area === "data" ? "sum" : undefined, + areaIndex: selectedFields.filter((f) => f.area === area).length, + }; + onFieldsChange([...selectedFields, newField]); + } + } + }; + + // 집계 함수 변경 + const handleSummaryChange = ( + field: AvailableField, + summaryType: AggregationType + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryType } : f + ); + onFieldsChange(newFields); + }; + + // 표시 모드 변경 + const handleDisplayModeChange = ( + field: AvailableField, + displayMode: SummaryDisplayMode + ) => { + const newFields = selectedFields.map((f) => + f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f + ); + onFieldsChange(newFields); + }; + + // 모든 필드 선택 해제 + const handleClearAll = () => { + const newFields = selectedFields.map((f) => ({ ...f, visible: false })); + onFieldsChange(newFields); + }; + + // 통계 + const stats = useMemo(() => { + const visible = selectedFields.filter((f) => f.visible !== false); + return { + total: availableFields.length, + selected: visible.length, + filter: visible.filter((f) => f.area === "filter").length, + row: visible.filter((f) => f.area === "row").length, + column: visible.filter((f) => f.area === "column").length, + data: visible.filter((f) => f.area === "data").length, + }; + }, [availableFields, selectedFields]); + + return ( + + + + 필드 선택기 + + 피벗 테이블에 표시할 필드를 선택하고 영역을 지정하세요. + + + + {/* 통계 */} +
+ 전체: {stats.total} + + 선택됨: {stats.selected} + + 필터: {stats.filter} + 행: {stats.row} + 열: {stats.column} + 데이터: {stats.data} +
+ + {/* 검색 및 필터 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9" + /> +
+ + + + +
+ + {/* 필드 목록 */} + +
+ {filteredFields.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredFields.map((field) => { + const config = selectedFields.find( + (f) => f.field === field.field && f.visible !== false + ); + return ( + handleAreaChange(field, area)} + onSummaryChange={ + config?.area === "data" + ? (summary) => handleSummaryChange(field, summary) + : undefined + } + onDisplayModeChange={ + config?.area === "data" + ? (mode) => handleDisplayModeChange(field, mode) + : undefined + } + /> + ); + }) + )} +
+
+ + {/* 푸터 */} +
+ +
+
+
+ ); +}; + +export default FieldChooser; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx new file mode 100644 index 00000000..063b4c6c --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -0,0 +1,551 @@ +"use client"; + +/** + * FieldPanel 컴포넌트 + * 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터) + * 드래그 앤 드롭으로 필드 재배치 가능 + */ + +import React, { useState } from "react"; +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig, PivotAreaType } from "../types"; +import { + X, + Filter, + Columns, + Rows, + BarChart3, + GripVertical, + ChevronDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// ==================== 타입 ==================== + +interface FieldPanelProps { + fields: PivotFieldConfig[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; + onFieldRemove?: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +interface FieldChipProps { + field: PivotFieldConfig; + onRemove: () => void; + onSettingsChange?: (field: PivotFieldConfig) => void; +} + +interface DroppableAreaProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + title: string; + icon: React.ReactNode; + onFieldRemove: (field: PivotFieldConfig) => void; + onFieldSettingsChange?: (field: PivotFieldConfig) => void; + isOver?: boolean; +} + +// ==================== 영역 설정 ==================== + +const AREA_CONFIG: Record< + PivotAreaType, + { title: string; icon: React.ReactNode; color: string } +> = { + filter: { + title: "필터", + icon: , + color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800", + }, + column: { + title: "열", + icon: , + color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800", + }, + row: { + title: "행", + icon: , + color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800", + }, + data: { + title: "데이터", + icon: , + color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800", + }, +}; + +// ==================== 필드 칩 (드래그 가능) ==================== + +const SortableFieldChip: React.FC = ({ + field, + onRemove, + onSettingsChange, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${field.area}-${field.field}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 필드 라벨 */} + + + + + + {field.area === "data" && ( + <> + + onSettingsChange?.({ ...field, summaryType: "sum" }) + } + > + 합계 + + + onSettingsChange?.({ ...field, summaryType: "count" }) + } + > + 개수 + + + onSettingsChange?.({ ...field, summaryType: "avg" }) + } + > + 평균 + + + onSettingsChange?.({ ...field, summaryType: "min" }) + } + > + 최소 + + + onSettingsChange?.({ ...field, summaryType: "max" }) + } + > + 최대 + + + + )} + + onSettingsChange?.({ + ...field, + sortOrder: field.sortOrder === "asc" ? "desc" : "asc", + }) + } + > + {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} + + + onSettingsChange?.({ ...field, visible: false })} + > + 필드 숨기기 + + + + + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 드롭 영역 ==================== + +const DroppableArea: React.FC = ({ + area, + fields, + title, + icon, + onFieldRemove, + onFieldSettingsChange, + isOver, +}) => { + const config = AREA_CONFIG[area]; + const areaFields = fields.filter((f) => f.area === area && f.visible !== false); + const fieldIds = areaFields.map((f) => `${area}-${f.field}`); + + return ( +
+ {/* 영역 헤더 */} +
+ {icon} + {title} + {areaFields.length > 0 && ( + + {areaFields.length} + + )} +
+ + {/* 필드 목록 */} + +
+ {areaFields.length === 0 ? ( + + 필드를 여기로 드래그 + + ) : ( + areaFields.map((field) => ( + onFieldRemove(field)} + onSettingsChange={onFieldSettingsChange} + /> + )) + )} +
+
+
+ ); +}; + +// ==================== 유틸리티 ==================== + +function getSummaryLabel(type: string): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유", + }; + return labels[type] || type; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FieldPanel: React.FC = ({ + fields, + onFieldsChange, + onFieldRemove, + onFieldSettingsChange, + collapsed = false, + onToggleCollapse, +}) => { + const [activeId, setActiveId] = useState(null); + const [overArea, setOverArea] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // 드래그 시작 + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + // 드래그 오버 + const handleDragOver = (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverArea(null); + return; + } + + // 드롭 영역 감지 + const overId = over.id as string; + const targetArea = overId.split("-")[0] as PivotAreaType; + if (["filter", "column", "row", "data"].includes(targetArea)) { + setOverArea(targetArea); + } + }; + + // 드래그 종료 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverArea(null); + + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + // 필드 정보 파싱 + const [sourceArea, sourceField] = activeId.split("-") as [ + PivotAreaType, + string + ]; + const [targetArea] = overId.split("-") as [PivotAreaType, string]; + + // 같은 영역 내 정렬 + if (sourceArea === targetArea) { + const areaFields = fields.filter((f) => f.area === sourceArea); + const sourceIndex = areaFields.findIndex((f) => f.field === sourceField); + const targetIndex = areaFields.findIndex( + (f) => `${f.area}-${f.field}` === overId + ); + + if (sourceIndex !== targetIndex && targetIndex >= 0) { + // 순서 변경 + const newFields = [...fields]; + const fieldToMove = newFields.find( + (f) => f.field === sourceField && f.area === sourceArea + ); + if (fieldToMove) { + fieldToMove.areaIndex = targetIndex; + // 다른 필드들 인덱스 조정 + newFields + .filter((f) => f.area === sourceArea && f.field !== sourceField) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .forEach((f, idx) => { + f.areaIndex = idx >= targetIndex ? idx + 1 : idx; + }); + } + onFieldsChange(newFields); + } + return; + } + + // 다른 영역으로 이동 + if (["filter", "column", "row", "data"].includes(targetArea)) { + const newFields = fields.map((f) => { + if (f.field === sourceField && f.area === sourceArea) { + return { + ...f, + area: targetArea as PivotAreaType, + areaIndex: fields.filter((ff) => ff.area === targetArea).length, + }; + } + return f; + }); + onFieldsChange(newFields); + } + }; + + // 필드 제거 + const handleFieldRemove = (field: PivotFieldConfig) => { + if (onFieldRemove) { + onFieldRemove(field); + } else { + // 기본 동작: visible을 false로 설정 + const newFields = fields.map((f) => + f.field === field.field && f.area === field.area + ? { ...f, visible: false } + : f + ); + onFieldsChange(newFields); + } + }; + + // 필드 설정 변경 + const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => { + if (onFieldSettingsChange) { + onFieldSettingsChange(updatedField); + } + const newFields = fields.map((f) => + f.field === updatedField.field && f.area === updatedField.area + ? updatedField + : f + ); + onFieldsChange(newFields); + }; + + // 활성 필드 찾기 (드래그 중인 필드) + const activeField = activeId + ? fields.find((f) => `${f.area}-${f.field}` === activeId) + : null; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* 2x2 그리드로 영역 배치 */} +
+ {/* 필터 영역 */} + + + {/* 열 영역 */} + + + {/* 행 영역 */} + + + {/* 데이터 영역 */} + +
+ + {/* 접기 버튼 */} + {onToggleCollapse && ( +
+ +
+ )} +
+ + {/* 드래그 오버레이 */} + + {activeField ? ( +
+ + {activeField.caption} +
+ ) : null} +
+
+ ); +}; + +export default FieldPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx new file mode 100644 index 00000000..e3185f5a --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/FilterPopup.tsx @@ -0,0 +1,265 @@ +"use client"; + +/** + * FilterPopup 컴포넌트 + * 피벗 필드의 값을 필터링하는 팝업 + */ + +import React, { useState, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotFieldConfig } from "../types"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Search, + Filter, + Check, + X, + CheckSquare, + Square, +} from "lucide-react"; + +// ==================== 타입 ==================== + +interface FilterPopupProps { + field: PivotFieldConfig; + data: any[]; + onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void; + trigger?: React.ReactNode; +} + +// ==================== 메인 컴포넌트 ==================== + +export const FilterPopup: React.FC = ({ + field, + data, + onFilterChange, + trigger, +}) => { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedValues, setSelectedValues] = useState>( + new Set(field.filterValues || []) + ); + const [filterType, setFilterType] = useState<"include" | "exclude">( + field.filterType || "include" + ); + + // 고유 값 추출 + const uniqueValues = useMemo(() => { + const values = new Set(); + data.forEach((row) => { + const value = row[field.field]; + if (value !== null && value !== undefined) { + values.add(value); + } + }); + return Array.from(values).sort((a, b) => { + if (typeof a === "number" && typeof b === "number") return a - b; + return String(a).localeCompare(String(b), "ko"); + }); + }, [data, field.field]); + + // 필터링된 값 목록 + const filteredValues = useMemo(() => { + if (!searchQuery) return uniqueValues; + const query = searchQuery.toLowerCase(); + return uniqueValues.filter((val) => + String(val).toLowerCase().includes(query) + ); + }, [uniqueValues, searchQuery]); + + // 값 토글 + const handleValueToggle = (value: any) => { + const newSelected = new Set(selectedValues); + if (newSelected.has(value)) { + newSelected.delete(value); + } else { + newSelected.add(value); + } + setSelectedValues(newSelected); + }; + + // 모두 선택 + const handleSelectAll = () => { + setSelectedValues(new Set(filteredValues)); + }; + + // 모두 해제 + const handleClearAll = () => { + setSelectedValues(new Set()); + }; + + // 적용 + const handleApply = () => { + onFilterChange(field, Array.from(selectedValues), filterType); + setOpen(false); + }; + + // 초기화 + const handleReset = () => { + setSelectedValues(new Set()); + setFilterType("include"); + onFilterChange(field, [], "include"); + setOpen(false); + }; + + // 필터 활성 상태 + const isFilterActive = field.filterValues && field.filterValues.length > 0; + + // 선택된 항목 수 + const selectedCount = selectedValues.size; + const totalCount = uniqueValues.length; + + return ( + + + {trigger || ( + + )} + + +
+
+ {field.caption} 필터 +
+ + +
+
+ + {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ + {/* 전체 선택/해제 */} +
+ + {selectedCount} / {totalCount} 선택됨 + +
+ + +
+
+
+ + {/* 값 목록 */} + +
+ {filteredValues.length === 0 ? ( +
+ 결과가 없습니다 +
+ ) : ( + filteredValues.map((value) => ( + + )) + )} +
+
+ + {/* 버튼 */} +
+ +
+ + +
+
+
+
+ ); +}; + +export default FilterPopup; + diff --git a/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx new file mode 100644 index 00000000..6f7c3708 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/PivotChart.tsx @@ -0,0 +1,386 @@ +"use client"; + +/** + * PivotChart 컴포넌트 + * 피벗 데이터를 차트로 시각화 + */ + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types"; +import { pathToKey } from "../utils/pivotEngine"; +import { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// ==================== 타입 ==================== + +interface PivotChartProps { + pivotResult: PivotResult; + config: PivotChartConfig; + dataFields: PivotFieldConfig[]; + className?: string; +} + +// ==================== 색상 ==================== + +const COLORS = [ + "#4472C4", // 파랑 + "#ED7D31", // 주황 + "#A5A5A5", // 회색 + "#FFC000", // 노랑 + "#5B9BD5", // 하늘 + "#70AD47", // 초록 + "#264478", // 진한 파랑 + "#9E480E", // 진한 주황 + "#636363", // 진한 회색 + "#997300", // 진한 노랑 +]; + +// ==================== 데이터 변환 ==================== + +function transformDataForChart( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + // 행 기준 차트 데이터 생성 + return flatRows.map((row) => { + const dataPoint: any = { + name: row.caption, + path: row.path, + }; + + // 각 열에 대한 데이터 추가 + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + + if (values && values.length > 0) { + const columnName = col.caption || "전체"; + dataPoint[columnName] = values[0].value; + } + }); + + // 총계 추가 + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + dataPoint["총계"] = rowTotal[0].value; + } + + return dataPoint; + }); +} + +function transformDataForPie( + pivotResult: PivotResult, + dataFields: PivotFieldConfig[] +): any[] { + const { flatRows, grandTotals } = pivotResult; + + return flatRows.map((row, idx) => { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + return { + name: row.caption, + value: rowTotal?.[0]?.value || 0, + color: COLORS[idx % COLORS.length], + }; + }); +} + +// ==================== 차트 컴포넌트 ==================== + +const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + return ( +
+

{label}

+ {payload.map((entry: any, idx: number) => ( +

+ {entry.name}: {entry.value?.toLocaleString()} +

+ ))} +
+ ); +}; + +// 막대 차트 +const PivotBarChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; + stacked?: boolean; +}> = ({ data, columns, height, showLegend, stacked }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 선 차트 +const PivotLineChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 영역 차트 +const PivotAreaChart: React.FC<{ + data: any[]; + columns: string[]; + height: number; + showLegend: boolean; +}> = ({ data, columns, height, showLegend }) => { + return ( + + + + + value.toLocaleString()} + /> + } /> + {showLegend && ( + + )} + {columns.map((col, idx) => ( + + ))} + + + ); +}; + +// 파이 차트 +const PivotPieChart: React.FC<{ + data: any[]; + height: number; + showLegend: boolean; +}> = ({ data, height, showLegend }) => { + return ( + + + + `${name} (${(percent * 100).toFixed(1)}%)` + } + labelLine + > + {data.map((entry, idx) => ( + + ))} + + } /> + {showLegend && ( + + )} + + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotChart: React.FC = ({ + pivotResult, + config, + dataFields, + className, +}) => { + // 차트 데이터 변환 + const chartData = useMemo(() => { + if (config.type === "pie") { + return transformDataForPie(pivotResult, dataFields); + } + return transformDataForChart(pivotResult, dataFields); + }, [pivotResult, dataFields, config.type]); + + // 열 이름 목록 (파이 차트 제외) + const columns = useMemo(() => { + if (config.type === "pie" || chartData.length === 0) return []; + + const firstItem = chartData[0]; + return Object.keys(firstItem).filter( + (key) => key !== "name" && key !== "path" + ); + }, [chartData, config.type]); + + const height = config.height || 300; + const showLegend = config.showLegend !== false; + + if (!config.enabled) { + return null; + } + + return ( +
+ {/* 차트 렌더링 */} + {config.type === "bar" && ( + + )} + + {config.type === "stackedBar" && ( + + )} + + {config.type === "line" && ( + + )} + + {config.type === "area" && ( + + )} + + {config.type === "pie" && ( + + )} +
+ ); +}; + +export default PivotChart; + diff --git a/frontend/lib/registry/components/pivot-grid/components/index.ts b/frontend/lib/registry/components/pivot-grid/components/index.ts new file mode 100644 index 00000000..a901a7cf --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/components/index.ts @@ -0,0 +1,10 @@ +/** + * PivotGrid 서브 컴포넌트 내보내기 + */ + +export { FieldPanel } from "./FieldPanel"; +export { FieldChooser } from "./FieldChooser"; +export { DrillDownModal } from "./DrillDownModal"; +export { FilterPopup } from "./FilterPopup"; +export { PivotChart } from "./PivotChart"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/index.ts b/frontend/lib/registry/components/pivot-grid/hooks/index.ts new file mode 100644 index 00000000..a9a1a4eb --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/index.ts @@ -0,0 +1,27 @@ +/** + * PivotGrid 커스텀 훅 내보내기 + */ + +export { + useVirtualScroll, + useVirtualColumnScroll, + useVirtual2DScroll, +} from "./useVirtualScroll"; + +export type { + VirtualScrollOptions, + VirtualScrollResult, + VirtualColumnScrollOptions, + VirtualColumnScrollResult, + Virtual2DScrollOptions, + Virtual2DScrollResult, +} from "./useVirtualScroll"; + +export { usePivotState } from "./usePivotState"; + +export type { + PivotStateConfig, + SavedPivotState, + UsePivotStateResult, +} from "./usePivotState"; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts new file mode 100644 index 00000000..9b001377 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/usePivotState.ts @@ -0,0 +1,231 @@ +"use client"; + +/** + * PivotState 훅 + * 피벗 그리드 상태 저장/복원 관리 + */ + +import { useState, useEffect, useCallback } from "react"; +import { PivotFieldConfig, PivotGridState } from "../types"; + +// ==================== 타입 ==================== + +export interface PivotStateConfig { + enabled: boolean; + storageKey?: string; + storageType?: "localStorage" | "sessionStorage"; +} + +export interface SavedPivotState { + version: string; + timestamp: number; + fields: PivotFieldConfig[]; + expandedRowPaths: string[][]; + expandedColumnPaths: string[][]; + filterConfig: Record; + sortConfig: { + field: string; + direction: "asc" | "desc"; + } | null; +} + +export interface UsePivotStateResult { + // 상태 + fields: PivotFieldConfig[]; + pivotState: PivotGridState; + + // 상태 변경 + setFields: (fields: PivotFieldConfig[]) => void; + setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void; + + // 저장/복원 + saveState: () => void; + loadState: () => boolean; + clearState: () => void; + hasStoredState: () => boolean; + + // 상태 정보 + lastSaved: Date | null; + isDirty: boolean; +} + +// ==================== 상수 ==================== + +const STATE_VERSION = "1.0.0"; +const DEFAULT_STORAGE_KEY = "pivot-grid-state"; + +// ==================== 훅 ==================== + +export function usePivotState( + initialFields: PivotFieldConfig[], + config: PivotStateConfig +): UsePivotStateResult { + const { + enabled, + storageKey = DEFAULT_STORAGE_KEY, + storageType = "localStorage", + } = config; + + // 상태 + const [fields, setFieldsInternal] = useState(initialFields); + const [pivotState, setPivotStateInternal] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + const [lastSaved, setLastSaved] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [initialStateLoaded, setInitialStateLoaded] = useState(false); + + // 스토리지 가져오기 + const getStorage = useCallback(() => { + if (typeof window === "undefined") return null; + return storageType === "localStorage" ? localStorage : sessionStorage; + }, [storageType]); + + // 저장된 상태 확인 + const hasStoredState = useCallback((): boolean => { + const storage = getStorage(); + if (!storage) return false; + return storage.getItem(storageKey) !== null; + }, [getStorage, storageKey]); + + // 상태 저장 + const saveState = useCallback(() => { + if (!enabled) return; + + const storage = getStorage(); + if (!storage) return; + + const stateToSave: SavedPivotState = { + version: STATE_VERSION, + timestamp: Date.now(), + fields, + expandedRowPaths: pivotState.expandedRowPaths, + expandedColumnPaths: pivotState.expandedColumnPaths, + filterConfig: pivotState.filterConfig, + sortConfig: pivotState.sortConfig, + }; + + try { + storage.setItem(storageKey, JSON.stringify(stateToSave)); + setLastSaved(new Date()); + setIsDirty(false); + console.log("✅ 피벗 상태 저장됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 저장 실패:", error); + } + }, [enabled, getStorage, storageKey, fields, pivotState]); + + // 상태 불러오기 + const loadState = useCallback((): boolean => { + if (!enabled) return false; + + const storage = getStorage(); + if (!storage) return false; + + try { + const saved = storage.getItem(storageKey); + if (!saved) return false; + + const parsedState: SavedPivotState = JSON.parse(saved); + + // 버전 체크 + if (parsedState.version !== STATE_VERSION) { + console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨"); + return false; + } + + // 상태 복원 + setFieldsInternal(parsedState.fields); + setPivotStateInternal({ + expandedRowPaths: parsedState.expandedRowPaths, + expandedColumnPaths: parsedState.expandedColumnPaths, + sortConfig: parsedState.sortConfig, + filterConfig: parsedState.filterConfig, + }); + setLastSaved(new Date(parsedState.timestamp)); + setIsDirty(false); + + console.log("✅ 피벗 상태 복원됨:", storageKey); + return true; + } catch (error) { + console.error("❌ 피벗 상태 복원 실패:", error); + return false; + } + }, [enabled, getStorage, storageKey]); + + // 상태 초기화 + const clearState = useCallback(() => { + const storage = getStorage(); + if (!storage) return; + + try { + storage.removeItem(storageKey); + setLastSaved(null); + console.log("🗑️ 피벗 상태 삭제됨:", storageKey); + } catch (error) { + console.error("❌ 피벗 상태 삭제 실패:", error); + } + }, [getStorage, storageKey]); + + // 필드 변경 (dirty 플래그 설정) + const setFields = useCallback((newFields: PivotFieldConfig[]) => { + setFieldsInternal(newFields); + setIsDirty(true); + }, []); + + // 피벗 상태 변경 (dirty 플래그 설정) + const setPivotState = useCallback( + (newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => { + setPivotStateInternal(newState); + setIsDirty(true); + }, + [] + ); + + // 초기 로드 + useEffect(() => { + if (!initialStateLoaded && enabled && hasStoredState()) { + loadState(); + setInitialStateLoaded(true); + } + }, [enabled, hasStoredState, loadState, initialStateLoaded]); + + // 초기 필드 동기화 (저장된 상태가 없을 때) + useEffect(() => { + if (initialStateLoaded) return; + if (!hasStoredState() && initialFields.length > 0) { + setFieldsInternal(initialFields); + setInitialStateLoaded(true); + } + }, [initialFields, hasStoredState, initialStateLoaded]); + + // 자동 저장 (변경 시) + useEffect(() => { + if (!enabled || !isDirty) return; + + const timeout = setTimeout(() => { + saveState(); + }, 1000); // 1초 디바운스 + + return () => clearTimeout(timeout); + }, [enabled, isDirty, saveState]); + + return { + fields, + pivotState, + setFields, + setPivotState, + saveState, + loadState, + clearState, + hasStoredState, + lastSaved, + isDirty, + }; +} + +export default usePivotState; + diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts new file mode 100644 index 00000000..152cb2df --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts @@ -0,0 +1,312 @@ +"use client"; + +/** + * Virtual Scroll 훅 + * 대용량 피벗 데이터의 가상 스크롤 처리 + */ + +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; + +// ==================== 타입 ==================== + +export interface VirtualScrollOptions { + itemCount: number; // 전체 아이템 수 + itemHeight: number; // 각 아이템 높이 (px) + containerHeight: number; // 컨테이너 높이 (px) + overscan?: number; // 버퍼 아이템 수 (기본: 5) +} + +export interface VirtualScrollResult { + // 현재 보여야 할 아이템 범위 + startIndex: number; + endIndex: number; + + // 가상 스크롤 관련 값 + totalHeight: number; // 전체 높이 + offsetTop: number; // 상단 오프셋 + + // 보여지는 아이템 목록 + visibleItems: number[]; + + // 이벤트 핸들러 + onScroll: (scrollTop: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +// ==================== 훅 ==================== + +export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult { + const { + itemCount, + itemHeight, + containerHeight, + overscan = 5, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + // 보이는 아이템 수 + const visibleCount = Math.ceil(containerHeight / itemHeight); + + // 시작/끝 인덱스 계산 + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const end = Math.min( + itemCount - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); + + // 전체 높이 + const totalHeight = itemCount * itemHeight; + + // 상단 오프셋 + const offsetTop = startIndex * itemHeight; + + // 보이는 아이템 인덱스 배열 + const visibleItems = useMemo(() => { + const items: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + items.push(i); + } + return items; + }, [startIndex, endIndex]); + + // 스크롤 핸들러 + const onScroll = useCallback((newScrollTop: number) => { + setScrollTop(newScrollTop); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + startIndex, + endIndex, + totalHeight, + offsetTop, + visibleItems, + onScroll, + containerRef, + }; +} + +// ==================== 열 가상 스크롤 ==================== + +export interface VirtualColumnScrollOptions { + columnCount: number; // 전체 열 수 + columnWidth: number; // 각 열 너비 (px) + containerWidth: number; // 컨테이너 너비 (px) + overscan?: number; +} + +export interface VirtualColumnScrollResult { + startIndex: number; + endIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + onScroll: (scrollLeft: number) => void; +} + +export function useVirtualColumnScroll( + options: VirtualColumnScrollOptions +): VirtualColumnScrollResult { + const { + columnCount, + columnWidth, + containerWidth, + overscan = 3, + } = options; + + const [scrollLeft, setScrollLeft] = useState(0); + + const { startIndex, endIndex } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan + ); + return { startIndex: start, endIndex: end }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]); + + const totalWidth = columnCount * columnWidth; + const offsetLeft = startIndex * columnWidth; + + const visibleColumns = useMemo(() => { + const cols: number[] = []; + for (let i = startIndex; i <= endIndex; i++) { + cols.push(i); + } + return cols; + }, [startIndex, endIndex]); + + const onScroll = useCallback((newScrollLeft: number) => { + setScrollLeft(newScrollLeft); + }, []); + + return { + startIndex, + endIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + }; +} + +// ==================== 2D 가상 스크롤 (행 + 열) ==================== + +export interface Virtual2DScrollOptions { + rowCount: number; + columnCount: number; + rowHeight: number; + columnWidth: number; + containerHeight: number; + containerWidth: number; + rowOverscan?: number; + columnOverscan?: number; +} + +export interface Virtual2DScrollResult { + // 행 범위 + rowStartIndex: number; + rowEndIndex: number; + totalHeight: number; + offsetTop: number; + visibleRows: number[]; + + // 열 범위 + columnStartIndex: number; + columnEndIndex: number; + totalWidth: number; + offsetLeft: number; + visibleColumns: number[]; + + // 스크롤 핸들러 + onScroll: (scrollTop: number, scrollLeft: number) => void; + + // 컨테이너 ref + containerRef: React.RefObject; +} + +export function useVirtual2DScroll( + options: Virtual2DScrollOptions +): Virtual2DScrollResult { + const { + rowCount, + columnCount, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + rowOverscan = 5, + columnOverscan = 3, + } = options; + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + // 행 계산 + const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan); + const end = Math.min( + rowCount - 1, + Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan + ); + + const rows: number[] = []; + for (let i = start; i <= end; i++) { + rows.push(i); + } + + return { + rowStartIndex: start, + rowEndIndex: end, + visibleRows: rows, + }; + }, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]); + + // 열 계산 + const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => { + const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan); + const end = Math.min( + columnCount - 1, + Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan + ); + + const cols: number[] = []; + for (let i = start; i <= end; i++) { + cols.push(i); + } + + return { + columnStartIndex: start, + columnEndIndex: end, + visibleColumns: cols, + }; + }, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]); + + const totalHeight = rowCount * rowHeight; + const totalWidth = columnCount * columnWidth; + const offsetTop = rowStartIndex * rowHeight; + const offsetLeft = columnStartIndex * columnWidth; + + const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => { + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + }, []); + + // 스크롤 이벤트 리스너 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + setScrollTop(container.scrollTop); + setScrollLeft(container.scrollLeft); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, []); + + return { + rowStartIndex, + rowEndIndex, + totalHeight, + offsetTop, + visibleRows, + columnStartIndex, + columnEndIndex, + totalWidth, + offsetLeft, + visibleColumns, + onScroll, + containerRef, + }; +} + +export default useVirtualScroll; + diff --git a/frontend/lib/registry/components/pivot-grid/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts index 16044dbc..b1bbe99b 100644 --- a/frontend/lib/registry/components/pivot-grid/index.ts +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -8,6 +8,7 @@ export type { // 기본 타입 PivotAreaType, AggregationType, + SummaryDisplayMode, SortDirection, DateGroupInterval, FieldDataType, diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index e0ea3199..e711a255 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -11,6 +11,19 @@ export type PivotAreaType = "row" | "column" | "data" | "filter"; // 집계 함수 타입 export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; +// 요약 표시 모드 +export type SummaryDisplayMode = + | "absoluteValue" // 절대값 (기본) + | "percentOfColumnTotal" // 열 총계 대비 % + | "percentOfRowTotal" // 행 총계 대비 % + | "percentOfGrandTotal" // 전체 총계 대비 % + | "percentOfColumnGrandTotal" // 열 대총계 대비 % + | "percentOfRowGrandTotal" // 행 대총계 대비 % + | "runningTotalByRow" // 행 방향 누계 + | "runningTotalByColumn" // 열 방향 누계 + | "differenceFromPrevious" // 이전 대비 차이 + | "percentDifferenceFromPrevious"; // 이전 대비 % 차이 + // 정렬 방향 export type SortDirection = "asc" | "desc" | "none"; @@ -48,6 +61,8 @@ export interface PivotFieldConfig { // 집계 설정 (data 영역용) summaryType?: AggregationType; // 집계 함수 + summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드 + showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭) // 정렬 설정 sortBy?: "value" | "caption"; // 정렬 기준 @@ -151,6 +166,45 @@ export interface PivotChartConfig { animate?: boolean; } +// 조건부 서식 규칙 +export interface ConditionalFormatRule { + id: string; + type: "colorScale" | "dataBar" | "iconSet" | "cellValue"; + field?: string; // 적용할 데이터 필드 (없으면 전체) + + // colorScale: 값 범위에 따른 색상 그라데이션 + colorScale?: { + minColor: string; // 최소값 색상 (예: "#ff0000") + midColor?: string; // 중간값 색상 (선택) + maxColor: string; // 최대값 색상 (예: "#00ff00") + }; + + // dataBar: 값에 따른 막대 표시 + dataBar?: { + color: string; // 막대 색상 + showValue?: boolean; // 값 표시 여부 + minValue?: number; // 최소값 (없으면 자동) + maxValue?: number; // 최대값 (없으면 자동) + }; + + // iconSet: 값에 따른 아이콘 표시 + iconSet?: { + type: "arrows" | "traffic" | "rating" | "flags"; + thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100) + reverse?: boolean; // 아이콘 순서 반전 + }; + + // cellValue: 조건에 따른 스타일 + cellValue?: { + operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between"; + value1: number; + value2?: number; // between 연산자용 + backgroundColor?: string; + textColor?: string; + bold?: boolean; + }; +} + // 스타일 설정 export interface PivotStyleConfig { theme: "default" | "compact" | "modern"; @@ -159,6 +213,7 @@ export interface PivotStyleConfig { borderStyle: "none" | "light" | "heavy"; alternateRowColors?: boolean; highlightTotals?: boolean; // 총합계 강조 + conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 } // ==================== 내보내기 설정 ==================== diff --git a/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts new file mode 100644 index 00000000..a9195d92 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/conditionalFormat.ts @@ -0,0 +1,311 @@ +/** + * 조건부 서식 유틸리티 + * 셀 값에 따른 스타일 계산 + */ + +import { ConditionalFormatRule } from "../types"; + +// ==================== 타입 ==================== + +export interface CellFormatStyle { + backgroundColor?: string; + textColor?: string; + fontWeight?: string; + dataBarWidth?: number; // 0-100% + dataBarColor?: string; + icon?: string; // 이모지 또는 아이콘 이름 +} + +// ==================== 색상 유틸리티 ==================== + +/** + * HEX 색상을 RGB로 변환 + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * RGB를 HEX로 변환 + */ +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +/** + * 두 색상 사이의 보간 + */ +function interpolateColor( + color1: string, + color2: string, + factor: number +): string { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return color1; + + const r = rgb1.r + (rgb2.r - rgb1.r) * factor; + const g = rgb1.g + (rgb2.g - rgb1.g) * factor; + const b = rgb1.b + (rgb2.b - rgb1.b) * factor; + + return rgbToHex(r, g, b); +} + +// ==================== 조건부 서식 계산 ==================== + +/** + * Color Scale 스타일 계산 + */ +function applyColorScale( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.colorScale) return {}; + + const { minColor, midColor, maxColor } = rule.colorScale; + const range = maxValue - minValue; + + if (range === 0) { + return { backgroundColor: minColor }; + } + + const normalizedValue = (value - minValue) / range; + + let backgroundColor: string; + + if (midColor) { + // 3색 그라데이션 + if (normalizedValue <= 0.5) { + backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2); + } else { + backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2); + } + } else { + // 2색 그라데이션 + backgroundColor = interpolateColor(minColor, maxColor, normalizedValue); + } + + // 배경색에 따른 텍스트 색상 결정 + const rgb = hexToRgb(backgroundColor); + const textColor = + rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 + ? "#000000" + : "#ffffff"; + + return { backgroundColor, textColor }; +} + +/** + * Data Bar 스타일 계산 + */ +function applyDataBar( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.dataBar) return {}; + + const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar; + + const min = ruleMin ?? minValue; + const max = ruleMax ?? maxValue; + const range = max - min; + + if (range === 0) { + return { dataBarWidth: 100, dataBarColor: color }; + } + + const width = Math.max(0, Math.min(100, ((value - min) / range) * 100)); + + return { + dataBarWidth: width, + dataBarColor: color, + }; +} + +/** + * Icon Set 스타일 계산 + */ +function applyIconSet( + value: number, + minValue: number, + maxValue: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.iconSet) return {}; + + const { type, thresholds, reverse } = rule.iconSet; + const range = maxValue - minValue; + const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100; + + // 아이콘 정의 + const iconSets: Record = { + arrows: ["↓", "→", "↑"], + traffic: ["🔴", "🟡", "🟢"], + rating: ["⭐", "⭐⭐", "⭐⭐⭐"], + flags: ["🚩", "🏳️", "🏁"], + }; + + const icons = iconSets[type] || iconSets.arrows; + const sortedIcons = reverse ? [...icons].reverse() : icons; + + // 임계값에 따른 아이콘 선택 + let iconIndex = 0; + for (let i = 0; i < thresholds.length; i++) { + if (percentage >= thresholds[i]) { + iconIndex = i + 1; + } + } + iconIndex = Math.min(iconIndex, sortedIcons.length - 1); + + return { + icon: sortedIcons[iconIndex], + }; +} + +/** + * Cell Value 조건 스타일 계산 + */ +function applyCellValue( + value: number, + rule: ConditionalFormatRule +): CellFormatStyle { + if (!rule.cellValue) return {}; + + const { operator, value1, value2, backgroundColor, textColor, bold } = + rule.cellValue; + + let matches = false; + + switch (operator) { + case ">": + matches = value > value1; + break; + case ">=": + matches = value >= value1; + break; + case "<": + matches = value < value1; + break; + case "<=": + matches = value <= value1; + break; + case "=": + matches = value === value1; + break; + case "!=": + matches = value !== value1; + break; + case "between": + matches = value2 !== undefined && value >= value1 && value <= value2; + break; + } + + if (!matches) return {}; + + return { + backgroundColor, + textColor, + fontWeight: bold ? "bold" : undefined, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 조건부 서식 적용 + */ +export function getConditionalStyle( + value: number | null | undefined, + field: string, + rules: ConditionalFormatRule[], + allValues: number[] // 해당 필드의 모든 값 (min/max 계산용) +): CellFormatStyle { + if (value === null || value === undefined || isNaN(value)) { + return {}; + } + + if (!rules || rules.length === 0) { + return {}; + } + + // min/max 계산 + const numericValues = allValues.filter((v) => !isNaN(v)); + const minValue = Math.min(...numericValues); + const maxValue = Math.max(...numericValues); + + let resultStyle: CellFormatStyle = {}; + + // 해당 필드에 적용되는 규칙 필터링 및 적용 + for (const rule of rules) { + // 필드 필터 확인 + if (rule.field && rule.field !== field) { + continue; + } + + let ruleStyle: CellFormatStyle = {}; + + switch (rule.type) { + case "colorScale": + ruleStyle = applyColorScale(value, minValue, maxValue, rule); + break; + case "dataBar": + ruleStyle = applyDataBar(value, minValue, maxValue, rule); + break; + case "iconSet": + ruleStyle = applyIconSet(value, minValue, maxValue, rule); + break; + case "cellValue": + ruleStyle = applyCellValue(value, rule); + break; + } + + // 스타일 병합 (나중 규칙이 우선) + resultStyle = { ...resultStyle, ...ruleStyle }; + } + + return resultStyle; +} + +/** + * 조건부 서식 스타일을 React 스타일 객체로 변환 + */ +export function formatStyleToReact( + style: CellFormatStyle +): React.CSSProperties { + const result: React.CSSProperties = {}; + + if (style.backgroundColor) { + result.backgroundColor = style.backgroundColor; + } + if (style.textColor) { + result.color = style.textColor; + } + if (style.fontWeight) { + result.fontWeight = style.fontWeight as any; + } + + return result; +} + +export default getConditionalStyle; + diff --git a/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts new file mode 100644 index 00000000..6069a3a5 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/exportExcel.ts @@ -0,0 +1,202 @@ +/** + * Excel 내보내기 유틸리티 + * 피벗 테이블 데이터를 Excel 파일로 내보내기 + * xlsx 라이브러리 사용 (브라우저 호환) + */ + +import * as XLSX from "xlsx"; +import { + PivotResult, + PivotFieldConfig, + PivotTotalsConfig, +} from "../types"; +import { pathToKey } from "./pivotEngine"; + +// ==================== 타입 ==================== + +export interface ExportOptions { + fileName?: string; + sheetName?: string; + title?: string; + subtitle?: string; + includeHeaders?: boolean; + includeTotals?: boolean; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터를 Excel로 내보내기 + */ +export async function exportPivotToExcel( + pivotResult: PivotResult, + fields: PivotFieldConfig[], + totals: PivotTotalsConfig, + options: ExportOptions = {} +): Promise { + const { + fileName = "pivot_export", + sheetName = "Pivot", + title, + includeHeaders = true, + includeTotals = true, + } = options; + + // 필드 분류 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + // 데이터 배열 생성 + const data: any[][] = []; + + // 제목 추가 + if (title) { + data.push([title]); + data.push([]); // 빈 행 + } + + // 헤더 행 + if (includeHeaders) { + const headerRow: any[] = [ + rowFields.map((f) => f.caption).join(" / ") || "항목", + ]; + + // 열 헤더 + for (const col of pivotResult.flatColumns) { + headerRow.push(col.caption || "(전체)"); + } + + // 총계 헤더 + if (totals?.showRowGrandTotals && includeTotals) { + headerRow.push("총계"); + } + + data.push(headerRow); + } + + // 데이터 행 + for (const row of pivotResult.flatRows) { + const excelRow: any[] = []; + + // 행 헤더 (들여쓰기 포함) + const indent = " ".repeat(row.level); + excelRow.push(indent + row.caption); + + // 데이터 셀 + for (const col of pivotResult.flatColumns) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = pivotResult.dataMatrix.get(cellKey); + + if (values && values.length > 0) { + excelRow.push(values[0].value); + } else { + excelRow.push(""); + } + } + + // 행 총계 + if (totals?.showRowGrandTotals && includeTotals) { + const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path)); + if (rowTotal && rowTotal.length > 0) { + excelRow.push(rowTotal[0].value); + } else { + excelRow.push(""); + } + } + + data.push(excelRow); + } + + // 열 총계 행 + if (totals?.showColumnGrandTotals && includeTotals) { + const totalRow: any[] = ["총계"]; + + for (const col of pivotResult.flatColumns) { + const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path)); + if (colTotal && colTotal.length > 0) { + totalRow.push(colTotal[0].value); + } else { + totalRow.push(""); + } + } + + // 대총합 + if (totals?.showRowGrandTotals) { + const grandTotal = pivotResult.grandTotals.grand; + if (grandTotal && grandTotal.length > 0) { + totalRow.push(grandTotal[0].value); + } else { + totalRow.push(""); + } + } + + data.push(totalRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(data); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = []; + const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0); + for (let i = 0; i < maxCols; i++) { + colWidths.push({ wch: i === 0 ? 25 : 15 }); + } + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} + +/** + * Drill Down 데이터를 Excel로 내보내기 + */ +export async function exportDrillDownToExcel( + data: any[], + columns: { field: string; caption: string }[], + options: ExportOptions = {} +): Promise { + const { + fileName = "drilldown_export", + sheetName = "Data", + title, + } = options; + + // 데이터 배열 생성 + const sheetData: any[][] = []; + + // 제목 + if (title) { + sheetData.push([title]); + sheetData.push([]); // 빈 행 + } + + // 헤더 + const headerRow = columns.map((col) => col.caption); + sheetData.push(headerRow); + + // 데이터 + for (const row of data) { + const dataRow = columns.map((col) => row[col.field] ?? ""); + sheetData.push(dataRow); + } + + // 워크시트 생성 + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // 컬럼 너비 설정 + const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 })); + worksheet["!cols"] = colWidths; + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, `${fileName}.xlsx`); +} diff --git a/frontend/lib/registry/components/pivot-grid/utils/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts index f832187e..2c0a83d6 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/index.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -1,4 +1,6 @@ export * from "./aggregation"; export * from "./pivotEngine"; +export * from "./exportExcel"; +export * from "./conditionalFormat"; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 18113066..4d3fecfd 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -12,6 +12,7 @@ import { PivotCellValue, DateGroupInterval, AggregationType, + SummaryDisplayMode, } from "../types"; import { aggregate, formatNumber, formatDate } from "./aggregation"; @@ -418,6 +419,185 @@ function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { return leaves; } +// ==================== Summary Display Mode 적용 ==================== + +/** + * Summary Display Mode에 따른 값 변환 + */ +function applyDisplayMode( + value: number, + displayMode: SummaryDisplayMode | undefined, + rowTotal: number, + columnTotal: number, + grandTotal: number, + prevValue: number | null, + runningTotal: number, + format?: PivotFieldConfig["format"] +): { value: number; formattedValue: string } { + if (!displayMode || displayMode === "absoluteValue") { + return { + value, + formattedValue: formatNumber(value, format), + }; + } + + let resultValue: number; + let formatOverride: PivotFieldConfig["format"] | undefined; + + switch (displayMode) { + case "percentOfRowTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfGrandTotal": + resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfRowGrandTotal": + resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "percentOfColumnGrandTotal": + resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + case "runningTotalByRow": + case "runningTotalByColumn": + resultValue = runningTotal; + break; + + case "differenceFromPrevious": + resultValue = prevValue === null ? 0 : value - prevValue; + break; + + case "percentDifferenceFromPrevious": + resultValue = prevValue === null || prevValue === 0 + ? 0 + : ((value - prevValue) / Math.abs(prevValue)) * 100; + formatOverride = { type: "percent", precision: 2, suffix: "%" }; + break; + + default: + resultValue = value; + } + + return { + value: resultValue, + formattedValue: formatNumber(resultValue, formatOverride || format), + }; +} + +/** + * 데이터 매트릭스에 Summary Display Mode 적용 + */ +function applyDisplayModeToMatrix( + matrix: Map, + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][], + rowTotals: Map, + columnTotals: Map, + grandTotals: PivotCellValue[] +): Map { + // displayMode가 있는 데이터 필드가 있는지 확인 + const hasDisplayMode = dataFields.some( + (df) => df.summaryDisplayMode || df.showValuesAs + ); + if (!hasDisplayMode) return matrix; + + const newMatrix = new Map(); + + // 누계를 위한 추적 (행별, 열별) + const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 + const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 + + // 행 순서대로 처리 + for (const row of flatRows) { + // 이전 열 값 추적 (차이 계산용) + const prevColValues: (number | null)[] = dataFields.map(() => null); + + for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { + const colPath = flatColumnLeaves[colIdx]; + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + const values = matrix.get(cellKey); + + if (!values) { + newMatrix.set(cellKey, []); + continue; + } + + const rowKey = pathToKey(row.path); + const colKey = pathToKey(colPath); + + // 총합 가져오기 + const rowTotal = rowTotals.get(rowKey); + const colTotal = columnTotals.get(colKey); + + const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { + const dataField = dataFields[fieldIdx]; + const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; + + if (!displayMode || displayMode === "absoluteValue") { + prevColValues[fieldIdx] = val.value; + return val; + } + + // 누계 계산 + // 행 방향 누계 + if (!rowRunningTotals.has(rowKey)) { + rowRunningTotals.set(rowKey, dataFields.map(() => 0)); + } + const rowRunning = rowRunningTotals.get(rowKey)!; + rowRunning[fieldIdx] += val.value || 0; + + // 열 방향 누계 + if (!colRunningTotals.has(colKey)) { + colRunningTotals.set(colKey, new Map()); + } + const colRunning = colRunningTotals.get(colKey)!; + if (!colRunning.has(fieldIdx)) { + colRunning.set(fieldIdx, 0); + } + colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); + + const result = applyDisplayMode( + val.value || 0, + displayMode, + rowTotal?.[fieldIdx]?.value || 0, + colTotal?.[fieldIdx]?.value || 0, + grandTotals[fieldIdx]?.value || 0, + prevColValues[fieldIdx], + displayMode === "runningTotalByRow" + ? rowRunning[fieldIdx] + : colRunning.get(fieldIdx) || 0, + dataField.format + ); + + prevColValues[fieldIdx] = val.value; + + return { + field: val.field, + value: result.value, + formattedValue: result.formattedValue, + }; + }); + + newMatrix.set(cellKey, newValues); + } + } + + return newMatrix; +} + // ==================== 총합계 계산 ==================== /** @@ -584,7 +764,7 @@ export function processPivotData( const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); // 데이터 매트릭스 생성 - const dataMatrix = buildDataMatrix( + let dataMatrix = buildDataMatrix( filteredData, rowFields, columnFields, @@ -603,6 +783,17 @@ export function processPivotData( flatColumnLeaves ); + // Summary Display Mode 적용 + dataMatrix = applyDisplayModeToMatrix( + dataMatrix, + dataFields, + flatRows, + flatColumnLeaves, + grandTotals.row, + grandTotals.column, + grandTotals.grand + ); + return { rowHeaders, columnHeaders, diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index c3991ae3..9da76559 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -32,6 +32,7 @@ import { DialogFooter, DialogDescription, } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; @@ -135,6 +136,13 @@ export const SplitPanelLayoutComponent: React.FC if (item[underscoreKey] !== undefined) { return item[underscoreKey]; } + + // 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기 + // 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우) + const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`)); + if (matchingKey && item[matchingKey] !== undefined) { + return item[matchingKey]; + } } return undefined; @@ -162,6 +170,11 @@ export const SplitPanelLayoutComponent: React.FC const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); + + // 🆕 추가 탭 관련 상태 + const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭 + const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 캐시 + const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 @@ -603,6 +616,41 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); + // 🆕 간단한 값 포맷팅 함수 (추가 탭용) + const formatValue = useCallback( + ( + value: any, + format?: { + type?: "number" | "currency" | "date" | "text"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }, + ): string => { + if (value === null || value === undefined) return "-"; + + // 날짜 포맷 + if (format?.type === "date" || format?.dateFormat) { + return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); + } + + // 숫자 포맷 + if ( + format?.type === "number" || + format?.type === "currency" || + format?.thousandSeparator || + format?.decimalPlaces !== undefined + ) { + return formatNumberValue(value, format); + } + + return String(value); + }, + [formatDateValue, formatNumberValue], + ); + // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( ( @@ -953,11 +1001,12 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 복합키 조건:", searchConditions); - // 엔티티 조인 API로 데이터 조회 + // 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달) const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, + deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); @@ -1030,12 +1079,137 @@ export const SplitPanelLayoutComponent: React.FC ], ); + // 🆕 추가 탭 데이터 로딩 함수 + const loadTabData = useCallback( + async (tabIndex: number, leftItem: any) => { + const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; + if (!tabConfig || !leftItem || isDesignMode) return; + + const tabTableName = tabConfig.tableName; + if (!tabTableName) return; + + setTabsLoading((prev) => ({ ...prev, [tabIndex]: true })); + try { + // 조인 키 확인 + const keys = tabConfig.relation?.keys; + const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; + const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; + + let resultData: any[] = []; + + if (leftColumn && rightColumn) { + // 조인 조건이 있는 경우 + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const searchConditions: Record = {}; + + if (keys && keys.length > 0) { + // 복합키 + keys.forEach((key) => { + if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { + searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + } + }); + } else { + // 단일키 + const leftValue = leftItem[leftColumn]; + if (leftValue !== undefined) { + searchConditions[rightColumn] = leftValue; + } + } + + console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions); + + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + }); + + resultData = result.data || []; + } else { + // 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭) + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + enableEntityJoin: true, + size: 1000, + }); + resultData = result.data || []; + } + + // 데이터 필터 적용 + const dataFilter = tabConfig.dataFilter; + if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { + resultData = resultData.filter((item: any) => { + return dataFilter.conditions.every((cond: any) => { + const value = item[cond.column]; + const condValue = cond.value; + switch (cond.operator) { + case "equals": + return value === condValue; + case "notEquals": + return value !== condValue; + case "contains": + return String(value).includes(String(condValue)); + default: + return true; + } + }); + }); + } + + // 중복 제거 적용 + const deduplication = tabConfig.deduplication; + if (deduplication?.enabled && deduplication.groupByColumn) { + const groupedMap = new Map(); + resultData.forEach((item) => { + const key = String(item[deduplication.groupByColumn] || ""); + const existing = groupedMap.get(key); + if (!existing) { + groupedMap.set(key, item); + } else { + // keepStrategy에 따라 유지할 항목 결정 + const sortCol = deduplication.sortColumn || "start_date"; + const existingVal = existing[sortCol]; + const newVal = item[sortCol]; + if (deduplication.keepStrategy === "latest" && newVal > existingVal) { + groupedMap.set(key, item); + } else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) { + groupedMap.set(key, item); + } + } + }); + resultData = Array.from(groupedMap.values()); + } + + console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length); + setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); + } catch (error) { + console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); + toast({ + title: "데이터 로드 실패", + description: `탭 데이터를 불러올 수 없습니다.`, + variant: "destructive", + }); + } finally { + setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); + } + }, + [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], + ); + // 좌측 항목 선택 핸들러 const handleLeftItemSelect = useCallback( (item: any) => { setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 - loadRightData(item); + setTabsData({}); // 모든 탭 데이터 초기화 + + // 현재 활성 탭에 따라 데이터 로드 + if (activeTabIndex === 0) { + loadRightData(item); + } else { + loadTabData(activeTabIndex, item); + } // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) const leftTableName = componentConfig.leftPanel?.tableName; @@ -1046,7 +1220,30 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], + [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], + ); + + // 🆕 탭 변경 핸들러 + const handleTabChange = useCallback( + (newTabIndex: number) => { + setActiveTabIndex(newTabIndex); + + // 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드 + if (selectedLeftItem) { + if (newTabIndex === 0) { + // 기본 탭: 우측 패널 데이터가 없으면 로드 + if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { + loadRightData(selectedLeftItem); + } + } else { + // 추가 탭: 해당 탭 데이터가 없으면 로드 + if (!tabsData[newTabIndex]) { + loadTabData(newTabIndex, selectedLeftItem); + } + } + } + }, + [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], ); // 우측 항목 확장/축소 토글 @@ -1442,14 +1639,19 @@ export const SplitPanelLayoutComponent: React.FC // 수정 버튼 핸들러 const handleEditClick = useCallback( - (panel: "left" | "right", item: any) => { + async (panel: "left" | "right", item: any) => { + // 🆕 현재 활성 탭의 설정 가져오기 + const currentTabConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; // 🆕 우측 패널 수정 버튼 설정 확인 - if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { - const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; + if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") { + const modalScreenId = currentTabConfig?.editButton?.modalScreenId; if (modalScreenId) { // 커스텀 모달 화면 열기 - const rightTableName = componentConfig.rightPanel?.tableName || ""; + const rightTableName = currentTabConfig?.tableName || ""; console.log("✅ 수정 모달 열기:", { tableName: rightTableName, @@ -1463,33 +1665,108 @@ export const SplitPanelLayoutComponent: React.FC }); // 🆕 groupByColumns 추출 - const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + const groupByColumns = currentTabConfig?.editButton?.groupByColumns || []; console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { groupByColumns, - editButtonConfig: componentConfig.rightPanel?.editButton, + editButtonConfig: currentTabConfig?.editButton, hasGroupByColumns: groupByColumns.length > 0, }); + // 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출) + let allRelatedRecords = [item]; // 기본값: 현재 아이템만 + + if (groupByColumns.length > 0) { + // groupByColumns 값으로 검색 조건 생성 + const matchConditions: Record = {}; + groupByColumns.forEach((col: string) => { + if (item[col] !== undefined && item[col] !== null) { + matchConditions[col] = item[col]; + } + }); + + console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", { + 테이블: rightTableName, + 조건: matchConditions, + }); + + if (Object.keys(matchConditions).length > 0) { + // 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출) + try { + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + + // 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확) + const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({ + id: `exact-${key}`, + columnName: key, + operator: "equals", + value: value, + valueType: "text", + })); + + console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters); + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + // search 대신 dataFilter 사용 (정확 매칭) + dataFilter: { + enabled: true, + matchType: "all", + filters: exactMatchFilters, + }, + enableEntityJoin: true, + size: 1000, + // 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기) + deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" }, + }); + + // 🔍 디버깅: API 응답 구조 확인 + console.log("🔍 [SplitPanel] API 응답 전체:", result); + console.log("🔍 [SplitPanel] result.data:", result.data); + console.log("🔍 [SplitPanel] result 타입:", typeof result); + + // result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라) + const dataArray = Array.isArray(result) ? result : (result.data || []); + + if (dataArray.length > 0) { + allRelatedRecords = dataArray; + console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", { + 조건: matchConditions, + 결과수: allRelatedRecords.length, + 레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })), + }); + } else { + console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용"); + } + } catch (error) { + console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error); + allRelatedRecords = [item]; + } + } else { + console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용"); + } + } + // 🔧 수정: URL 파라미터 대신 editData로 직접 전달 // 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨 window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, - editData: item, // 전체 데이터를 직접 전달 - ...(groupByColumns.length > 0 && { - urlParams: { + editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열) + urlParams: { + mode: "edit", // 🆕 수정 모드 표시 + ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), - }, - }), + }), + }, }, }), ); console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { screenId: modalScreenId, - editData: item, + editData: allRelatedRecords, + recordCount: allRelatedRecords.length, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); @@ -1503,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC setEditModalFormData({ ...item }); setShowEditModal(true); }, - [componentConfig], + [componentConfig, activeTabIndex], ); // 수정 모달 저장 @@ -1603,13 +1880,19 @@ export const SplitPanelLayoutComponent: React.FC // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { + // 🆕 현재 활성 탭의 설정 가져오기 + const currentTabConfig = + activeTabIndex === 0 + ? componentConfig.rightPanel + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + // 우측 패널 삭제 시 중계 테이블 확인 let tableName = - deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; + deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { - tableName = componentConfig.rightPanel.addConfig.targetTable; + if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) { + tableName = currentTabConfig.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } @@ -1739,7 +2022,12 @@ export const SplitPanelLayoutComponent: React.FC setRightData(null); } } else if (deleteModalPanel === "right" && selectedLeftItem) { - loadRightData(selectedLeftItem); + // 🆕 현재 활성 탭에 따라 새로고침 + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem); + } else { + loadTabData(activeTabIndex, selectedLeftItem); + } } } else { toast({ @@ -1763,7 +2051,7 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); + }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -2584,6 +2872,34 @@ export const SplitPanelLayoutComponent: React.FC className="flex flex-shrink-0 flex-col" > + {/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */} + {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && ( +
+ handleTabChange(Number(value))} + className="w-full" + > + + + {componentConfig.rightPanel?.title || "기본"} + + {componentConfig.rightPanel?.additionalTabs?.map((tab, index) => ( + + {tab.label || `탭 ${index + 1}`} + + ))} + + +
+ )} >
- {componentConfig.rightPanel?.title || "우측 패널"} + {activeTabIndex === 0 + ? componentConfig.rightPanel?.title || "우측 패널" + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title || + componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label || + "우측 패널"} {!isDesignMode && (
- {componentConfig.rightPanel?.showAdd && ( - - )} + {/* 현재 활성 탭에 따른 추가 버튼 */} + {activeTabIndex === 0 + ? componentConfig.rightPanel?.showAdd && ( + + ) + : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && ( + + )} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
)} @@ -2625,16 +2953,228 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 우측 데이터 */} - {isLoadingRight ? ( - // 로딩 중 -
-
- -

데이터를 불러오는 중...

-
-
- ) : rightData ? ( + {/* 🆕 추가 탭 데이터 렌더링 */} + {activeTabIndex > 0 ? ( + // 추가 탭 컨텐츠 + (() => { + const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + const currentTabData = tabsData[activeTabIndex] || []; + const isTabLoading = tabsLoading[activeTabIndex]; + + if (isTabLoading) { + return ( +
+
+ +

데이터를 불러오는 중...

+
+
+ ); + } + + if (!selectedLeftItem) { + return ( +
+

좌측에서 항목을 선택하세요

+
+ ); + } + + if (currentTabData.length === 0) { + return ( +
+

데이터가 없습니다

+
+ ); + } + + // 탭 데이터 렌더링 (목록/테이블 모드) + const isTableMode = currentTabConfig?.displayMode === "table"; + + if (isTableMode) { + // 테이블 모드 + const displayColumns = currentTabConfig?.columns || []; + const columnsToShow = + displayColumns.length > 0 + ? displayColumns.map((col) => ({ + ...col, + label: col.label || col.name, + })) + : Object.keys(currentTabData[0] || {}) + .filter(shouldShowField) + .slice(0, 8) + .map((key) => ({ name: key, label: key })); + + return ( +
+ + + + {columnsToShow.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + + + {currentTabData.map((item: any, idx: number) => ( + + {columnsToShow.map((col: any) => ( + + ))} + {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( + + )} + + ))} + +
+ {col.label} + 작업
+ {formatValue(item[col.name], col.format)} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
+ ); + } else { + // 목록 (카드) 모드 + const displayColumns = currentTabConfig?.columns || []; + const summaryCount = currentTabConfig?.summaryColumnCount ?? 3; + const showLabel = currentTabConfig?.summaryShowLabel ?? true; + + return ( +
+ {currentTabData.map((item: any, idx: number) => { + const itemId = item.id || idx; + const isExpanded = expandedRightItems.has(itemId); + + // 표시할 컬럼 결정 + const columnsToShow = + displayColumns.length > 0 + ? displayColumns + : Object.keys(item) + .filter(shouldShowField) + .slice(0, 8) + .map((key) => ({ name: key, label: key })); + + const summaryColumns = columnsToShow.slice(0, summaryCount); + const detailColumns = columnsToShow.slice(summaryCount); + + return ( +
+
toggleRightItemExpansion(itemId)} + > +
+
+ {summaryColumns.map((col: any) => ( +
+ {showLabel && ( + {col.label}: + )} + + {formatValue(item[col.name], col.format)} + +
+ ))} +
+
+
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} + {detailColumns.length > 0 && ( + isExpanded ? ( + + ) : ( + + ) + )} +
+
+ {isExpanded && detailColumns.length > 0 && ( +
+
+ {detailColumns.map((col: any) => ( +
+ {col.label}: + {formatValue(item[col.name], col.format)} +
+ ))} +
+
+ )} +
+ ); + })} +
+ ); + } + })() + ) : ( + /* 기본 탭 (우측 패널) 데이터 */ + <> + {isLoadingRight ? ( + // 로딩 중 +
+
+ +

데이터를 불러오는 중...

+
+
+ ) : rightData ? ( // 실제 데이터 표시 Array.isArray(rightData) ? ( // 조인 모드: 여러 데이터를 테이블/리스트로 표시 @@ -3077,6 +3617,8 @@ export const SplitPanelLayoutComponent: React.FC
)} + + )}
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 832107c4..9810388f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -11,10 +11,11 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // Accordion 제거 - 단순 섹션으로 변경 -import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react"; +import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; -import { SplitPanelLayoutConfig } from "./types"; +import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { tableTypeApi } from "@/lib/api/screen"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; @@ -189,6 +190,848 @@ const ScreenSelector: React.FC<{ ); }; +/** + * 추가 탭 설정 패널 (우측 패널과 동일한 구조) + */ +interface AdditionalTabConfigPanelProps { + tab: AdditionalTabConfig; + tabIndex: number; + config: SplitPanelLayoutConfig; + updateRightPanel: (updates: Partial) => void; + availableRightTables: TableInfo[]; + leftTableColumns: ColumnInfo[]; + menuObjid?: number; + // 공유 컬럼 로드 상태 + loadedTableColumns: Record; + loadTableColumns: (tableName: string) => Promise; + loadingColumns: Record; +} + +const AdditionalTabConfigPanel: React.FC = ({ + tab, + tabIndex, + config, + updateRightPanel, + availableRightTables, + leftTableColumns, + menuObjid, + loadedTableColumns, + loadTableColumns, + loadingColumns, +}) => { + // 탭 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) { + loadTableColumns(tab.tableName); + } + }, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]); + + // 현재 탭의 컬럼 목록 + const tabColumns = useMemo(() => { + return tab.tableName ? loadedTableColumns[tab.tableName] || [] : []; + }, [tab.tableName, loadedTableColumns]); + + // 로딩 상태 + const loadingTabColumns = tab.tableName ? loadingColumns[tab.tableName] || false : false; + + // 탭 업데이트 헬퍼 + const updateTab = (updates: Partial) => { + const newTabs = [...(config.rightPanel?.additionalTabs || [])]; + newTabs[tabIndex] = { ...tab, ...updates }; + updateRightPanel({ additionalTabs: newTabs }); + }; + + return ( + + +
+ + + {tab.label || `탭 ${tabIndex + 1}`} + + {tab.tableName && ( + ({tab.tableName}) + )} +
+
+ +
+ {/* ===== 1. 기본 정보 ===== */} +
+ +
+
+ + updateTab({ label: e.target.value })} + placeholder="탭 이름" + className="h-8 text-xs" + /> +
+
+ + updateTab({ title: e.target.value })} + placeholder="패널 제목" + className="h-8 text-xs" + /> +
+
+
+ + updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })} + placeholder="48" + className="h-8 w-24 text-xs" + /> +
+
+ + {/* ===== 2. 테이블 선택 ===== */} +
+ +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {availableRightTables.map((table) => ( + updateTab({ tableName: table.tableName, columns: [] })} + > + + {table.displayName || table.tableName} + + ))} + + + + +
+
+ + {/* ===== 3. 표시 모드 ===== */} +
+ +
+ + +
+ + {/* 요약 설정 (목록 모드) */} + {tab.displayMode === "list" && ( +
+
+ + updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })} + min={1} + max={10} + className="h-8 text-xs" + /> +
+
+ updateTab({ summaryShowLabel: !!checked })} + /> + +
+
+ )} +
+ + {/* ===== 4. 컬럼 매핑 (조인 키) ===== */} +
+ +

+ 좌측 패널 선택 시 관련 데이터만 표시합니다 +

+
+
+ + +
+
+ + +
+
+
+ + {/* ===== 5. 기능 버튼 ===== */} +
+ +
+
+ updateTab({ showSearch: !!checked })} + /> + +
+
+ updateTab({ showAdd: !!checked })} + /> + +
+
+ updateTab({ showEdit: !!checked })} + /> + +
+
+ updateTab({ showDelete: !!checked })} + /> + +
+
+
+ + {/* ===== 6. 표시 컬럼 설정 ===== */} +
+
+ + +
+

+ 표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다. +

+ + {/* 테이블 미선택 상태 */} + {!tab.tableName && ( +
+

먼저 테이블을 선택하세요

+
+ )} + + {/* 테이블 선택됨 - 컬럼 목록 */} + {tab.tableName && ( +
+ {/* 로딩 상태 */} + {loadingTabColumns && ( +
+

컬럼을 불러오는 중...

+
+ )} + + {/* 설정된 컬럼이 없을 때 */} + {!loadingTabColumns && (tab.columns || []).length === 0 && ( +
+

설정된 컬럼이 없습니다

+

컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다

+
+ )} + + {/* 설정된 컬럼 목록 */} + {!loadingTabColumns && (tab.columns || []).length > 0 && ( + (tab.columns || []).map((col, colIndex) => ( +
+ {/* 상단: 순서 변경 + 삭제 버튼 */} +
+
+ + + #{colIndex + 1} +
+ +
+ + {/* 컬럼 선택 */} +
+ + +
+ + {/* 라벨 + 너비 */} +
+
+ + { + const newColumns = [...(tab.columns || [])]; + newColumns[colIndex] = { ...col, label: e.target.value }; + updateTab({ columns: newColumns }); + }} + placeholder="표시 라벨" + className="h-8 text-xs" + /> +
+
+ + { + const newColumns = [...(tab.columns || [])]; + newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 }; + updateTab({ columns: newColumns }); + }} + placeholder="100" + className="h-8 text-xs" + /> +
+
+
+ )) + )} +
+ )} +
+ + {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} + {tab.showAdd && ( +
+
+ + +
+ +
+ {(tab.addModalColumns || []).length === 0 ? ( +
+

추가 모달에 표시할 컬럼을 설정하세요

+
+ ) : ( + (tab.addModalColumns || []).map((col, colIndex) => ( +
+ + { + const newColumns = [...(tab.addModalColumns || [])]; + newColumns[colIndex] = { ...col, label: e.target.value }; + updateTab({ addModalColumns: newColumns }); + }} + placeholder="라벨" + className="h-8 w-24 text-xs" + /> +
+ { + const newColumns = [...(tab.addModalColumns || [])]; + newColumns[colIndex] = { ...col, required: !!checked }; + updateTab({ addModalColumns: newColumns }); + }} + /> + 필수 +
+ +
+ )) + )} +
+
+ )} + + {/* ===== 8. 데이터 필터링 ===== */} +
+ + updateTab({ dataFilter })} + menuObjid={menuObjid} + /> +
+ + {/* ===== 9. 중복 데이터 제거 ===== */} +
+
+ + { + if (checked) { + updateTab({ + deduplication: { + enabled: true, + groupByColumn: "", + keepStrategy: "latest", + sortColumn: "start_date", + }, + }); + } else { + updateTab({ deduplication: undefined }); + } + }} + /> +
+ {tab.deduplication?.enabled && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* ===== 10. 수정 버튼 설정 ===== */} + {tab.showEdit && ( +
+ +
+
+ + +
+ + {tab.editButton?.mode === "modal" && ( +
+ + { + updateTab({ + editButton: { ...tab.editButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} + +
+
+ + { + updateTab({ + editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="수정" + className="h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 그룹핑 기준 컬럼 */} +
+ +

수정 시 같은 값을 가진 레코드를 함께 불러옵니다

+
+ {tabColumns.map((col) => ( +
+ { + const current = tab.editButton?.groupByColumns || []; + const newColumns = checked + ? [...current, col.columnName] + : current.filter((c) => c !== col.columnName); + updateTab({ + editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns }, + }); + }} + /> + +
+ ))} +
+
+
+
+ )} + + {/* ===== 11. 삭제 버튼 설정 ===== */} + {tab.showDelete && ( +
+ +
+
+
+ + { + updateTab({ + deleteButton: { ...tab.deleteButton, enabled: true, buttonLabel: e.target.value || undefined }, + }); + }} + placeholder="삭제" + className="h-7 text-xs" + /> +
+
+ + +
+
+
+ + { + updateTab({ + deleteButton: { ...tab.deleteButton, enabled: true, confirmMessage: e.target.value || undefined }, + }); + }} + placeholder="정말 삭제하시겠습니까?" + className="h-7 text-xs" + /> +
+
+
+ )} + + {/* ===== 탭 삭제 버튼 ===== */} +
+ +
+
+
+
+ ); +}; + /** * SplitPanelLayout 설정 패널 */ @@ -2854,6 +3697,72 @@ export const SplitPanelLayoutConfigPanel: React.FC + {/* ======================================== */} + {/* 추가 탭 설정 (우측 패널과 동일한 구조) */} + {/* ======================================== */} +
+
+
+

추가 탭

+

+ 우측 패널에 다른 테이블 데이터를 탭으로 추가합니다 +

+
+ +
+ + {/* 추가된 탭 목록 */} + {(config.rightPanel?.additionalTabs?.length || 0) > 0 ? ( + + {config.rightPanel?.additionalTabs?.map((tab, tabIndex) => ( + + ))} + + ) : ( +
+

+ 추가된 탭이 없습니다. [탭 추가] 버튼을 클릭하여 새 탭을 추가하세요. +

+
+ )} +
+ {/* 레이아웃 설정 */}
@@ -2886,3 +3795,4 @@ export const SplitPanelLayoutConfigPanel: React.FC ); }; + diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 0e9d6db9..17edc100 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -4,6 +4,105 @@ import { DataFilterConfig } from "@/types/screen-management"; +/** + * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) + */ +export interface AdditionalTabConfig { + // 탭 고유 정보 + tabId: string; + label: string; + + // === 우측 패널과 동일한 설정 === + title: string; + panelHeaderHeight?: number; + tableName?: string; + dataSource?: string; + displayMode?: "list" | "table"; + showSearch?: boolean; + showAdd?: boolean; + showEdit?: boolean; + showDelete?: boolean; + summaryColumnCount?: number; + summaryShowLabel?: boolean; + + columns?: Array<{ + name: string; + label: string; + width?: number; + sortable?: boolean; + align?: "left" | "center" | "right"; + bold?: boolean; + format?: { + type?: "number" | "currency" | "date" | "text"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }; + }>; + + addModalColumns?: Array<{ + name: string; + label: string; + required?: boolean; + }>; + + relation?: { + type?: "join" | "detail"; + leftColumn?: string; + rightColumn?: string; + foreignKey?: string; + keys?: Array<{ + leftColumn: string; + rightColumn: string; + }>; + }; + + addConfig?: { + targetTable?: string; + autoFillColumns?: Record; + leftPanelColumn?: string; + targetColumn?: string; + }; + + tableConfig?: { + showCheckbox?: boolean; + showRowNumber?: boolean; + rowHeight?: number; + headerHeight?: number; + striped?: boolean; + bordered?: boolean; + hoverable?: boolean; + stickyHeader?: boolean; + }; + + dataFilter?: DataFilterConfig; + + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; + + editButton?: { + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; + buttonVariant?: "default" | "outline" | "ghost"; + groupByColumns?: string[]; + }; + + deleteButton?: { + enabled: boolean; + buttonLabel?: string; + buttonVariant?: "default" | "outline" | "ghost" | "destructive"; + confirmMessage?: string; + }; +} + export interface SplitPanelLayoutConfig { // 좌측 패널 설정 leftPanel: { @@ -165,6 +264,9 @@ export interface SplitPanelLayoutConfig { buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost") confirmMessage?: string; // 삭제 확인 메시지 }; + + // 🆕 추가 탭 설정 (멀티 테이블 탭) + additionalTabs?: AdditionalTabConfig[]; }; // 레이아웃 설정 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 8ff83b6f..cd93a3b5 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -87,6 +87,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateConfig("leftPanel.showEditButton", checked)} />
+ {/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */} + {config.leftPanel?.showEditButton && ( +
+ + updateConfig("leftPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택" + open={leftEditModalOpen} + onOpenChange={setLeftEditModalOpen} + /> +
+ )}
updateConfig("rightPanel.showEditButton", checked)} />
+ {/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */} + {config.rightPanel?.showEditButton && ( +
+ + updateConfig("rightPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택" + open={rightEditModalOpen} + onOpenChange={setRightEditModalOpen} + /> +
+ )}
| undefined = undefined; - // ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기 + // 🆕 마스터-디테일 구조 확인 및 처리 + if (context.screenId) { + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); + + if (relationResponse.success && relationResponse.data) { + // 마스터-디테일 구조인 경우 전용 API 사용 + console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data); + + const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData( + context.screenId, + context.filterConditions + ); + + if (downloadResponse.success && downloadResponse.data) { + dataToExport = downloadResponse.data.data; + visibleColumns = downloadResponse.data.columns; + + // 헤더와 컬럼 매핑 + columnLabels = {}; + downloadResponse.data.columns.forEach((col: string, index: number) => { + columnLabels![col] = downloadResponse.data.headers[index] || col; + }); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`); + } else { + toast.error("마스터-디테일 데이터 조회에 실패했습니다."); + return false; + } + + // 마스터-디테일 데이터 변환 및 다운로드 + if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { + dataToExport = dataToExport.map((row: any) => { + const filteredRow: Record = {}; + visibleColumns!.forEach((columnName: string) => { + const label = columnLabels?.[columnName] || columnName; + filteredRow[label] = row[columnName]; + }); + return filteredRow; + }); + } + + // 파일명 생성 + let defaultFileName = relationResponse.data.masterTable || "데이터"; + if (typeof window !== "undefined") { + const menuName = localStorage.getItem("currentMenuName"); + if (menuName) defaultFileName = menuName; + } + const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; + const sheetName = config.excelSheetName || "Sheet1"; + + await exportToExcel(dataToExport, fileName, sheetName, true); + toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`); + return true; + } + } + + // ✅ 기존 로직: 단일 테이블 처리 if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); @@ -4574,8 +4636,7 @@ export class ButtonActionExecutor { const includeHeaders = config.excelIncludeHeaders !== false; // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 - let visibleColumns: string[] | undefined = undefined; - let columnLabels: Record | undefined = undefined; + // visibleColumns, columnLabels는 함수 상단에서 이미 선언됨 try { // 화면 레이아웃 데이터 가져오기 (별도 API 사용) @@ -4776,8 +4837,53 @@ export class ButtonActionExecutor { context, userId: context.userId, tableName: context.tableName, + screenId: context.screenId, }); + // 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지) + let isMasterDetail = false; + let masterDetailRelation: any = null; + let masterDetailExcelConfig: any = undefined; + + // 화면 레이아웃에서 분할 패널 자동 감지 + if (context.screenId) { + const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); + const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); + + if (relationResponse.success && relationResponse.data) { + isMasterDetail = true; + masterDetailRelation = relationResponse.data; + + // 버튼 설정에서 채번 규칙 등 추가 설정 가져오기 + if (config.masterDetailExcel) { + masterDetailExcelConfig = { + ...config.masterDetailExcel, + // 분할 패널에서 감지한 테이블 정보로 덮어쓰기 + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + detailFkColumn: relationResponse.data.detailFkColumn, + }; + } else { + // 버튼 설정이 없으면 분할 패널 정보만 사용 + masterDetailExcelConfig = { + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + detailFkColumn: relationResponse.data.detailFkColumn, + simpleMode: true, // 기본값으로 간단 모드 사용 + }; + } + + console.log("📊 마스터-디테일 구조 자동 감지:", { + masterTable: relationResponse.data.masterTable, + detailTable: relationResponse.data.detailTable, + masterKeyColumn: relationResponse.data.masterKeyColumn, + numberingRuleId: masterDetailExcelConfig?.numberingRuleId, + }); + } + } + // 동적 import로 모달 컴포넌트 로드 const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { createRoot } = await import("react-dom/client"); @@ -4820,6 +4926,11 @@ export class ButtonActionExecutor { uploadMode: config.excelUploadMode || "insert", keyColumn: config.excelKeyColumn, userId: context.userId, + // 🆕 마스터-디테일 관련 props + screenId: context.screenId, + isMasterDetail, + masterDetailRelation, + masterDetailExcelConfig, onSuccess: () => { // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 context.onRefresh?.(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4f5a1fd..f0ef7c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,6 +59,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", @@ -542,6 +543,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -6963,6 +7005,59 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7158,6 +7253,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7225,7 +7326,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -7266,6 +7366,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -7275,6 +7384,68 @@ "node": "*" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -7285,7 +7456,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7329,6 +7499,32 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -7501,6 +7697,18 @@ "node": ">=0.8" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7665,11 +7873,39 @@ "node": ">= 10" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concaveman": { @@ -7731,6 +7967,33 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -8323,6 +8586,12 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8605,6 +8874,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -8639,6 +8917,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -9338,6 +9625,61 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/exit-on-epipe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", @@ -9377,6 +9719,19 @@ "node": ">=8.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9586,6 +9941,34 @@ "node": ">=0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9773,6 +10156,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9847,7 +10251,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10121,6 +10524,17 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10843,6 +11257,18 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -11142,6 +11568,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11158,6 +11590,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11165,6 +11664,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11386,7 +11897,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11399,12 +11909,23 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11557,6 +12078,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -11707,6 +12237,15 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -12829,6 +13368,36 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -13086,6 +13655,19 @@ "node": ">= 0.8.15" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/robust-predicates": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", @@ -13891,6 +14473,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -14032,6 +14644,15 @@ "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14107,6 +14728,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/troika-three-text": { "version": "0.52.4", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", @@ -14402,6 +15032,24 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14754,6 +15402,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -14974,6 +15628,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9cf087c..1dc6c6fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,7 @@ "date-fns": "^4.1.0", "docx": "^9.5.1", "docx-preview": "^0.3.6", + "exceljs": "^4.4.0", "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 6eb1bb1c..9c7d5c5e 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -344,6 +344,11 @@ export interface InsertActionNodeData { targetField: string; targetFieldLabel?: string; staticValue?: any; + // 🔥 값 생성 유형 추가 + valueType?: "source" | "static" | "autoGenerate"; // 소스 필드 / 고정값 / 자동 생성 + // 자동 생성 옵션 (valueType === "autoGenerate" 일 때) + numberingRuleId?: string; // 채번 규칙 ID + numberingRuleName?: string; // 채번 규칙명 (표시용) }>; options: { batchSize?: number;