diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png new file mode 100644 index 00000000..0fad6fa6 Binary files /dev/null and b/.playwright-mcp/pivotgrid-demo.png differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png new file mode 100644 index 00000000..79041f47 Binary files /dev/null and b/.playwright-mcp/pivotgrid-table.png differ 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/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts index 29abfa8e..74d9e893 100644 --- a/backend-node/src/controllers/codeMergeController.ts +++ b/backend-node/src/controllers/codeMergeController.ts @@ -282,3 +282,175 @@ export async function previewCodeMerge( } } +/** + * 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경 + * 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경 + */ +export async function mergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 시작", { + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_by_value($1, $2, $3)", + [oldValue, newValue, companyCode] + ); + + // 결과 처리 + const affectedData = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = affectedData.reduce( + (sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0), + 0 + ); + + logger.info("값 기반 코드 병합 완료", { + oldValue, + newValue, + affectedTablesCount: affectedData.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + oldValue, + newValue, + affectedData: affectedData.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + rowsUpdated: parseInt(row.out_rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 실패:", { + error: error.message, + stack: error.stack, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 값 기반 코드 병합 미리보기 + * 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회 + */ +export async function previewMergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM preview_merge_code_by_value($1, $2)", + [oldValue, companyCode] + ); + + const preview = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = preview.reduce( + (sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0), + 0 + ); + + logger.info("값 기반 코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + oldValue, + preview: preview.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + affectedRows: parseInt(row.out_affected_rows), + })), + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + 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/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 031a1506..ab7114a5 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq const companyCode = req.user!.companyCode; const { ruleId } = req.params; + logger.info("코드 할당 요청", { ruleId, companyCode }); + try { const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { - logger.error("코드 할당 실패", { error: error.message }); + logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 9b3d81a2..65cd5f4c 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2185,3 +2185,67 @@ export async function multiTableSave( } } +/** + * 두 테이블 간의 엔티티 관계 자동 감지 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +export async function getTableEntityRelations( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { leftTable, rightTable } = req.query; + + logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + + if (!leftTable || !rightTable) { + const response: ApiResponse = { + success: false, + message: "leftTable과 rightTable 파라미터가 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const relations = await tableManagementService.detectTableEntityRelations( + String(leftTable), + String(rightTable) + ); + + logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + + const response: ApiResponse = { + success: true, + message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, + data: { + leftTable: String(leftTable), + rightTable: String(rightTable), + relations, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", + error: { + code: "ENTITY_RELATIONS_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts index 78cbd3e1..2cb41923 100644 --- a/backend-node/src/routes/codeMergeRoutes.ts +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -3,6 +3,8 @@ import { mergeCodeAllTables, getTablesWithColumn, previewCodeMerge, + mergeCodeByValue, + previewMergeCodeByValue, } from "../controllers/codeMergeController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -13,7 +15,7 @@ router.use(authenticateToken); /** * POST /api/code-merge/merge-all-tables - * 코드 병합 실행 (모든 관련 테이블에 적용) + * 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만) * Body: { columnName, oldValue, newValue } */ router.post("/merge-all-tables", mergeCodeAllTables); @@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn); /** * POST /api/code-merge/preview - * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * 코드 병합 미리보기 (같은 컬럼명 기준) * Body: { columnName, oldValue } */ router.post("/preview", previewCodeMerge); +/** + * POST /api/code-merge/merge-by-value + * 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경) + * Body: { oldValue, newValue } + */ +router.post("/merge-by-value", mergeCodeByValue); + +/** + * POST /api/code-merge/preview-by-value + * 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색) + * Body: { oldValue } + */ +router.post("/preview-by-value", previewMergeCodeByValue); + export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f87aa5d6..f9d88d92 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -698,6 +698,7 @@ router.post( try { const { tableName } = req.params; const filterConditions = req.body; + const userCompany = req.user?.companyCode; if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { return res.status(400).json({ @@ -706,11 +707,12 @@ router.post( }); } - console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany }); const result = await dataService.deleteGroupRecords( tableName, - filterConditions + filterConditions, + userCompany // 회사 코드 전달 ); if (!result.success) { diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d0716d59..fa7832ee 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -38,6 +39,15 @@ router.use(authenticateToken); */ router.get("/tables", getTableList); +/** + * 두 테이블 간 엔티티 관계 조회 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +router.get("/tables/entity-relations", getTableEntityRelations); + /** * 테이블 컬럼 정보 조회 * GET /api/table-management/tables/:tableName/columns diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index a1a494f2..75c57673 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1189,6 +1189,13 @@ class DataService { [tableName] ); + console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, { + pkColumns: pkResult.map((r) => r.attname), + pkCount: pkResult.length, + inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id, + inputIdType: typeof id, + }); + let whereClauses: string[] = []; let params: any[] = []; @@ -1216,17 +1223,31 @@ class DataService { params.push(typeof id === "object" ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); const result = await query(queryText, params); + // 삭제된 행이 없으면 실패 처리 + if (result.length === 0) { + console.warn( + `⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`, + { whereClauses, params } + ); + return { + success: false, + message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + console.log( `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` ); return { success: true, + data: result[0], // 삭제된 레코드 정보 반환 }; } catch (error) { console.error(`레코드 삭제 오류 (${tableName}):`, error); @@ -1240,10 +1261,14 @@ class DataService { /** * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 + * @param userCompany 사용자 회사 코드 (멀티테넌시 필터링) */ async deleteGroupRecords( tableName: string, - filterConditions: Record + filterConditions: Record, + userCompany?: string ): Promise> { try { const validation = await this.validateTableAccess(tableName); @@ -1255,6 +1280,7 @@ class DataService { const whereValues: any[] = []; let paramIndex = 1; + // 사용자 필터 조건 추가 for (const [key, value] of Object.entries(filterConditions)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); @@ -1269,10 +1295,24 @@ class DataService { }; } + // 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외) + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode && userCompany && userCompany !== "*") { + whereConditions.push(`"company_code" = $${paramIndex}`); + whereValues.push(userCompany); + paramIndex++; + console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`); + } + const whereClause = whereConditions.join(" AND "); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; - console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { + tableName, + conditions: filterConditions, + userCompany, + whereClause, + }); const result = await pool.query(deleteQuery, whereValues); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 98db1eee..6ae8f696 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1306,6 +1306,41 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원) + // 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함 + if (Array.isArray(value) && value.length > 0) { + // 배열의 각 값에 대해 OR 조건으로 검색 + // 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로 + // 각 값을 LIKE 또는 = 조건으로 처리 + const conditions: string[] = []; + const values: any[] = []; + + value.forEach((v: any, idx: number) => { + const safeValue = String(v).trim(); + // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 + // 예: "2,3" 컬럼에서 "2"를 찾으려면: + // - 정확히 "2" + // - "2," 로 시작 + // - ",2" 로 끝남 + // - ",2," 중간에 포함 + const paramBase = paramIndex + (idx * 4); + conditions.push(`( + ${columnName}::text = $${paramBase} OR + ${columnName}::text LIKE $${paramBase + 1} OR + ${columnName}::text LIKE $${paramBase + 2} OR + ${columnName}::text LIKE $${paramBase + 3} + )`); + values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + }); + + logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + return { + whereClause: `(${conditions.join(" OR ")})`, + values, + paramCount: values.length, + }; + } + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo( @@ -3734,6 +3769,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") { @@ -3742,6 +3786,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, @@ -4630,4 +4681,101 @@ export class TableManagementService { return false; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다. + * + * @param leftTable 좌측 테이블명 + * @param rightTable 우측 테이블명 + * @returns 감지된 엔티티 관계 배열 + */ + async detectTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise> { + try { + logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); + + const relations: Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> = []; + + // 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code + const rightToLeftRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [rightTable, leftTable] + ); + + for (const rel of rightToLeftRels) { + relations.push({ + leftColumn: rel.reference_column, + rightColumn: rel.column_name, + direction: "right_to_left", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + // 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: left_table의 item_id -> right_table(item_info)의 item_number + const leftToRightRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [leftTable, rightTable] + ); + + for (const rel of leftToRightRels) { + relations.push({ + leftColumn: rel.column_name, + rightColumn: rel.reference_column, + direction: "left_to_right", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); + relations.forEach((rel, idx) => { + logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + }); + + return relations; + } catch (error) { + logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + return []; + } + } } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index f41c62af..f7926f43 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -174,8 +174,16 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) if (editData) { console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); - setFormData(editData); - setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + + // 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리 + if (Array.isArray(editData)) { + console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`); + setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리) + setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장 + } else { + setFormData(editData); + setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + } } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 16eca3cd..b30bc1f4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -4,7 +4,7 @@ * DELETE 액션 노드 속성 편집 */ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps { data: DeleteActionNodeData; } +// 소스 필드 타입 +interface SourceField { + name: string; + label?: string; +} + const OPERATORS = [ { value: "EQUALS", label: "=" }, { value: "NOT_EQUALS", label: "≠" }, @@ -34,7 +40,7 @@ const OPERATORS = [ ] as const; export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { - const { updateNode, getExternalConnectionsCache } = useFlowEditorStore(); + const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore(); // 🔥 타겟 타입 상태 const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); @@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP const [targetTable, setTargetTable] = useState(data.targetTable); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); + // 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState([]); + const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState([]); + // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); @@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP // whereConditions 변경 시 fieldOpenState 초기화 useEffect(() => { setFieldOpenState(new Array(whereConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(whereConditions.length).fill(false)); }, [whereConditions.length]); + // 🆕 소스 필드 로딩 (연결된 입력 노드에서) + const loadSourceFields = useCallback(async () => { + // 현재 노드로 연결된 엣지 찾기 + const incomingEdges = edges.filter((e) => e.target === nodeId); + console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges); + + if (incomingEdges.length === 0) { + console.log("⚠️ 연결된 소스 노드가 없습니다"); + setSourceFields([]); + return; + } + + const fields: SourceField[] = []; + const processedFields = new Set(); + + for (const edge of incomingEdges) { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) continue; + + console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data); + + // 소스 노드 타입에 따라 필드 추출 + if (sourceNode.type === "trigger" && sourceNode.data.tableName) { + // 트리거 노드: 테이블 컬럼 조회 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("트리거 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) { + // 테이블 소스 노드 + try { + const columns = await tableTypeApi.getColumns(sourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("테이블 소스 노드 컬럼 로딩 실패:", error); + } + } else if (sourceNode.type === "condition") { + // 조건 노드: 연결된 이전 노드에서 필드 가져오기 + const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id); + for (const condEdge of conditionIncomingEdges) { + const condSourceNode = nodes.find((n) => n.id === condEdge.source); + if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) { + try { + const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName); + if (columns && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (!processedFields.has(colName)) { + processedFields.add(colName); + fields.push({ + name: colName, + label: col.columnLabel || col.column_label || colName, + }); + } + }); + } + } catch (error) { + console.error("조건 노드 소스 컬럼 로딩 실패:", error); + } + } + } + } + } + + console.log("✅ DELETE 노드 소스 필드:", fields); + setSourceFields(fields); + }, [nodeId, nodes, edges]); + + // 소스 필드 로딩 + useEffect(() => { + loadSourceFields(); + }, [loadSourceFields]); + const loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); @@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP field: "", operator: "EQUALS", value: "", + sourceField: undefined, + staticValue: undefined, }, ]; setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); setFieldOpenState(new Array(newConditions.length).fill(false)); + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleConditionChange = (index: number, field: string, value: any) => { const newConditions = [...whereConditions]; newConditions[index] = { ...newConditions[index], [field]: value }; setWhereConditions(newConditions); + + // 자동 저장 + updateNode(nodeId, { + whereConditions: newConditions, + }); }; const handleSave = () => { @@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP + {/* 🆕 소스 필드 - 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/pop/PopProductionPanel.tsx b/frontend/components/pop/PopProductionPanel.tsx index 6d61bd9b..ceb1d902 100644 --- a/frontend/components/pop/PopProductionPanel.tsx +++ b/frontend/components/pop/PopProductionPanel.tsx @@ -124,17 +124,17 @@ export function PopProductionPanel({ return (

점검 항목

-
-
-
- {formatDate(currentDateTime)} - - {formatTime(currentDateTime)} - +
+ {formatDate(currentDateTime)} + {formatTime(currentDateTime)}
diff --git a/frontend/components/pop/styles.css b/frontend/components/pop/styles.css index a12b80fa..c2913c8e 100644 --- a/frontend/components/pop/styles.css +++ b/frontend/components/pop/styles.css @@ -142,9 +142,10 @@ line-height: 1.5; color: rgb(var(--text-primary)); background: rgb(var(--bg-deepest)); - min-height: 100vh; - min-height: 100dvh; + height: 100vh; + height: 100dvh; overflow-x: hidden; + overflow-y: auto; -webkit-font-smoothing: antialiased; position: relative; } @@ -197,8 +198,7 @@ .pop-app { display: flex; flex-direction: column; - min-height: 100vh; - min-height: 100dvh; + min-height: 100%; padding: var(--spacing-sm); padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); } @@ -209,7 +209,9 @@ border: 1px solid rgb(var(--border)); border-radius: var(--radius-lg); margin-bottom: var(--spacing-sm); - position: relative; + position: sticky; + top: 0; + z-index: 100; overflow: hidden; } @@ -227,8 +229,8 @@ .pop-top-bar { display: flex; align-items: center; - padding: var(--spacing-sm) var(--spacing-md); - gap: var(--spacing-md); + padding: var(--spacing-xs) var(--spacing-sm); + gap: var(--spacing-sm); } .pop-top-bar.row-1 { @@ -243,8 +245,8 @@ .pop-datetime { display: flex; align-items: center; - gap: var(--spacing-sm); - font-size: var(--text-xs); + gap: var(--spacing-xs); + font-size: var(--text-2xs); } .pop-date { @@ -254,7 +256,7 @@ .pop-time { color: rgb(var(--neon-cyan)); font-weight: 700; - font-size: var(--text-sm); + font-size: var(--text-xs); } /* 생산유형 버튼 */ @@ -264,12 +266,12 @@ } .pop-type-btn { - padding: var(--spacing-xs) var(--spacing-md); + padding: 4px var(--spacing-sm); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-muted)); - font-size: var(--text-xs); + font-size: var(--text-2xs); font-weight: 500; cursor: pointer; transition: all var(--transition-fast); @@ -291,12 +293,13 @@ .pop-filter-btn { display: flex; align-items: center; - gap: var(--spacing-xs); - padding: var(--spacing-sm) var(--spacing-md); + gap: 2px; + padding: 4px var(--spacing-sm); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-secondary)); + font-size: var(--text-2xs); font-size: var(--text-xs); font-weight: 500; cursor: pointer; @@ -343,6 +346,9 @@ border-radius: var(--radius-lg); overflow: hidden; margin-bottom: var(--spacing-sm); + position: sticky; + top: 90px; + z-index: 99; } .pop-status-tab { @@ -395,7 +401,7 @@ /* ==================== 메인 콘텐츠 ==================== */ .pop-main-content { flex: 1; - overflow-y: auto; + min-height: 0; } .pop-work-list { @@ -675,6 +681,7 @@ align-items: center; gap: 4px; padding: 4px 8px; + font-size: var(--text-2xs); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-sm); @@ -851,10 +858,10 @@ align-items: center; justify-content: center; gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-lg); + padding: var(--spacing-md) var(--spacing-lg); border: 1px solid transparent; border-radius: var(--radius-md); - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 600; cursor: pointer; transition: all var(--transition-fast); @@ -1105,8 +1112,9 @@ top: 0; right: 0; bottom: 0; + left: 0; width: 100%; - max-width: 600px; + max-width: none; background: rgb(var(--bg-primary)); display: flex; flex-direction: column; @@ -1133,11 +1141,29 @@ } .pop-slide-panel-title { - font-size: var(--text-lg); + font-size: var(--text-xl); font-weight: 700; color: rgb(var(--text-primary)); } +/* 슬라이드 패널 날짜/시간 */ +.pop-panel-datetime { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.pop-panel-date { + font-size: var(--text-sm); + color: rgb(var(--text-muted)); +} + +.pop-panel-time { + font-size: var(--text-base); + font-weight: 700; + color: rgb(var(--neon-cyan)); +} + .pop-badge { padding: 2px 8px; border-radius: var(--radius-full); @@ -1174,7 +1200,7 @@ .pop-work-steps-header { padding: var(--spacing-md); - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 600; color: rgb(var(--text-muted)); border-bottom: 1px solid rgb(var(--border)); @@ -1239,7 +1265,7 @@ } .pop-work-step-name { - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 500; color: rgb(var(--text-primary)); white-space: nowrap; @@ -1248,13 +1274,13 @@ } .pop-work-step-time { - font-size: var(--text-2xs); + font-size: var(--text-xs); color: rgb(var(--text-muted)); } .pop-work-step-status { - font-size: var(--text-2xs); - padding: 2px 6px; + font-size: var(--text-xs); + padding: 3px 8px; border-radius: var(--radius-sm); } @@ -1288,14 +1314,14 @@ } .pop-step-title { - font-size: var(--text-lg); + font-size: var(--text-xl); font-weight: 700; color: rgb(var(--text-primary)); margin-bottom: var(--spacing-xs); } .pop-step-description { - font-size: var(--text-sm); + font-size: var(--text-base); color: rgb(var(--text-muted)); } @@ -1311,20 +1337,20 @@ align-items: center; justify-content: center; gap: var(--spacing-sm); - padding: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); background: rgba(255, 255, 255, 0.03); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-secondary)); - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 500; cursor: pointer; transition: all var(--transition-fast); } .pop-time-control-btn svg { - width: 16px; - height: 16px; + width: 20px; + height: 20px; } .pop-time-control-btn:hover:not(:disabled) { @@ -1358,7 +1384,7 @@ } .pop-step-form-title { - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 600; color: rgb(var(--text-primary)); margin-bottom: var(--spacing-md); @@ -1374,20 +1400,53 @@ .pop-form-label { display: block; - font-size: var(--text-xs); + font-size: var(--text-sm); font-weight: 500; color: rgb(var(--text-secondary)); margin-bottom: var(--spacing-xs); } +/* 체크박스 리스트 */ +.pop-checkbox-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.pop-checkbox-label { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--text-base); + color: rgb(var(--text-primary)); + cursor: pointer; +} + +.pop-checkbox { + width: 20px; + height: 20px; + accent-color: rgb(var(--neon-cyan)); + cursor: pointer; +} + +.pop-checkbox:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pop-checkbox-label:has(.pop-checkbox:disabled) { + opacity: 0.6; + cursor: not-allowed; +} + .pop-input { width: 100%; - padding: var(--spacing-sm) var(--spacing-md); + padding: var(--spacing-md); background: rgba(var(--bg-tertiary), 0.5); border: 1px solid rgb(var(--border)); border-radius: var(--radius-md); color: rgb(var(--text-primary)); - font-size: var(--text-sm); + font-size: var(--text-base); transition: all var(--transition-fast); } @@ -1441,12 +1500,12 @@ } .pop-work-order-info-item .label { - font-size: var(--text-2xs); + font-size: var(--text-xs); color: rgb(var(--text-muted)); } .pop-work-order-info-item .value { - font-size: var(--text-sm); + font-size: var(--text-base); font-weight: 500; color: rgb(var(--text-primary)); } @@ -1634,9 +1693,9 @@ /* 헤더 인라인 테마 토글 버튼 */ .pop-theme-toggle-inline { - width: 32px; - height: 32px; - margin-left: var(--spacing-sm); + width: 26px; + height: 26px; + margin-left: var(--spacing-xs); border-radius: var(--radius-md); background: rgba(255, 255, 255, 0.05); border: 1px solid rgb(var(--border)); @@ -1656,8 +1715,8 @@ } .pop-theme-toggle-inline svg { - width: 18px; - height: 18px; + width: 14px; + height: 14px; } /* ==================== 아이콘 버튼 ==================== */ @@ -1722,7 +1781,144 @@ } .pop-slide-panel-content { - max-width: 100%; + max-width: none; + left: 0; + } +} + +/* 태블릿 이상 (768px+) - 폰트 크기 증가 */ +@media (min-width: 768px) { + /* 상태 탭 sticky 위치 조정 */ + .pop-status-tabs { + top: 100px; + } + + /* 헤더 */ + .pop-top-bar { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + gap: var(--spacing-md); + } + + .pop-datetime { + font-size: var(--text-xs); + } + + .pop-time { + font-size: var(--text-sm); + } + + .pop-type-btn { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + .pop-filter-btn { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + .pop-theme-toggle-inline { + width: 32px; + height: 32px; + } + + .pop-theme-toggle-inline svg { + width: 16px; + height: 16px; + } + + .pop-equipment-name, + .pop-process-name { + font-size: var(--text-sm); + } + + /* 상태 탭 */ + .pop-status-tab-label { + font-size: var(--text-base); + } + + .pop-status-tab-count { + font-size: var(--text-xl); + } + + .pop-status-tab-detail { + font-size: var(--text-sm); + } + + /* 작업 카드 */ + .pop-work-card-id { + font-size: var(--text-base); + } + + .pop-work-card-item { + font-size: var(--text-lg); + } + + .pop-work-card-spec { + font-size: var(--text-base); + } + + .pop-work-card-info-item .label { + font-size: var(--text-sm); + } + + .pop-work-card-info-item .value { + font-size: var(--text-base); + } + + /* 작업 카드 내부 - 태블릿 */ + .pop-work-number { + font-size: var(--text-base); + } + + .pop-work-status { + font-size: var(--text-xs); + padding: 3px 10px; + } + + .pop-work-info-label { + font-size: var(--text-xs); + } + + .pop-work-info-value { + font-size: var(--text-sm); + } + + .pop-process-bar-label, + .pop-process-bar-count { + font-size: var(--text-xs); + } + + .pop-process-chip { + font-size: var(--text-xs); + padding: 5px 10px; + } + + .pop-progress-text, + .pop-progress-percent { + font-size: var(--text-sm); + } + + .pop-btn-sm { + font-size: var(--text-xs); + padding: var(--spacing-xs) var(--spacing-md); + } + + /* 공정 타임라인 */ + .pop-process-step-label { + font-size: var(--text-sm); + } + + /* 하단 네비게이션 */ + .pop-nav-btn { + font-size: var(--text-base); + } + + /* 배지 */ + .pop-badge { + font-size: var(--text-sm); + padding: 4px 10px; } } @@ -1734,6 +1930,11 @@ padding-bottom: calc(64px + var(--spacing-lg) + env(safe-area-inset-bottom, 0px)); } + /* 상태 탭 sticky 위치 조정 - 데스크톱 */ + .pop-status-tabs { + top: 110px; + } + .pop-work-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); @@ -1741,11 +1942,217 @@ } .pop-slide-panel-content { - max-width: 700px; + max-width: none; + left: 0; } .pop-work-steps-sidebar { - width: 240px; + width: 280px; + } + + /* 데스크톱 (1024px+) - 폰트 크기 더 증가 */ + + /* 헤더 */ + .pop-top-bar { + font-size: var(--text-base); + padding: var(--spacing-sm) var(--spacing-lg); + } + + .pop-datetime { + font-size: var(--text-sm); + } + + .pop-time { + font-size: var(--text-base); + } + + .pop-type-btn { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + .pop-filter-btn { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + .pop-theme-toggle-inline { + width: 36px; + height: 36px; + } + + .pop-theme-toggle-inline svg { + width: 18px; + height: 18px; + } + + .pop-equipment-name, + .pop-process-name { + font-size: var(--text-base); + } + + /* 상태 탭 */ + .pop-status-tab-label { + font-size: var(--text-lg); + } + + .pop-status-tab-count { + font-size: var(--text-2xl); + } + + .pop-status-tab-detail { + font-size: var(--text-base); + } + + /* 작업 카드 */ + .pop-work-card-id { + font-size: var(--text-lg); + } + + .pop-work-card-item { + font-size: var(--text-xl); + } + + .pop-work-card-spec { + font-size: var(--text-lg); + } + + .pop-work-card-info-item .label { + font-size: var(--text-base); + } + + .pop-work-card-info-item .value { + font-size: var(--text-lg); + } + + /* 작업 카드 내부 - 데스크톱 */ + .pop-work-number { + font-size: var(--text-lg); + } + + .pop-work-status { + font-size: var(--text-sm); + padding: 4px 12px; + } + + .pop-work-info-label { + font-size: var(--text-sm); + } + + .pop-work-info-value { + font-size: var(--text-base); + } + + .pop-process-bar-label, + .pop-process-bar-count { + font-size: var(--text-sm); + } + + .pop-process-chip { + font-size: var(--text-sm); + padding: 6px 12px; + } + + .pop-progress-text, + .pop-progress-percent { + font-size: var(--text-base); + } + + .pop-btn-sm { + font-size: var(--text-sm); + padding: var(--spacing-sm) var(--spacing-md); + } + + /* 공정 타임라인 */ + .pop-process-step-label { + font-size: var(--text-base); + } + + /* 하단 네비게이션 */ + .pop-nav-btn { + font-size: var(--text-lg); + } + + /* 배지 */ + .pop-badge { + font-size: var(--text-base); + padding: 5px 12px; + } + + /* 슬라이드 패널 - 데스크톱 */ + .pop-slide-panel-title { + font-size: var(--text-2xl); + } + + .pop-panel-date { + font-size: var(--text-base); + } + + .pop-panel-time { + font-size: var(--text-lg); + } + + .pop-work-steps-header { + font-size: var(--text-base); + } + + .pop-work-step-name { + font-size: var(--text-base); + } + + .pop-work-step-time { + font-size: var(--text-sm); + } + + .pop-work-step-status { + font-size: var(--text-sm); + } + + .pop-step-title { + font-size: var(--text-2xl); + } + + .pop-step-description { + font-size: var(--text-lg); + } + + .pop-step-form-title { + font-size: var(--text-lg); + } + + .pop-form-label { + font-size: var(--text-base); + } + + .pop-checkbox-label { + font-size: var(--text-lg); + } + + .pop-checkbox { + width: 24px; + height: 24px; + } + + .pop-input { + font-size: var(--text-lg); + padding: var(--spacing-md) var(--spacing-lg); + } + + .pop-work-order-info-item .label { + font-size: var(--text-sm); + } + + .pop-work-order-info-item .value { + font-size: var(--text-lg); + } + + .pop-btn { + font-size: var(--text-base); + padding: var(--spacing-md) var(--spacing-xl); + } + + .pop-time-control-btn { + font-size: var(--text-lg); } } diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 32451d18..c1b644cc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -761,10 +761,74 @@ export const EditModal: React.FC = ({ className }) => { // INSERT 모드 console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); + // 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) + const dataToSave = { ...formData }; + const fieldsWithNumbering: Record = {}; + + // formData에서 채번 규칙이 설정된 필드 찾기 + for (const [key, value] of Object.entries(formData)) { + if (key.endsWith("_numberingRuleId") && value) { + const fieldName = key.replace("_numberingRuleId", ""); + fieldsWithNumbering[fieldName] = value as string; + console.log(`🎯 [EditModal] 채번 규칙 발견: ${fieldName} → 규칙 ${value}`); + } + } + + // 채번 규칙이 있는 필드에 대해 allocateCode 호출 + if (Object.keys(fieldsWithNumbering).length > 0) { + console.log("🎯 [EditModal] 채번 규칙 할당 시작"); + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + + let hasAllocationFailure = false; + const failedFields: string[] = []; + + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { + try { + console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); + const allocateResult = await allocateNumberingCode(ruleId); + + if (allocateResult.success && allocateResult.data?.generatedCode) { + const newCode = allocateResult.data.generatedCode; + console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`); + dataToSave[fieldName] = newCode; + } else { + console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error); + if (!dataToSave[fieldName] || dataToSave[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } + } + } catch (allocateError) { + console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError); + if (!dataToSave[fieldName] || dataToSave[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } + } + } + + // 채번 규칙 할당 실패 시 저장 중단 + if (hasAllocationFailure) { + const fieldNames = failedFields.join(", "); + toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); + console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); + return; + } + + // _numberingRuleId 필드 제거 (실제 저장하지 않음) + for (const key of Object.keys(dataToSave)) { + if (key.endsWith("_numberingRuleId")) { + delete dataToSave[key]; + } + } + } + + console.log("[EditModal] 최종 저장 데이터:", dataToSave); + const response = await dynamicFormApi.saveFormData({ screenId: modalState.screenId!, tableName: screenData.screenInfo.tableName, - data: formData, + data: dataToSave, }); if (response.success) { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 376f9953..f786d1d1 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC = ( } case "entity": { + // DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용 const widget = comp as WidgetComponent; - const config = widget.webTypeConfig as EntityTypeConfig | undefined; - - console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { - componentId: widget.id, - widgetType: widget.widgetType, - config, - appliedSettings: { - entityName: config?.entityName, - displayField: config?.displayField, - valueField: config?.valueField, - multiple: config?.multiple, - defaultValue: config?.defaultValue, - }, - }); - - const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요..."; - const defaultOptions = [ - { label: "사용자", value: "user" }, - { label: "제품", value: "product" }, - { label: "주문", value: "order" }, - { label: "카테고리", value: "category" }, - ]; - - return ( - , + return applyStyles( + updateFormData(fieldName, value), + onFormDataChange: updateFormData, + formData: formData, + readonly: readonly, + required: required, + placeholder: widget.placeholder || "엔티티를 선택하세요", + isInteractive: true, + className: "w-full h-full", + }} + />, ); } diff --git a/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx index 05171d01..1cae7dce 100644 --- a/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Search, Database, Link, X, Plus } from "lucide-react"; import { EntityTypeConfig } from "@/types/screen"; @@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: "", displayFormat: "simple", separator: " - ", + multiple: false, // 다중 선택 + uiMode: "select", // UI 모드: select, combo, modal, autocomplete ...config, }; @@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, displayFormat: safeConfig.displayFormat, separator: safeConfig.separator, + multiple: safeConfig.multiple, + uiMode: safeConfig.uiMode, }); const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" }); @@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, displayFormat: safeConfig.displayFormat, separator: safeConfig.separator, + multiple: safeConfig.multiple, + uiMode: safeConfig.uiMode, }); }, [ safeConfig.referenceTable, @@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC = ({ co safeConfig.placeholder, safeConfig.displayFormat, safeConfig.separator, + safeConfig.multiple, + safeConfig.uiMode, ]); + // UI 모드 옵션 + const uiModes = [ + { value: "select", label: "드롭다운 선택" }, + { value: "combo", label: "입력 + 모달 버튼" }, + { value: "modal", label: "모달 팝업" }, + { value: "autocomplete", label: "자동완성" }, + ]; + const updateConfig = (key: keyof EntityTypeConfig, value: any) => { // 로컬 상태 즉시 업데이트 setLocalValues((prev) => ({ ...prev, [key]: value })); @@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC = ({ co />
+ {/* UI 모드 */} +
+ + +

+ {localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."} + {localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."} + {localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."} + {localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."} +

+
+ + {/* 다중 선택 */} +
+
+ +

여러 항목을 선택할 수 있습니다.

+
+ updateConfig("multiple", checked)} + /> +
+ {/* 필터 관리 */}
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 967e43ca..9b5b7aea 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -34,7 +34,11 @@ const getApiBaseUrl = (): string => { export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 +// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 export const getFullImageUrl = (imagePath: string): string => { + // 빈 값 체크 + if (!imagePath) return ""; + // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; @@ -42,8 +46,29 @@ export const getFullImageUrl = (imagePath: string): string => { // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 if (imagePath.startsWith("/uploads")) { - const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거 - return `${baseUrl}${imagePath}`; + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${imagePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${imagePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) + const baseUrl = API_BASE_URL.replace("/api", ""); + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${imagePath}`; + } + + // 최종 fallback + return imagePath; } return imagePath; 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/api/file.ts b/frontend/lib/api/file.ts index 4908b381..e6cab8ae 100644 --- a/frontend/lib/api/file.ts +++ b/frontend/lib/api/file.ts @@ -247,10 +247,40 @@ export const getFileDownloadUrl = (fileId: string): string => { /** * 직접 파일 경로 URL 생성 (정적 파일 서빙) + * 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 */ export const getDirectFileUrl = (filePath: string): string => { + // 빈 값 체크 + if (!filePath) return ""; + + // 이미 전체 URL인 경우 그대로 반환 + if (filePath.startsWith("http://") || filePath.startsWith("https://")) { + return filePath; + } + + // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return `https://api.vexplor.com${filePath}`; + } + + // 로컬 개발환경 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return `http://localhost:8080${filePath}`; + } + } + + // SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback) const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; - return `${baseUrl}${filePath}`; + if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { + return `${baseUrl}${filePath}`; + } + + // 최종 fallback + return filePath; }; /** diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index d03d83bf..5953fd82 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -328,6 +328,40 @@ class TableManagementApi { }; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ + async getTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise; + }>> { + try { + const response = await apiClient.get( + `${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}` + ); + return response.data; + } catch (error: any) { + console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } } // 싱글톤 인스턴스 생성 diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 70785171..5045a43b 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -35,7 +35,9 @@ export function EntitySearchInputComponent({ parentValue: parentValueProp, parentFieldId, formData, - // 🆕 추가 props + // 다중선택 props + multiple: multipleProp, + // 추가 props component, isInteractive, onFormDataChange, @@ -49,8 +51,11 @@ export function EntitySearchInputComponent({ // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; - // 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서) - const config = component?.componentConfig || {}; + // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서) + const config = component?.componentConfig || component?.webTypeConfig || {}; + const isMultiple = multipleProp ?? config.multiple ?? false; + + // 연쇄관계 설정 추출 const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; // cascadingParentField: ConfigPanel에서 저장되는 필드명 const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId; @@ -68,11 +73,27 @@ export function EntitySearchInputComponent({ const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); + // 다중선택 상태 (콤마로 구분된 값들) + const [selectedValues, setSelectedValues] = useState([]); + const [selectedDataList, setSelectedDataList] = useState([]); + // 연쇄관계 상태 const [cascadingOptions, setCascadingOptions] = useState([]); const [isCascadingLoading, setIsCascadingLoading] = useState(false); const previousParentValue = useRef(null); + // 다중선택 초기값 설정 + useEffect(() => { + if (isMultiple && value) { + const vals = + typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value]; + setSelectedValues(vals.map(String)); + } else if (isMultiple && !value) { + setSelectedValues([]); + setSelectedDataList([]); + } + }, [isMultiple, value]); + // 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요 const parentValue = isChildRole ? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined)) @@ -249,23 +270,75 @@ export function EntitySearchInputComponent({ }, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]); const handleSelect = (newValue: any, fullData: EntitySearchResult) => { - setSelectedData(fullData); - setDisplayValue(fullData[displayField] || ""); - onChange?.(newValue, fullData); + if (isMultiple) { + // 다중선택 모드 + const valueStr = String(newValue); + const isAlreadySelected = selectedValues.includes(valueStr); + + let newSelectedValues: string[]; + let newSelectedDataList: EntitySearchResult[]; + + if (isAlreadySelected) { + // 이미 선택된 항목이면 제거 + newSelectedValues = selectedValues.filter((v) => v !== valueStr); + newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr); + } else { + // 선택되지 않은 항목이면 추가 + newSelectedValues = [...selectedValues, valueStr]; + newSelectedDataList = [...selectedDataList, fullData]; + } + + setSelectedValues(newSelectedValues); + setSelectedDataList(newSelectedDataList); + + const joinedValue = newSelectedValues.join(","); + onChange?.(joinedValue, newSelectedDataList); + + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, joinedValue); + console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue); + } + } else { + // 단일선택 모드 + setSelectedData(fullData); + setDisplayValue(fullData[displayField] || ""); + onChange?.(newValue, fullData); + + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, newValue); + console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); + } + } + }; + + // 다중선택 모드에서 개별 항목 제거 + const handleRemoveValue = (valueToRemove: string) => { + const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove); + const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove); + + setSelectedValues(newSelectedValues); + setSelectedDataList(newSelectedDataList); + + const joinedValue = newSelectedValues.join(","); + onChange?.(joinedValue || null, newSelectedDataList); - // 🆕 onFormDataChange 호출 (formData에 값 저장) if (isInteractive && onFormDataChange && component?.columnName) { - onFormDataChange(component.columnName, newValue); - console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); + onFormDataChange(component.columnName, joinedValue || null); + console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue); } }; const handleClear = () => { - setDisplayValue(""); - setSelectedData(null); - onChange?.(null, null); + if (isMultiple) { + setSelectedValues([]); + setSelectedDataList([]); + onChange?.(null, []); + } else { + setDisplayValue(""); + setSelectedData(null); + onChange?.(null, null); + } - // 🆕 onFormDataChange 호출 (formData에서 값 제거) if (isInteractive && onFormDataChange && component?.columnName) { onFormDataChange(component.columnName, null); console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null); @@ -280,7 +353,10 @@ export function EntitySearchInputComponent({ const handleSelectOption = (option: EntitySearchResult) => { handleSelect(option[valueField], option); - setSelectOpen(false); + // 다중선택이 아닌 경우에만 드롭다운 닫기 + if (!isMultiple) { + setSelectOpen(false); + } }; // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) @@ -289,6 +365,111 @@ export function EntitySearchInputComponent({ // select 모드: 검색 가능한 드롭다운 if (mode === "select") { + // 다중선택 모드 + if (isMultiple) { + return ( +
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} + + {/* 선택된 항목들 표시 (태그 형식) */} +
!disabled && !isLoading && setSelectOpen(true)} + style={{ cursor: disabled ? "not-allowed" : "pointer" }} + > + {selectedValues.length > 0 ? ( + selectedValues.map((val) => { + const opt = effectiveOptions.find((o) => String(o[valueField]) === val); + const label = opt?.[displayField] || val; + return ( + + {label} + {!disabled && ( + + )} + + ); + }) + ) : ( + + {isLoading + ? "로딩 중..." + : shouldApplyCascading && !parentValue + ? "상위 항목을 먼저 선택하세요" + : placeholder} + + )} + +
+ + {/* 옵션 드롭다운 */} + {selectOpen && !disabled && ( +
+ + + + 항목을 찾을 수 없습니다. + + {effectiveOptions.map((option, index) => { + const isSelected = selectedValues.includes(String(option[valueField])); + return ( + handleSelectOption(option)} + className="text-xs sm:text-sm" + > + +
+ {option[displayField]} + {valueField !== displayField && ( + {option[valueField]} + )} +
+
+ ); + })} +
+
+
+ {/* 닫기 버튼 */} +
+ +
+
+ )} + + {/* 외부 클릭 시 닫기 */} + {selectOpen &&
setSelectOpen(false)} />} +
+ ); + } + + // 단일선택 모드 (기존 로직) return (
{/* 라벨 렌더링 */} @@ -366,6 +547,95 @@ export function EntitySearchInputComponent({ } // modal, combo, autocomplete 모드 + // 다중선택 모드 + if (isMultiple) { + return ( +
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} + + {/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */} +
+
+ {selectedValues.length > 0 ? ( + selectedValues.map((val) => { + // selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기 + const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val); + const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val); + const label = opt?.[displayField] || val; + return ( + + {label} + {!disabled && ( + + )} + + ); + }) + ) : ( + {placeholder} + )} +
+ + {/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */} + {(mode === "modal" || mode === "combo") && ( + + )} +
+ + {/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */} + {(mode === "modal" || mode === "combo") && ( + + )} +
+ ); + } + + // 단일선택 모드 (기존 로직) return (
{/* 라벨 렌더링 */} diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx index 3cd4d35a..fb75daa4 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx @@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({

+
+
+ + + updateConfig({ multiple: checked }) + } + /> +
+

+ {localConfig.multiple + ? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다." + : "하나의 항목만 선택할 수 있습니다."} +

+
+
= ({ + component, + value, + onChange, + readonly = false, + ...props +}) => { + // component에서 필요한 설정 추출 + const widget = component as any; + const webTypeConfig = widget?.webTypeConfig || {}; + const componentConfig = widget?.componentConfig || {}; + + // 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성 + const config = { ...componentConfig, ...webTypeConfig }; + + // 테이블 타입 관리에서 설정된 참조 테이블 정보 사용 + const tableName = config.referenceTable || widget?.referenceTable || ""; + const displayField = config.labelField || config.displayColumn || config.displayField || "name"; + const valueField = config.valueField || config.referenceColumn || "id"; + + // UI 모드: uiMode > mode 순서 + const uiMode = config.uiMode || config.mode || "select"; + + // 다중선택 설정 + const multiple = config.multiple ?? false; + + // placeholder + const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요"; + + console.log("🏢 EntitySearchInputWrapper 렌더링:", { + tableName, + displayField, + valueField, + uiMode, + multiple, + value, + config, + }); + + // 테이블 정보가 없으면 안내 메시지 표시 + if (!tableName) { + return ( +
+ 테이블 타입 관리에서 참조 테이블을 설정해주세요 +
+ ); + } + + return ( + + ); +}; + +EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper"; + diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx index 7f841ec3..555efe9b 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Search, Loader2 } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Search, Loader2, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useEntitySearch } from "./useEntitySearch"; import { EntitySearchResult } from "./types"; @@ -26,6 +28,9 @@ interface EntitySearchModalProps { modalTitle?: string; modalColumns?: string[]; onSelect: (value: any, fullData: EntitySearchResult) => void; + // 다중선택 관련 + multiple?: boolean; + selectedValues?: string[]; // 이미 선택된 값들 } export function EntitySearchModal({ @@ -39,6 +44,8 @@ export function EntitySearchModal({ modalTitle = "검색", modalColumns = [], onSelect, + multiple = false, + selectedValues = [], }: EntitySearchModalProps) { const [localSearchText, setLocalSearchText] = useState(""); const { @@ -71,7 +78,15 @@ export function EntitySearchModal({ const handleSelect = (item: EntitySearchResult) => { onSelect(item[valueField], item); - onOpenChange(false); + // 다중선택이 아닌 경우에만 모달 닫기 + if (!multiple) { + onOpenChange(false); + } + }; + + // 항목이 선택되어 있는지 확인 + const isItemSelected = (item: EntitySearchResult): boolean => { + return selectedValues.includes(String(item[valueField])); }; // 표시할 컬럼 결정 @@ -123,10 +138,16 @@ export function EntitySearchModal({ {/* 검색 결과 테이블 */}
-
+
- + + {/* 다중선택 시 체크박스 컬럼 */} + {multiple && ( + + )} {displayColumns.map((col) => ( + {!multiple && ( + + )} {loading && results.length === 0 ? ( - ) : results.length === 0 ? ( - ) : ( results.map((item, index) => { const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; + const isSelected = isItemSelected(item); return ( - handleSelect(item)} - > + className={cn( + "border-t cursor-pointer transition-colors", + isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent" + )} + onClick={() => handleSelect(item)} + > + {/* 다중선택 시 체크박스 */} + {multiple && ( + + )} {displayColumns.map((col) => ( - ))} - - - ); + {item[col] || "-"} + + ))} + {!multiple && ( + + )} + + ); }) )} @@ -211,12 +250,18 @@ export function EntitySearchModal({ )} + {/* 다중선택 시 선택된 항목 수 표시 */} + {multiple && selectedValues.length > 0 && ( +
+ {selectedValues.length}개 항목 선택됨 +
+ )}
diff --git a/frontend/lib/registry/components/entity-search-input/config.ts b/frontend/lib/registry/components/entity-search-input/config.ts index e147736f..fab81c9f 100644 --- a/frontend/lib/registry/components/entity-search-input/config.ts +++ b/frontend/lib/registry/components/entity-search-input/config.ts @@ -11,6 +11,9 @@ export interface EntitySearchInputConfig { showAdditionalInfo?: boolean; additionalFields?: string[]; + // 다중 선택 설정 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) + // 연쇄관계 설정 (cascading_relation 테이블과 연동) cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRole?: "parent" | "child"; // 역할 (부모/자식) diff --git a/frontend/lib/registry/components/entity-search-input/index.ts b/frontend/lib/registry/components/entity-search-input/index.ts index 3f70d0fe..5d1bf7d4 100644 --- a/frontend/lib/registry/components/entity-search-input/index.ts +++ b/frontend/lib/registry/components/entity-search-input/index.ts @@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config"; // 컴포넌트 내보내기 export { EntitySearchInputComponent } from "./EntitySearchInputComponent"; +export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper"; export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer"; export { EntitySearchModal } from "./EntitySearchModal"; export { useEntitySearch } from "./useEntitySearch"; diff --git a/frontend/lib/registry/components/entity-search-input/types.ts b/frontend/lib/registry/components/entity-search-input/types.ts index d29268b6..73abd9dd 100644 --- a/frontend/lib/registry/components/entity-search-input/types.ts +++ b/frontend/lib/registry/components/entity-search-input/types.ts @@ -19,6 +19,9 @@ export interface EntitySearchInputProps { placeholder?: string; disabled?: boolean; + // 다중선택 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) + // 필터링 filterCondition?: Record; // 추가 WHERE 조건 companyCode?: string; // 멀티테넌시 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 new file mode 100644 index 00000000..e7904a95 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -0,0 +1,988 @@ +"use client"; + +/** + * PivotGrid 메인 컴포넌트 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +import React, { useState, useMemo, useCallback, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + 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, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, + LayoutGrid, + FileSpreadsheet, + BarChart3, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 서브 컴포넌트 ==================== + +// 행 헤더 셀 +interface RowHeaderCellProps { + row: PivotFlatRow; + rowFields: PivotFieldConfig[]; + onToggleExpand: (path: string[]) => void; +} + +const RowHeaderCell: React.FC = ({ + row, + rowFields, + onToggleExpand, +}) => { + const indentSize = row.level * 20; + + return ( + + ); +}; + +// 데이터 셀 +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 ( + + ); + } + + // 단일 데이터 필드인 경우 + if (values.length === 1) { + return ( + + ); + } + + // 다중 데이터 필드인 경우 + return ( + <> + {values.map((val, idx) => ( + + ))} + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridComponent: React.FC = ({ + title, + fields: initialFields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + 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 || []; + + // ==================== 필드 분류 ==================== + + const rowFields = useMemo( + () => + fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const columnFields = useMemo( + () => + fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [fields] + ); + + const dataFields = useMemo( + () => + fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + [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(() => { + if (!data || data.length === 0 || fields.length === 0) { + return null; + } + + const visibleFields = fields.filter((f) => f.visible !== false); + if (visibleFields.filter((f) => f.area !== "filter").length === 0) { + return null; + } + + return processPivotData( + data, + 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[]) => { + setPivotState((prev) => { + const pathKey = pathToKey(path); + const existingIndex = prev.expandedRowPaths.findIndex( + (p) => pathToKey(p) === pathKey + ); + + let newPaths: string[][]; + if (existingIndex >= 0) { + newPaths = prev.expandedRowPaths.filter( + (_, i) => i !== existingIndex + ); + } else { + newPaths = [...prev.expandedRowPaths, path]; + } + + onExpandChange?.(newPaths); + + return { + ...prev, + expandedRowPaths: newPaths, + }; + }); + }, + [onExpandChange] + ); + + // 전체 확장 + const handleExpandAll = useCallback(() => { + if (!pivotResult) return; + + const allRowPaths: string[][] = []; + pivotResult.flatRows.forEach((row) => { + if (row.hasChildren) { + allRowPaths.push(row.path); + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + }, [pivotResult]); + + // 전체 축소 + const handleCollapseAll = useCallback(() => { + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); + }, []); + + // 셀 클릭 + const handleCellClick = useCallback( + (rowPath: string[], colPath: string[], values: PivotCellValue[]) => { + if (!onCellClick) return; + + const cellData: PivotCellData = { + value: values[0]?.value, + rowPath, + columnPath: colPath, + field: values[0]?.field, + }; + + onCellClick(cellData); + }, + [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; + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + let csv = ""; + + // 헤더 행 + const headerRow = [""].concat( + flatColumns.map((col) => col.caption || "총계") + ); + if (totals?.showRowGrandTotals) { + headerRow.push("총계"); + } + csv += headerRow.join(",") + "\n"; + + // 데이터 행 + flatRows.forEach((row) => { + const rowData = [row.caption]; + + flatColumns.forEach((col) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey); + rowData.push(values?.[0]?.value?.toString() || ""); + }); + + if (totals?.showRowGrandTotals) { + const rowTotal = grandTotals.row.get(pathToKey(row.path)); + rowData.push(rowTotal?.[0]?.value?.toString() || ""); + } + + csv += rowData.join(",") + "\n"; + }); + + // 열 총계 행 + if (totals?.showColumnGrandTotals) { + const totalRow = ["총계"]; + flatColumns.forEach((col) => { + const colTotal = grandTotals.column.get(pathToKey(col.path)); + totalRow.push(colTotal?.[0]?.value?.toString() || ""); + }); + if (totals?.showRowGrandTotals) { + totalRow.push(grandTotals.grand[0]?.value?.toString() || ""); + } + csv += totalRow.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 = `${title || "pivot"}_export.csv`; + 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]); + + // ==================== 렌더링 ==================== + + // 빈 상태 + if (!data || data.length === 0) { + return ( +
+ +

데이터가 없습니다

+

데이터를 로드하거나 필드를 설정해주세요

+
+ ); + } + + // 필드 미설정 + const hasActiveFields = fields.some( + (f) => f.visible !== false && f.area !== "filter" + ); + if (!hasActiveFields) { + return ( +
+ {/* 필드 패널 */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 안내 메시지 */} +
+ +

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

+

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

+ +
+ + {/* 필드 선택기 모달 */} + +
+ ); + } + + // 피벗 결과 없음 + if (!pivotResult) { + return ( +
+ +
+ ); + } + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + return ( +
+ {/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */} + setShowFieldPanel(!showFieldPanel)} + /> + + {/* 헤더 툴바 */} +
+
+ {title &&

{title}

} + + ({data.length}건) + +
+ +
+ {/* 필드 선택기 버튼 */} + {fieldChooser?.enabled !== false && ( + + )} + + {/* 필드 패널 토글 */} + + + {allowExpandAll && ( + <> + + + + + )} + + {/* 차트 토글 */} + {chartConfig && ( + + )} + + {/* 내보내기 버튼들 */} + {exportConfig?.excel && ( + <> + + + + )} + + +
+
+ + {/* 피벗 테이블 */} +
+
+ 선택 + ))} - - 선택 - + 선택 +
+

검색 중...

+ 검색 결과가 없습니다
+ handleSelect(item)} + onClick={(e) => e.stopPropagation()} + /> + - {item[col] || "-"} - - -
+ +
+
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+
+ - + + {/* Data Bar */} + {hasDataBar && ( +
+ )} + + {icon && {icon}} + {values[0].formattedValue} + +
+ {hasDataBar && ( +
+ )} + + {icon && {icon}} + {val.formattedValue} + +
+ + {/* 열 헤더 */} + + {/* 좌상단 코너 (행 필드 라벨) */} + + + {/* 열 헤더 셀 */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + + {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} + {dataFields.length > 1 && ( + + {flatColumns.map((col, colIdx) => ( + + {dataFields.map((df, dfIdx) => ( + + ))} + + ))} + {totals?.showRowGrandTotals && + dataFields.map((df, dfIdx) => ( + + ))} + + )} + + + + {flatRows.map((row, rowIdx) => ( + + {/* 행 헤더 */} + + + {/* 데이터 셀 */} + {flatColumns.map((col, colIdx) => { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`; + const values = dataMatrix.get(cellKey) || []; + + // 조건부 서식 (첫 번째 값 기준) + const conditionalStyle = + values.length > 0 && values[0].field + ? getCellConditionalStyle(values[0].value, values[0].field) + : undefined; + + return ( + handleCellClick(row.path, col.path, values) + : undefined + } + onDoubleClick={() => + handleCellDoubleClick(row.path, col.path, values) + } + /> + ); + })} + + {/* 행 총계 */} + {totals?.showRowGrandTotals && ( + + )} + + ))} + + {/* 열 총계 행 */} + {totals?.showColumnGrandTotals && ( + + + + {flatColumns.map((col, colIdx) => ( + + ))} + + {/* 대총합 */} + {totals?.showRowGrandTotals && ( + + )} + + )} + +
0 ? 2 : 1} + > + {rowFields.map((f) => f.caption).join(" / ") || "항목"} + + {col.caption || "(전체)"} + + 총계 +
+ {df.caption} + + {df.caption} +
+ 총계 +
+
+ + {/* 차트 */} + {showChart && chartConfig && pivotResult && ( + + )} + + {/* 필드 선택기 모달 */} + + + {/* Drill Down 모달 */} + setDrillDownData((prev) => ({ ...prev, open }))} + cellData={drillDownData.cellData} + data={data} + fields={fields} + rowFields={rowFields} + columnFields={columnFields} + /> +
+ ); +}; + +export default PivotGridComponent; diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx new file mode 100644 index 00000000..f3e9a976 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -0,0 +1,1007 @@ +"use client"; + +/** + * PivotGrid 설정 패널 + * 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridComponentConfig, + PivotFieldConfig, + PivotAreaType, + AggregationType, + DateGroupInterval, + FieldDataType, +} from "./types"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Plus, + Trash2, + GripVertical, + Settings2, + Rows, + Columns, + Database, + Filter, + ChevronUp, + ChevronDown, +} from "lucide-react"; +import { apiClient } from "@/lib/api/client"; + +// ==================== 타입 ==================== + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; + is_nullable: string; +} + +interface PivotGridConfigPanelProps { + config: PivotGridComponentConfig; + onChange: (config: PivotGridComponentConfig) => void; +} + +// ==================== 유틸리티 ==================== + +const AREA_LABELS: Record = { + row: { label: "행 영역", icon: }, + column: { label: "열 영역", icon: }, + data: { label: "데이터 영역", icon: }, + filter: { label: "필터 영역", icon: }, +}; + +const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: "sum", label: "합계" }, + { value: "count", label: "개수" }, + { value: "avg", label: "평균" }, + { value: "min", label: "최소" }, + { value: "max", label: "최대" }, + { value: "countDistinct", label: "고유값 개수" }, +]; + +const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [ + { value: "year", label: "연도" }, + { value: "quarter", label: "분기" }, + { value: "month", label: "월" }, + { value: "week", label: "주" }, + { value: "day", label: "일" }, +]; + +const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [ + { value: "string", label: "문자열" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "boolean", label: "부울" }, +]; + +// DB 타입을 FieldDataType으로 변환 +function mapDbTypeToFieldType(dbType: string): FieldDataType { + const type = dbType.toLowerCase(); + if ( + type.includes("int") || + type.includes("numeric") || + type.includes("decimal") || + type.includes("float") || + type.includes("double") || + type.includes("real") + ) { + return "number"; + } + if ( + type.includes("date") || + type.includes("time") || + type.includes("timestamp") + ) { + return "date"; + } + if (type.includes("bool")) { + return "boolean"; + } + return "string"; +} + +// ==================== 필드 설정 컴포넌트 ==================== + +interface FieldConfigItemProps { + field: PivotFieldConfig; + index: number; + onChange: (field: PivotFieldConfig) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +} + +const FieldConfigItem: React.FC = ({ + field, + index, + onChange, + onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}) => { + return ( +
+ {/* 드래그 핸들 & 순서 버튼 */} +
+ + + +
+ + {/* 필드 설정 */} +
+ {/* 필드명 & 라벨 */} +
+
+ + onChange({ ...field, field: e.target.value })} + placeholder="column_name" + className="h-8 text-xs" + /> +
+
+ + onChange({ ...field, caption: e.target.value })} + placeholder="표시명" + className="h-8 text-xs" + /> +
+
+ + {/* 데이터 타입 & 집계 함수 */} +
+
+ + +
+ + {field.area === "data" && ( +
+ + +
+ )} + + {field.dataType === "date" && + (field.area === "row" || field.area === "column") && ( +
+ + +
+ )} +
+
+ + {/* 삭제 버튼 */} + +
+ ); +}; + +// ==================== 영역별 필드 목록 ==================== + +interface AreaFieldListProps { + area: PivotAreaType; + fields: PivotFieldConfig[]; + allColumns: ColumnInfo[]; + onFieldsChange: (fields: PivotFieldConfig[]) => void; +} + +const AreaFieldList: React.FC = ({ + area, + fields, + allColumns, + onFieldsChange, +}) => { + const areaFields = fields.filter((f) => f.area === area); + const { label, icon } = AREA_LABELS[area]; + + const handleAddField = () => { + const newField: PivotFieldConfig = { + field: "", + caption: "", + area, + areaIndex: areaFields.length, + dataType: "string", + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleAddFromColumn = (column: ColumnInfo) => { + const dataType = mapDbTypeToFieldType(column.data_type); + const newField: PivotFieldConfig = { + field: column.column_name, + caption: column.column_comment || column.column_name, + area, + areaIndex: areaFields.length, + dataType, + visible: true, + }; + if (area === "data") { + newField.summaryType = "sum"; + } + onFieldsChange([...fields, newField]); + }; + + const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => { + const newFields = [...fields]; + const globalIndex = fields.findIndex( + (f) => f.area === area && f.areaIndex === index + ); + if (globalIndex >= 0) { + newFields[globalIndex] = updatedField; + onFieldsChange(newFields); + } + }; + + const handleRemoveField = (index: number) => { + const newFields = fields.filter( + (f) => !(f.area === area && f.areaIndex === index) + ); + // 인덱스 재정렬 + let idx = 0; + newFields.forEach((f) => { + if (f.area === area) { + f.areaIndex = idx++; + } + }); + onFieldsChange(newFields); + }; + + const handleMoveField = (fromIndex: number, direction: "up" | "down") => { + const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1; + if (toIndex < 0 || toIndex >= areaFields.length) return; + + const newAreaFields = [...areaFields]; + const [moved] = newAreaFields.splice(fromIndex, 1); + newAreaFields.splice(toIndex, 0, moved); + + // 인덱스 재정렬 + newAreaFields.forEach((f, idx) => { + f.areaIndex = idx; + }); + + // 전체 필드 업데이트 + const newFields = fields.filter((f) => f.area !== area); + onFieldsChange([...newFields, ...newAreaFields]); + }; + + // 이미 추가된 컬럼 제외 + const availableColumns = allColumns.filter( + (col) => !fields.some((f) => f.field === col.column_name) + ); + + return ( + + +
+ {icon} + {label} + + {areaFields.length} + +
+
+ + {/* 필드 목록 */} + {areaFields + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)) + .map((field, idx) => ( + handleFieldChange(field.areaIndex || idx, f)} + onRemove={() => handleRemoveField(field.areaIndex || idx)} + onMoveUp={() => handleMoveField(idx, "up")} + onMoveDown={() => handleMoveField(idx, "down")} + isFirst={idx === 0} + isLast={idx === areaFields.length - 1} + /> + ))} + + {/* 필드 추가 */} +
+ + + +
+
+
+ ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 + const response = await apiClient.get("/table-management/tables"); + if (response.data.success) { + setTables(response.data.data || []); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 테이블 선택 시 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.dataSource?.tableName) { + setColumns([]); + return; + } + + setLoadingColumns(true); + try { + // apiClient의 baseURL이 이미 /api를 포함하므로 /api 제외 + const response = await apiClient.get( + `/table-management/tables/${config.dataSource.tableName}/columns` + ); + if (response.data.success) { + setColumns(response.data.data || []); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.dataSource?.tableName]); + + // 설정 업데이트 헬퍼 + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange] + ); + + return ( +
+ {/* 데이터 소스 설정 */} +
+ + +
+ + +
+
+ + + + {/* 필드 설정 */} + {config.dataSource?.tableName && ( +
+
+ + + {columns.length}개 컬럼 + +
+ + {loadingColumns ? ( +
+ 컬럼 로딩 중... +
+ ) : ( + + {(["row", "column", "data", "filter"] as PivotAreaType[]).map( + (area) => ( + updateConfig({ fields })} + /> + ) + )} + + )} +
+ )} + + + + {/* 표시 설정 */} +
+ + +
+
+ + + updateConfig({ + totals: { ...config.totals, showRowGrandTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showColumnGrandTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showRowTotals: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + totals: { ...config.totals, showColumnTotals: v }, + }) + } + /> +
+
+ +
+ + + updateConfig({ + style: { ...config.style, alternateRowColors: v }, + }) + } + /> +
+ +
+ + + updateConfig({ + style: { ...config.style, highlightTotals: v }, + }) + } + /> +
+
+ + + + {/* 기능 설정 */} +
+ + +
+
+ + + updateConfig({ allowExpandAll: v }) + } + /> +
+ +
+ + + updateConfig({ + exportConfig: { ...config.exportConfig, excel: v }, + }) + } + /> +
+
+
+ + + + {/* 차트 설정 */} +
+ + +
+
+ + + 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}개의 조건부 서식이 + 적용됨 +

+ )} +
+
+ + + + {/* 크기 설정 */} +
+ + +
+
+ + updateConfig({ height: e.target.value })} + placeholder="auto 또는 400px" + className="h-8 text-xs" + /> +
+ +
+ + updateConfig({ maxHeight: e.target.value })} + placeholder="600px" + className="h-8 text-xs" + /> +
+
+
+
+ ); +}; + +export default PivotGridConfigPanel; + diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx new file mode 100644 index 00000000..8e3563d9 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -0,0 +1,306 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +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 컴포넌트 정의 + */ +const PivotGridDefinition = createComponentDefinition({ + id: "pivot-grid", + name: "피벗 그리드", + nameEng: "PivotGrid Component", + description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: PivotGridWrapper, // 래퍼 컴포넌트 사용 + defaultConfig: { + dataSource: { + type: "table", + tableName: "", + }, + fields: SAMPLE_FIELDS, + // 미리보기용 샘플 데이터 + sampleData: SAMPLE_DATA, + totals: { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style: { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll: true, + exportConfig: { + excel: true, + }, + height: "400px", + }, + defaultSize: { width: 800, height: 500 }, + configPanel: PivotGridConfigPanel, + icon: "BarChart3", + tags: ["피벗", "분석", "집계", "그리드", "데이터"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +/** + * PivotGrid 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +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 ( + + ); + } +} + +// 자동 등록 실행 +PivotGridRenderer.registerSelf(); + +// 강제 등록 (디버깅용) +if (typeof window !== "undefined") { + setTimeout(() => { + try { + PivotGridRenderer.registerSelf(); + } catch (error) { + console.error("❌ PivotGrid 강제 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/pivot-grid/README.md b/frontend/lib/registry/components/pivot-grid/README.md new file mode 100644 index 00000000..bc6fba52 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/README.md @@ -0,0 +1,239 @@ +# PivotGrid 컴포넌트 + +다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다. + +## 주요 기능 + +### 1. 다차원 데이터 배치 + +- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시) +- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기) +- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량) +- **필터 영역(Filter Area)**: 전체 데이터 필터링 + +### 2. 집계 함수 + +| 함수 | 설명 | 사용 예 | +|------|------|---------| +| `sum` | 합계 | 매출 합계 | +| `count` | 개수 | 건수 | +| `avg` | 평균 | 평균 단가 | +| `min` | 최소값 | 최저가 | +| `max` | 최대값 | 최고가 | +| `countDistinct` | 고유값 개수 | 거래처 수 | + +### 3. 날짜 그룹화 + +날짜 필드를 다양한 단위로 그룹화할 수 있습니다: + +- `year`: 연도별 +- `quarter`: 분기별 +- `month`: 월별 +- `week`: 주별 +- `day`: 일별 + +### 4. 드릴다운 + +계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다. + +### 5. 총합계/소계 + +- 행 총합계 (Row Grand Total) +- 열 총합계 (Column Grand Total) +- 행 소계 (Row Subtotal) +- 열 소계 (Column Subtotal) + +### 6. 내보내기 + +CSV 형식으로 데이터를 내보낼 수 있습니다. + +## 사용법 + +### 기본 사용 + +```tsx +import { PivotGridComponent } from "@/lib/registry/components/pivot-grid"; + +const salesData = [ + { region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 }, + { region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 }, + // ... +]; + + +``` + +### 날짜 그룹화 + +```tsx + +``` + +### 포맷 설정 + +```tsx + +``` + +### 화면 관리에서 사용 + +설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다. + +```tsx +import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid"; + + +``` + +## 설정 옵션 + +### PivotGridProps + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `title` | `string` | - | 피벗 테이블 제목 | +| `data` | `any[]` | `[]` | 원본 데이터 배열 | +| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 | +| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 | +| `style` | `PivotStyleConfig` | - | 스타일 설정 | +| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 | +| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 | +| `height` | `string | number` | `"auto"` | 높이 | +| `maxHeight` | `string` | - | 최대 높이 | + +### PivotFieldConfig + +| 속성 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `field` | `string` | O | 데이터 필드명 | +| `caption` | `string` | O | 표시 라벨 | +| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 | +| `areaIndex` | `number` | - | 영역 내 순서 | +| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 | +| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) | +| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 | +| `format` | `PivotFieldFormat` | - | 값 포맷 | +| `visible` | `boolean` | - | 표시 여부 | + +### PivotTotalsConfig + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 | +| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 | +| `showRowTotals` | `boolean` | `true` | 행 소계 표시 | +| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 | + +## 파일 구조 + +``` +pivot-grid/ +├── index.ts # 모듈 진입점 +├── types.ts # 타입 정의 +├── PivotGridComponent.tsx # 메인 컴포넌트 +├── PivotGridRenderer.tsx # 화면 관리 렌더러 +├── PivotGridConfigPanel.tsx # 설정 패널 +├── README.md # 문서 +└── utils/ + ├── index.ts # 유틸리티 모듈 진입점 + ├── aggregation.ts # 집계 함수 + └── pivotEngine.ts # 피벗 데이터 처리 엔진 +``` + +## 사용 시나리오 + +### 1. 매출 분석 + +지역별/기간별/제품별 매출 현황을 분석합니다. + +### 2. 재고 현황 + +창고별/품목별 재고 수량을 한눈에 파악합니다. + +### 3. 생산 실적 + +생산라인별/일자별 생산량을 분석합니다. + +### 4. 비용 분석 + +부서별/계정별 비용을 집계하여 분석합니다. + +### 5. 수주 현황 + +거래처별/품목별/월별 수주 현황을 분석합니다. + +## 주의사항 + +1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요. +2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다. +3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요. + + 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 new file mode 100644 index 00000000..b1bbe99b --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -0,0 +1,62 @@ +/** + * PivotGrid 컴포넌트 모듈 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +// 타입 내보내기 +export type { + // 기본 타입 + PivotAreaType, + AggregationType, + SummaryDisplayMode, + SortDirection, + DateGroupInterval, + FieldDataType, + DataSourceType, + // 필드 설정 + PivotFieldFormat, + PivotFieldConfig, + // 데이터 소스 + PivotFilterCondition, + PivotJoinConfig, + PivotDataSourceConfig, + // 표시 설정 + PivotTotalsConfig, + FieldChooserConfig, + PivotChartConfig, + PivotStyleConfig, + PivotExportConfig, + // Props + PivotGridProps, + // 결과 데이터 + PivotCellData, + PivotHeaderNode, + PivotCellValue, + PivotResult, + PivotFlatRow, + PivotFlatColumn, + // 상태 + PivotGridState, + // Config + PivotGridComponentConfig, +} from "./types"; + +// 컴포넌트 내보내기 +export { PivotGridComponent } from "./PivotGridComponent"; +export { PivotGridConfigPanel } from "./PivotGridConfigPanel"; + +// 유틸리티 +export { + aggregate, + sum, + count, + avg, + min, + max, + countDistinct, + formatNumber, + formatDate, + getAggregationLabel, +} from "./utils/aggregation"; + +export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine"; diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts new file mode 100644 index 00000000..e711a255 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -0,0 +1,401 @@ +/** + * PivotGrid 컴포넌트 타입 정의 + * 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트 + */ + +// ==================== 기본 타입 ==================== + +// 필드 영역 타입 +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"; + +// 날짜 그룹 간격 +export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day"; + +// 필드 데이터 타입 +export type FieldDataType = "string" | "number" | "date" | "boolean"; + +// 데이터 소스 타입 +export type DataSourceType = "table" | "api" | "static"; + +// ==================== 필드 설정 ==================== + +// 필드 포맷 설정 +export interface PivotFieldFormat { + type: "number" | "currency" | "percent" | "date" | "text"; + precision?: number; // 소수점 자릿수 + thousandSeparator?: boolean; // 천단위 구분자 + prefix?: string; // 접두사 (예: "$", "₩") + suffix?: string; // 접미사 (예: "%", "원") + dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD") +} + +// 필드 설정 +export interface PivotFieldConfig { + // 기본 정보 + field: string; // 데이터 필드명 + caption: string; // 표시 라벨 + area: PivotAreaType; // 배치 영역 + areaIndex?: number; // 영역 내 순서 + + // 데이터 타입 + dataType?: FieldDataType; // 데이터 타입 + + // 집계 설정 (data 영역용) + summaryType?: AggregationType; // 집계 함수 + summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드 + showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭) + + // 정렬 설정 + sortBy?: "value" | "caption"; // 정렬 기준 + sortOrder?: SortDirection; // 정렬 방향 + sortBySummary?: string; // 요약값 기준 정렬 (data 필드명) + + // 날짜 그룹화 설정 + groupInterval?: DateGroupInterval; // 날짜 그룹 간격 + groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성) + + // 표시 설정 + visible?: boolean; // 표시 여부 + width?: number; // 컬럼 너비 + expanded?: boolean; // 기본 확장 상태 + + // 포맷 설정 + format?: PivotFieldFormat; // 값 포맷 + + // 필터 설정 + filterValues?: any[]; // 선택된 필터 값 + filterType?: "include" | "exclude"; // 필터 타입 + allowFiltering?: boolean; // 필터링 허용 + allowSorting?: boolean; // 정렬 허용 + + // 계층 관련 + displayFolder?: string; // 필드 선택기에서 폴더 구조 + isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능) +} + +// ==================== 데이터 소스 설정 ==================== + +// 필터 조건 +export interface PivotFilterCondition { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; + value?: any; + valueFromField?: string; // formData에서 값 가져오기 +} + +// 조인 설정 +export interface PivotJoinConfig { + joinType: "INNER" | "LEFT" | "RIGHT"; + targetTable: string; + sourceColumn: string; + targetColumn: string; + columns: string[]; // 가져올 컬럼들 +} + +// 데이터 소스 설정 +export interface PivotDataSourceConfig { + type: DataSourceType; + + // 테이블 기반 + tableName?: string; // 테이블명 + + // API 기반 + apiEndpoint?: string; // API 엔드포인트 + apiMethod?: "GET" | "POST"; // HTTP 메서드 + + // 정적 데이터 + staticData?: any[]; // 정적 데이터 + + // 필터 조건 + filterConditions?: PivotFilterCondition[]; + + // 조인 설정 + joinConfigs?: PivotJoinConfig[]; +} + +// ==================== 표시 설정 ==================== + +// 총합계 표시 설정 +export interface PivotTotalsConfig { + // 행 총합계 + showRowGrandTotals?: boolean; // 행 총합계 표시 + showRowTotals?: boolean; // 행 소계 표시 + rowTotalsPosition?: "first" | "last"; // 소계 위치 + + // 열 총합계 + showColumnGrandTotals?: boolean; // 열 총합계 표시 + showColumnTotals?: boolean; // 열 소계 표시 + columnTotalsPosition?: "first" | "last"; // 소계 위치 +} + +// 필드 선택기 설정 +export interface FieldChooserConfig { + enabled: boolean; // 활성화 여부 + allowSearch?: boolean; // 검색 허용 + layout?: "default" | "simplified"; // 레이아웃 + height?: number; // 높이 + applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점 +} + +// 차트 연동 설정 +export interface PivotChartConfig { + enabled: boolean; // 차트 표시 여부 + type: "bar" | "line" | "area" | "pie" | "stackedBar"; + position: "top" | "bottom" | "left" | "right"; + height?: number; + showLegend?: boolean; + 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"; + headerStyle: "default" | "dark" | "light"; + cellPadding: "compact" | "normal" | "comfortable"; + borderStyle: "none" | "light" | "heavy"; + alternateRowColors?: boolean; + highlightTotals?: boolean; // 총합계 강조 + conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙 +} + +// ==================== 내보내기 설정 ==================== + +export interface PivotExportConfig { + excel?: boolean; + pdf?: boolean; + fileName?: string; +} + +// ==================== 메인 Props ==================== + +export interface PivotGridProps { + // 기본 설정 + id?: string; + title?: string; + + // 데이터 소스 + dataSource?: PivotDataSourceConfig; + + // 필드 설정 + fields?: PivotFieldConfig[]; + + // 표시 설정 + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // 필드 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // 기능 설정 + allowSortingBySummary?: boolean; // 요약값 기준 정렬 + allowFiltering?: boolean; // 필터링 허용 + allowExpandAll?: boolean; // 전체 확장/축소 허용 + wordWrapEnabled?: boolean; // 텍스트 줄바꿈 + + // 크기 설정 + height?: string | number; + maxHeight?: string; + + // 상태 저장 + stateStoring?: { + enabled: boolean; + storageKey?: string; // localStorage 키 + }; + + // 내보내기 + exportConfig?: PivotExportConfig; + + // 데이터 (외부 주입용) + data?: any[]; + + // 이벤트 + onCellClick?: (cellData: PivotCellData) => void; + onCellDoubleClick?: (cellData: PivotCellData) => void; + onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void; + onExpandChange?: (expandedPaths: string[][]) => void; + onDataChange?: (data: any[]) => void; +} + +// ==================== 결과 데이터 구조 ==================== + +// 셀 데이터 +export interface PivotCellData { + value: any; // 셀 값 + rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"]) + columnPath: string[]; // 열 경로 (예: ["2024", "Q1"]) + field?: string; // 데이터 필드명 + aggregationType?: AggregationType; + isTotal?: boolean; // 총합계 여부 + isGrandTotal?: boolean; // 대총합 여부 +} + +// 헤더 노드 (트리 구조) +export interface PivotHeaderNode { + value: any; // 원본 값 + caption: string; // 표시 텍스트 + level: number; // 깊이 + children?: PivotHeaderNode[]; // 자식 노드 + isExpanded: boolean; // 확장 상태 + path: string[]; // 경로 (드릴다운용) + subtotal?: PivotCellValue[]; // 소계 + span?: number; // colspan/rowspan +} + +// 셀 값 +export interface PivotCellValue { + field: string; // 데이터 필드 + value: number | null; // 집계 값 + formattedValue: string; // 포맷된 값 +} + +// 피벗 결과 데이터 구조 +export interface PivotResult { + // 행 헤더 트리 + rowHeaders: PivotHeaderNode[]; + + // 열 헤더 트리 + columnHeaders: PivotHeaderNode[]; + + // 데이터 매트릭스 (rowPath + columnPath → values) + dataMatrix: Map; + + // 플랫 행 목록 (렌더링용) + flatRows: PivotFlatRow[]; + + // 플랫 열 목록 (렌더링용) + flatColumns: PivotFlatColumn[]; + + // 총합계 + grandTotals: { + row: Map; // 행별 총합 + column: Map; // 열별 총합 + grand: PivotCellValue[]; // 대총합 + }; +} + +// 플랫 행 (렌더링용) +export interface PivotFlatRow { + path: string[]; + level: number; + caption: string; + isExpanded: boolean; + hasChildren: boolean; + isTotal?: boolean; +} + +// 플랫 열 (렌더링용) +export interface PivotFlatColumn { + path: string[]; + level: number; + caption: string; + span: number; + isTotal?: boolean; +} + +// ==================== 상태 관리 ==================== + +export interface PivotGridState { + expandedRowPaths: string[][]; // 확장된 행 경로들 + expandedColumnPaths: string[][]; // 확장된 열 경로들 + sortConfig: { + field: string; + direction: SortDirection; + } | null; + filterConfig: Record; // 필드별 필터값 +} + +// ==================== 컴포넌트 Config (화면관리용) ==================== + +export interface PivotGridComponentConfig { + // 데이터 소스 + dataSource?: PivotDataSourceConfig; + + // 필드 설정 + fields?: PivotFieldConfig[]; + + // 표시 설정 + totals?: PivotTotalsConfig; + style?: PivotStyleConfig; + + // 필드 선택기 + fieldChooser?: FieldChooserConfig; + + // 차트 연동 + chart?: PivotChartConfig; + + // 기능 설정 + allowSortingBySummary?: boolean; + allowFiltering?: boolean; + allowExpandAll?: boolean; + wordWrapEnabled?: boolean; + + // 크기 설정 + height?: string | number; + maxHeight?: string; + + // 내보내기 + exportConfig?: PivotExportConfig; +} + + diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts new file mode 100644 index 00000000..39aa1c5f --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts @@ -0,0 +1,176 @@ +/** + * PivotGrid 집계 함수 유틸리티 + * 다양한 집계 연산을 수행합니다. + */ + +import { AggregationType, PivotFieldFormat } from "../types"; + +// ==================== 집계 함수 ==================== + +/** + * 합계 계산 + */ +export function sum(values: number[]): number { + return values.reduce((acc, val) => acc + (val || 0), 0); +} + +/** + * 개수 계산 + */ +export function count(values: any[]): number { + return values.length; +} + +/** + * 평균 계산 + */ +export function avg(values: number[]): number { + if (values.length === 0) return 0; + return sum(values) / values.length; +} + +/** + * 최소값 계산 + */ +export function min(values: number[]): number { + if (values.length === 0) return 0; + return Math.min(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 최대값 계산 + */ +export function max(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values.filter((v) => v !== null && v !== undefined)); +} + +/** + * 고유값 개수 계산 + */ +export function countDistinct(values: any[]): number { + return new Set(values.filter((v) => v !== null && v !== undefined)).size; +} + +/** + * 집계 타입에 따른 집계 수행 + */ +export function aggregate( + values: any[], + type: AggregationType = "sum" +): number { + const numericValues = values + .map((v) => (typeof v === "number" ? v : parseFloat(v))) + .filter((v) => !isNaN(v)); + + switch (type) { + case "sum": + return sum(numericValues); + case "count": + return count(values); + case "avg": + return avg(numericValues); + case "min": + return min(numericValues); + case "max": + return max(numericValues); + case "countDistinct": + return countDistinct(values); + default: + return sum(numericValues); + } +} + +// ==================== 포맷 함수 ==================== + +/** + * 숫자 포맷팅 + */ +export function formatNumber( + value: number | null | undefined, + format?: PivotFieldFormat +): string { + if (value === null || value === undefined) return "-"; + + const { + type = "number", + precision = 0, + thousandSeparator = true, + prefix = "", + suffix = "", + } = format || {}; + + let formatted: string; + + switch (type) { + case "currency": + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "percent": + formatted = (value * 100).toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + break; + + case "number": + default: + if (thousandSeparator) { + formatted = value.toLocaleString("ko-KR", { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); + } else { + formatted = value.toFixed(precision); + } + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +/** + * 날짜 포맷팅 + */ +export function formatDate( + value: Date | string | null | undefined, + format: string = "YYYY-MM-DD" +): string { + if (!value) return "-"; + + const date = typeof value === "string" ? new Date(value) : value; + + if (isNaN(date.getTime())) return "-"; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const quarter = Math.ceil((date.getMonth() + 1) / 3); + + return format + .replace("YYYY", String(year)) + .replace("MM", month) + .replace("DD", day) + .replace("Q", `Q${quarter}`); +} + +/** + * 집계 타입 라벨 반환 + */ +export function getAggregationLabel(type: AggregationType): string { + const labels: Record = { + sum: "합계", + count: "개수", + avg: "평균", + min: "최소", + max: "최대", + countDistinct: "고유값", + }; + return labels[type] || "합계"; +} + + 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 new file mode 100644 index 00000000..2c0a83d6 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -0,0 +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 new file mode 100644 index 00000000..4d3fecfd --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -0,0 +1,812 @@ +/** + * PivotGrid 데이터 처리 엔진 + * 원시 데이터를 피벗 구조로 변환합니다. + */ + +import { + PivotFieldConfig, + PivotResult, + PivotHeaderNode, + PivotFlatRow, + PivotFlatColumn, + PivotCellValue, + DateGroupInterval, + AggregationType, + SummaryDisplayMode, +} from "../types"; +import { aggregate, formatNumber, formatDate } from "./aggregation"; + +// ==================== 헬퍼 함수 ==================== + +/** + * 필드 값 추출 (날짜 그룹핑 포함) + */ +function getFieldValue( + row: Record, + field: PivotFieldConfig +): string { + const rawValue = row[field.field]; + + if (rawValue === null || rawValue === undefined) { + return "(빈 값)"; + } + + // 날짜 그룹핑 처리 + if (field.groupInterval && field.dataType === "date") { + const date = new Date(rawValue); + if (isNaN(date.getTime())) return String(rawValue); + + switch (field.groupInterval) { + case "year": + return String(date.getFullYear()); + case "quarter": + return `Q${Math.ceil((date.getMonth() + 1) / 3)}`; + case "month": + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + case "week": + const weekNum = getWeekNumber(date); + return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; + case "day": + return formatDate(date, "YYYY-MM-DD"); + default: + return String(rawValue); + } + } + + return String(rawValue); +} + +/** + * 주차 계산 + */ +function getWeekNumber(date: Date): number { + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +/** + * 경로를 키로 변환 + */ +export function pathToKey(path: string[]): string { + return path.join("||"); +} + +/** + * 키를 경로로 변환 + */ +export function keyToPath(key: string): string[] { + return key.split("||"); +} + +// ==================== 헤더 생성 ==================== + +/** + * 계층적 헤더 노드 생성 + */ +function buildHeaderTree( + data: Record[], + fields: PivotFieldConfig[], + expandedPaths: Set +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + // 첫 번째 필드로 그룹화 + const firstField = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, firstField); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + // 정렬 + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (firstField.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + // 노드 생성 + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: 0, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + // 자식 노드 생성 (확장된 경우만) + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + 1 + ); + // span 계산 + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * 자식 노드 재귀 생성 + */ +function buildChildNodes( + data: Record[], + fields: PivotFieldConfig[], + parentPath: string[], + expandedPaths: Set, + level: number +): PivotHeaderNode[] { + if (fields.length === 0) return []; + + const field = fields[0]; + const groups = new Map[]>(); + + data.forEach((row) => { + const value = getFieldValue(row, field); + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value)!.push(row); + }); + + const sortedKeys = Array.from(groups.keys()).sort((a, b) => { + if (field.sortOrder === "desc") { + return b.localeCompare(a, "ko"); + } + return a.localeCompare(b, "ko"); + }); + + const nodes: PivotHeaderNode[] = []; + const remainingFields = fields.slice(1); + + for (const key of sortedKeys) { + const groupData = groups.get(key)!; + const path = [...parentPath, key]; + const pathKey = pathToKey(path); + + const node: PivotHeaderNode = { + value: key, + caption: key, + level: level, + isExpanded: expandedPaths.has(pathKey), + path: path, + span: 1, + }; + + if (remainingFields.length > 0 && node.isExpanded) { + node.children = buildChildNodes( + groupData, + remainingFields, + path, + expandedPaths, + level + 1 + ); + node.span = calculateSpan(node.children); + } + + nodes.push(node); + } + + return nodes; +} + +/** + * span 계산 (colspan/rowspan) + */ +function calculateSpan(children?: PivotHeaderNode[]): number { + if (!children || children.length === 0) return 1; + return children.reduce((sum, child) => sum + child.span, 0); +} + +// ==================== 플랫 구조 변환 ==================== + +/** + * 헤더 트리를 플랫 행으로 변환 + */ +function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { + const result: PivotFlatRow[] = []; + + function traverse(node: PivotHeaderNode) { + result.push({ + path: node.path, + level: node.level, + caption: node.caption, + isExpanded: node.isExpanded, + hasChildren: !!(node.children && node.children.length > 0), + }); + + if (node.isExpanded && node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + return result; +} + +/** + * 헤더 트리를 플랫 열로 변환 (각 레벨별) + */ +function flattenColumns( + nodes: PivotHeaderNode[], + maxLevel: number +): PivotFlatColumn[][] { + const levels: PivotFlatColumn[][] = Array.from( + { length: maxLevel + 1 }, + () => [] + ); + + function traverse(node: PivotHeaderNode, currentLevel: number) { + levels[currentLevel].push({ + path: node.path, + level: currentLevel, + caption: node.caption, + span: node.span, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, currentLevel + 1); + } + } else if (currentLevel < maxLevel) { + // 확장되지 않은 노드는 다음 레벨들에서 span으로 처리 + for (let i = currentLevel + 1; i <= maxLevel; i++) { + levels[i].push({ + path: node.path, + level: i, + caption: "", + span: node.span, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + +/** + * 열 헤더의 최대 깊이 계산 + */ +function getMaxColumnLevel( + nodes: PivotHeaderNode[], + totalFields: number +): number { + let maxLevel = 0; + + function traverse(node: PivotHeaderNode, level: number) { + maxLevel = Math.max(maxLevel, level); + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return Math.min(maxLevel, totalFields - 1); +} + +// ==================== 데이터 매트릭스 생성 ==================== + +/** + * 데이터 매트릭스 생성 + */ +function buildDataMatrix( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): Map { + const matrix = new Map(); + + // 각 셀에 대해 해당하는 데이터 집계 + for (const row of flatRows) { + for (const colPath of flatColumnLeaves) { + const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; + + // 해당 행/열 경로에 맞는 데이터 필터링 + const filteredData = data.filter((record) => { + // 행 조건 확인 + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + + // 열 조건 확인 + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + + return true; + }); + + // 데이터 필드별 집계 + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate( + values, + dataField.summaryType || "sum" + ); + const formattedValue = formatNumber( + aggregatedValue, + dataField.format + ); + + return { + field: dataField.field, + value: aggregatedValue, + formattedValue, + }; + }); + + matrix.set(cellKey, cellValues); + } + } + + return matrix; +} + +/** + * 열 leaf 노드 경로 추출 + */ +function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] { + const leaves: string[][] = []; + + function traverse(node: PivotHeaderNode) { + if (!node.isExpanded || !node.children || node.children.length === 0) { + leaves.push(node.path); + } else { + for (const child of node.children) { + traverse(child); + } + } + } + + for (const node of nodes) { + traverse(node); + } + + // 열 필드가 없을 경우 빈 경로 추가 + if (leaves.length === 0) { + leaves.push([]); + } + + 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; +} + +// ==================== 총합계 계산 ==================== + +/** + * 총합계 계산 + */ +function calculateGrandTotals( + data: Record[], + rowFields: PivotFieldConfig[], + columnFields: PivotFieldConfig[], + dataFields: PivotFieldConfig[], + flatRows: PivotFlatRow[], + flatColumnLeaves: string[][] +): { + row: Map; + column: Map; + grand: PivotCellValue[]; +} { + const rowTotals = new Map(); + const columnTotals = new Map(); + + // 행별 총합 (각 행의 모든 열 합계) + for (const row of flatRows) { + const filteredData = data.filter((record) => { + for (let i = 0; i < row.path.length; i++) { + const field = rowFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== row.path[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + rowTotals.set(pathToKey(row.path), cellValues); + } + + // 열별 총합 (각 열의 모든 행 합계) + for (const colPath of flatColumnLeaves) { + const filteredData = data.filter((record) => { + for (let i = 0; i < colPath.length; i++) { + const field = columnFields[i]; + if (!field) continue; + const value = getFieldValue(record, field); + if (value !== colPath[i]) return false; + } + return true; + }); + + const cellValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = filteredData.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + columnTotals.set(pathToKey(colPath), cellValues); + } + + // 대총합 + const grandValues: PivotCellValue[] = dataFields.map((dataField) => { + const values = data.map((r) => r[dataField.field]); + const aggregatedValue = aggregate(values, dataField.summaryType || "sum"); + return { + field: dataField.field, + value: aggregatedValue, + formattedValue: formatNumber(aggregatedValue, dataField.format), + }; + }); + + return { + row: rowTotals, + column: columnTotals, + grand: grandValues, + }; +} + +// ==================== 메인 함수 ==================== + +/** + * 피벗 데이터 처리 + */ +export function processPivotData( + data: Record[], + fields: PivotFieldConfig[], + expandedRowPaths: string[][] = [], + expandedColumnPaths: string[][] = [] +): PivotResult { + // 영역별 필드 분리 + const rowFields = fields + .filter((f) => f.area === "row" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const columnFields = fields + .filter((f) => f.area === "column" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const dataFields = fields + .filter((f) => f.area === "data" && f.visible !== false) + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + + const filterFields = fields.filter( + (f) => f.area === "filter" && f.visible !== false + ); + + // 필터 적용 + let filteredData = data; + for (const filterField of filterFields) { + if (filterField.filterValues && filterField.filterValues.length > 0) { + filteredData = filteredData.filter((row) => { + const value = getFieldValue(row, filterField); + if (filterField.filterType === "exclude") { + return !filterField.filterValues!.includes(value); + } + return filterField.filterValues!.includes(value); + }); + } + } + + // 확장 경로 Set 변환 + const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); + const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + + // 기본 확장: 첫 번째 레벨 모두 확장 + if (expandedRowPaths.length === 0 && rowFields.length > 0) { + const firstField = rowFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedRowSet.add(val)); + } + + if (expandedColumnPaths.length === 0 && columnFields.length > 0) { + const firstField = columnFields[0]; + const uniqueValues = new Set( + filteredData.map((row) => getFieldValue(row, firstField)) + ); + uniqueValues.forEach((val) => expandedColSet.add(val)); + } + + // 헤더 트리 생성 + const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet); + const columnHeaders = buildHeaderTree( + filteredData, + columnFields, + expandedColSet + ); + + // 플랫 구조 변환 + const flatRows = flattenRows(rowHeaders); + const flatColumnLeaves = getColumnLeaves(columnHeaders); + const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length); + const flatColumns = flattenColumns(columnHeaders, maxColumnLevel); + + // 데이터 매트릭스 생성 + let dataMatrix = buildDataMatrix( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // 총합계 계산 + const grandTotals = calculateGrandTotals( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // Summary Display Mode 적용 + dataMatrix = applyDisplayModeToMatrix( + dataMatrix, + dataFields, + flatRows, + flatColumnLeaves, + grandTotals.row, + grandTotals.column, + grandTotals.grand + ); + + return { + rowHeaders, + columnHeaders, + dataMatrix, + flatRows, + flatColumns: flatColumnLeaves.map((path, idx) => ({ + path, + level: path.length - 1, + caption: path[path.length - 1] || "", + span: 1, + })), + grandTotals, + }; +} + + diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index e91d34f4..19073e39 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -434,20 +434,50 @@ export const SelectedItemsDetailInputComponent: React.FC { // 각 그룹의 엔트리 배열들을 준비 - const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); + // 🔧 빈 엔트리 필터링: id만 있고 실제 필드 값이 없는 엔트리는 제외 + const groupEntriesArrays: GroupEntry[][] = groups.map((group) => { + const entries = item.fieldGroups[group.id] || []; + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + + // 실제 필드 값이 하나라도 있는 엔트리만 포함 + return entries.filter((entry) => { + const hasAnyFieldValue = groupFields.some((field) => { + const value = entry[field.name]; + return value !== undefined && value !== null && value !== ""; + }); + + if (!hasAnyFieldValue && Object.keys(entry).length <= 1) { + console.log("⏭️ [generateCartesianProduct] 빈 엔트리 필터링:", { + entryId: entry.id, + groupId: group.id, + entryKeys: Object.keys(entry), + }); + } + + return hasAnyFieldValue; + }); + }); // 🆕 모든 그룹이 비어있는지 확인 const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0); if (allGroupsEmpty) { - // 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성 - // (거래처 품번/품명, 기간별 단가 없이도 저장 가능) - console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", { - itemIndex, - itemId: item.id, - }); - // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 - allRecords.push({}); + // 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시) + // 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지) + if (itemsList.length === 1) { + console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", { + itemIndex, + itemId: item.id, + }); + // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 + allRecords.push({}); + } else { + console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", { + itemIndex, + itemId: item.id, + totalItems: itemsList.length, + }); + } return; } @@ -471,6 +501,11 @@ export const SelectedItemsDetailInputComponent: React.FC { const newCombination = { ...currentCombination }; + // 🆕 기존 레코드의 id가 있으면 포함 (UPDATE를 위해) + if (entry.id) { + newCombination.id = entry.id; + } + // 현재 그룹의 필드들을 조합에 추가 const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id); groupFields.forEach((field) => { @@ -573,6 +608,27 @@ export const SelectedItemsDetailInputComponent: React.FC v === null || v === undefined || v === ""); + + if (hasEmptyParentKey) { + console.error("❌ [SelectedItemsDetailInput] parentKeys가 비어있거나 유효하지 않습니다!", parentKeys); + window.dispatchEvent( + new CustomEvent("formSaveError", { + detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, + }), + ); + + // 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정 + if (event instanceof CustomEvent && event.detail) { + (event.detail as any).skipDefaultSave = true; + console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (parentKeys 검증 실패)"); + } + return; + } + // items를 Cartesian Product로 변환 const records = generateCartesianProduct(items); @@ -591,24 +647,21 @@ export const SelectedItemsDetailInputComponent: React.FC size: 1, }); - const detail = result.items && result.items.length > 0 ? result.items[0] : null; + // result.data가 EntityJoinResponse의 실제 배열 필드 + const detail = result.data && result.data.length > 0 ? result.data[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -940,26 +941,65 @@ export const SplitPanelLayoutComponent: React.FC return; } - // 🆕 복합키 지원 - if (keys && keys.length > 0 && leftTable) { + // 🆕 엔티티 관계 자동 감지 로직 개선 + // 1. 설정된 keys가 있으면 사용 + // 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회 + let effectiveKeys = keys || []; + + if (effectiveKeys.length === 0 && leftTable && rightTableName) { + // 엔티티 관계 자동 감지 + console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName); + + if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) { + effectiveKeys = relResponse.data.relations.map((rel) => ({ + leftColumn: rel.leftColumn, + rightColumn: rel.rightColumn, + })); + console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys); + } + } + + if (effectiveKeys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 + // 복합키 조건 생성 (다중 값 지원) + // 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함 + // 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 const searchConditions: Record = {}; - keys.forEach((key) => { + effectiveKeys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + const leftValue = leftItem[key.leftColumn]; + // 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화 + if (typeof leftValue === "string") { + if (leftValue.includes(",")) { + // "2,3" 형태면 분리해서 배열로 + const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v); + searchConditions[key.rightColumn] = values; + console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values); + } else { + // 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로) + searchConditions[key.rightColumn] = [leftValue.trim()]; + console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]); + } + } else { + // 숫자나 다른 타입은 배열로 감싸기 + searchConditions[key.rightColumn] = [leftValue]; + console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]); + } } }); 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); @@ -988,7 +1028,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -1006,6 +1046,9 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) + } else { + console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); + setRightData([]); } } } @@ -1589,13 +1632,12 @@ 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" && currentTabConfig?.editButton?.mode === "modal") { const modalScreenId = currentTabConfig?.editButton?.modalScreenId; @@ -1604,27 +1646,8 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모달 화면 열기 const rightTableName = currentTabConfig?.tableName || ""; - // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) - let primaryKeyName = "id"; - let primaryKeyValue: any; - - if (item.id !== undefined && item.id !== null) { - primaryKeyName = "id"; - primaryKeyValue = item.id; - } else if (item.ID !== undefined && item.ID !== null) { - primaryKeyName = "ID"; - primaryKeyValue = item.ID; - } else { - // 첫 번째 필드를 Primary Key로 간주 - const firstKey = Object.keys(item)[0]; - primaryKeyName = firstKey; - primaryKeyValue = item[firstKey]; - } - console.log("✅ 수정 모달 열기:", { tableName: rightTableName, - primaryKeyName, - primaryKeyValue, screenId: modalScreenId, fullItem: item, }); @@ -1643,15 +1666,88 @@ export const SplitPanelLayoutComponent: React.FC hasGroupByColumns: groupByColumns.length > 0, }); - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + // 🆕 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: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열) urlParams: { - mode: "edit", - editId: primaryKeyValue, - tableName: rightTableName, + mode: "edit", // 🆕 수정 모드 표시 ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), @@ -1660,10 +1756,10 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { + console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { screenId: modalScreenId, - editId: primaryKeyValue, - tableName: rightTableName, + editData: allRelatedRecords, + recordCount: allRelatedRecords.length, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); @@ -1814,47 +1910,89 @@ export const SplitPanelLayoutComponent: React.FC try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); - // 🔍 중복 제거 설정 디버깅 - console.log("🔍 중복 제거 디버깅:", { + // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication) + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication; + + console.log("🔍 삭제 설정 디버깅:", { panel: deleteModalPanel, - dataFilter: componentConfig.rightPanel?.dataFilter, - deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, - enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, + groupByColumns, + deduplication, + deduplicationEnabled: deduplication?.enabled, }); let result; - // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { - const deduplication = componentConfig.rightPanel.dataFilter.deduplication; - const groupByColumn = deduplication.groupByColumn; - - if (groupByColumn && deleteModalItem[groupByColumn]) { - const groupValue = deleteModalItem[groupByColumn]; - console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); - - // groupByColumn 값으로 필터링하여 삭제 - const filterConditions: Record = { - [groupByColumn]: groupValue, - }; - - // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) - if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { - const leftColumn = componentConfig.rightPanel.join.leftColumn; - const rightColumn = componentConfig.rightPanel.join.rightColumn; - filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인 + if (deleteModalPanel === "right") { + // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들) + if (groupByColumns.length > 0) { + const filterConditions: Record = {}; + + // 선택된 컬럼들의 값을 필터 조건으로 추가 + for (const col of groupByColumns) { + if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) { + filterConditions[col] = deleteModalItem[col]; + } } - console.log("🗑️ 그룹 삭제 조건:", filterConditions); + // 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함 + // (다른 거래처의 같은 품목이 삭제되는 것을 방지) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join?.leftColumn; + const rightColumn = componentConfig.rightPanel.join?.rightColumn; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) { + // rightColumn이 filterConditions에 없으면 추가 + if (!filterConditions[rightColumn]) { + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`); + } + } + } - // 그룹 삭제 API 호출 - result = await dataApi.deleteGroupRecords(tableName, filterConditions); - } else { - // 단일 레코드 삭제 + // 필터 조건이 있으면 그룹 삭제 + if (Object.keys(filterConditions).length > 0) { + console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`); + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + // 필터 조건이 없으면 단일 삭제 + console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환"); + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } + // 2. 중복 제거(deduplication)가 활성화된 경우 + else if (deduplication?.enabled && deduplication?.groupByColumn) { + const groupByColumn = deduplication.groupByColumn; + const groupValue = deleteModalItem[groupByColumn]; + + if (groupValue) { + console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); + + const filterConditions: Record = { + [groupByColumn]: groupValue, + }; + + // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join.leftColumn; + const rightColumn = componentConfig.rightPanel.join.rightColumn; + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + } + + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } + // 3. 그 외: 단일 레코드 삭제 + else { result = await dataApi.deleteRecord(tableName, primaryKey); } } else { - // 단일 레코드 삭제 + // 좌측 패널: 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index f0b02ea2..3c5c7116 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1272,6 +1272,71 @@ export const SplitPanelLayoutConfigPanel: React.FC + >([]); + const [isDetectingRelations, setIsDetectingRelations] = useState(false); + + useEffect(() => { + const detectRelations = async () => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const rightTable = config.rightPanel?.tableName; + + // 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지 + if (relationshipType !== "join" || !leftTable || !rightTable) { + setAutoDetectedRelations([]); + return; + } + + setIsDetectingRelations(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable); + + if (response.success && response.data?.relations) { + console.log("🔍 엔티티 관계 자동 감지:", response.data.relations); + setAutoDetectedRelations(response.data.relations); + + // 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정 + const currentKeys = config.rightPanel?.relation?.keys || []; + if (response.data.relations.length > 0 && currentKeys.length === 0) { + // 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능) + const firstRel = response.data.relations[0]; + console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel); + updateRightPanel({ + relation: { + ...config.rightPanel?.relation, + type: "join", + useMultipleKeys: true, + keys: [ + { + leftColumn: firstRel.leftColumn, + rightColumn: firstRel.rightColumn, + }, + ], + }, + }); + } + } + } catch (error) { + console.error("❌ 엔티티 관계 감지 실패:", error); + setAutoDetectedRelations([]); + } finally { + setIsDetectingRelations(false); + } + }; + + detectRelations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]); + console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); console.log(" - config:", config); console.log(" - tables:", tables); @@ -2476,234 +2541,50 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 컬럼 매핑 - 조인 모드에서만 표시 */} + {/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */} {relationshipType !== "detail" && ( -
-
-
- -

좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다

-
- +
+
+ +

테이블 타입관리에서 정의된 엔티티 관계입니다

-

복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)

- - {/* 복합키가 설정된 경우 */} - {(config.rightPanel?.relation?.keys || []).length > 0 ? ( - <> - {(config.rightPanel?.relation?.keys || []).map((key, index) => ( -
-
- 조인 키 {index + 1} - -
-
-
- - -
-
- - -
-
+ {isDetectingRelations ? ( +
+
+ 관계 감지 중... +
+ ) : autoDetectedRelations.length > 0 ? ( +
+ {autoDetectedRelations.map((rel, index) => ( +
+ + {leftTableName}.{rel.leftColumn} + + + + {rightTableName}.{rel.rightColumn} + + + {rel.inputType === "entity" ? "엔티티" : "카테고리"} +
))} - +

+ 테이블 타입관리에서 엔티티/카테고리 설정을 변경하면 자동으로 적용됩니다 +

+
+ ) : config.rightPanel?.tableName ? ( +
+

감지된 엔티티 관계가 없습니다

+

+ 테이블 타입관리에서 엔티티 타입과 참조 테이블을 설정하세요 +

+
) : ( - /* 단일키 (하위 호환성) */ - <> -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {leftTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, leftColumn: value }, - }); - setLeftColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- -
- -
- -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, foreignKey: value }, - }); - setRightColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- +
+

우측 테이블을 선택하면 관계를 자동 감지합니다

+
)}
)} diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 8ffa8afe..a101efaf 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -104,6 +104,20 @@ export const TextInputComponent: React.FC = ({ const currentFormValue = formData?.[component.columnName]; const currentComponentValue = component.value; + // 🆕 채번 규칙이 설정되어 있으면 항상 _numberingRuleId를 formData에 설정 + // (값 생성 성공 여부와 관계없이, 저장 시점에 allocateCode를 호출하기 위함) + if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { + const ruleId = testAutoGeneration.options.numberingRuleId; + if (ruleId && ruleId !== "undefined" && ruleId !== "null" && ruleId !== "") { + const ruleIdKey = `${component.columnName}_numberingRuleId`; + // formData에 아직 설정되지 않은 경우에만 설정 + if (isInteractive && onFormDataChange && !formData?.[ruleIdKey]) { + onFormDataChange(ruleIdKey, ruleId); + console.log("📝 채번 규칙 ID 사전 설정:", ruleIdKey, ruleId); + } + } + } + // 자동생성된 값이 없고, 현재 값도 없을 때만 생성 if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { isGeneratingRef.current = true; // 생성 시작 플래그 @@ -144,13 +158,6 @@ export const TextInputComponent: React.FC = ({ if (isInteractive && onFormDataChange && component.columnName) { console.log("📝 formData 업데이트:", component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue); - - // 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함) - if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { - const ruleIdKey = `${component.columnName}_numberingRuleId`; - onFormDataChange(ruleIdKey, testAutoGeneration.options.numberingRuleId); - console.log("📝 채번 규칙 ID 저장:", ruleIdKey, testAutoGeneration.options.numberingRuleId); - } } } } else if (!autoGeneratedValue && testAutoGeneration.type !== "none") { diff --git a/frontend/lib/registry/init.ts b/frontend/lib/registry/init.ts index c0082c2b..46f562b1 100644 --- a/frontend/lib/registry/init.ts +++ b/frontend/lib/registry/init.ts @@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget"; import { FileWidget } from "@/components/screen/widgets/types/FileWidget"; import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget"; -import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget"; +import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper"; import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget"; // 개별적으로 설정 패널들을 import @@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() { name: "엔티티 선택", category: "input", description: "데이터베이스 엔티티 선택 필드", - component: EntityWidget, + component: EntitySearchInputWrapper, configPanel: EntityConfigPanel, defaultConfig: { entityType: "", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7512d6c0..4af6988b 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -535,17 +535,26 @@ export class ButtonActionExecutor { // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 + // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 + const beforeSaveEventDetail = { + formData: context.formData, + skipDefaultSave: false, + }; window.dispatchEvent( new CustomEvent("beforeFormSave", { - detail: { - formData: context.formData, - }, + detail: beforeSaveEventDetail, }), ); // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 await new Promise((resolve) => setTimeout(resolve, 100)); + // 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기 + if (beforeSaveEventDetail.skipDefaultSave) { + console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)"); + return true; + } + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 렉 구조 컴포넌트 일괄 저장 감지 @@ -806,6 +815,9 @@ export class ButtonActionExecutor { console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + let hasAllocationFailure = false; + const failedFields: string[] = []; + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); @@ -816,13 +828,31 @@ export class ButtonActionExecutor { console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); formData[fieldName] = newCode; } else { - console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error); + console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error); + // 🆕 기존 값이 빈 문자열이면 실패로 표시 + if (!formData[fieldName] || formData[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } } } catch (allocateError) { console.error(`❌ ${fieldName} 코드 할당 오류:`, allocateError); - // 오류 시 기존 값 유지 + // 🆕 기존 값이 빈 문자열이면 실패로 표시 + if (!formData[fieldName] || formData[fieldName] === "") { + hasAllocationFailure = true; + failedFields.push(fieldName); + } } } + + // 🆕 채번 규칙 할당 실패 시 저장 중단 + if (hasAllocationFailure) { + const fieldNames = failedFields.join(", "); + toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); + console.error(`❌ 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); + console.error("💡 해결 방법: 화면관리에서 해당 필드의 채번 규칙 설정을 확인하세요."); + return false; + } } console.log("✅ 채번 규칙 할당 완료"); @@ -3053,6 +3083,7 @@ export class ButtonActionExecutor { config: ButtonActionConfig, rowData: any, context: ButtonActionContext, + isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달 ): Promise { const { groupByColumns = [] } = config; @@ -3126,10 +3157,11 @@ export class ButtonActionExecutor { const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, - title: config.editModalTitle || "데이터 수정", + title: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"), description: description, modalSize: config.modalSize || "lg", editData: rowData, + isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록 groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 tableName: context.tableName, // 🆕 테이블명 전달 buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) @@ -3244,23 +3276,61 @@ export class ButtonActionExecutor { "code", ]; + // 🆕 화면 설정에서 채번 규칙 가져오기 + let screenNumberingRules: Record = {}; + if (config.targetScreenId) { + try { + const { screenApi } = await import("@/lib/api/screen"); + const layout = await screenApi.getLayout(config.targetScreenId); + + // 레이아웃에서 채번 규칙이 설정된 컴포넌트 찾기 + const findNumberingRules = (components: any[]): void => { + for (const comp of components) { + const compConfig = comp.componentConfig || {}; + // text-input 컴포넌트의 채번 규칙 확인 + if (compConfig.autoGeneration?.type === "numbering_rule" && compConfig.autoGeneration?.options?.numberingRuleId) { + const columnName = compConfig.columnName || comp.columnName; + if (columnName) { + screenNumberingRules[columnName] = compConfig.autoGeneration.options.numberingRuleId; + console.log(`📋 화면 설정에서 채번 규칙 발견: ${columnName} → ${compConfig.autoGeneration.options.numberingRuleId}`); + } + } + // 중첩된 컴포넌트 확인 + if (comp.children && Array.isArray(comp.children)) { + findNumberingRules(comp.children); + } + } + }; + + if (layout?.components) { + findNumberingRules(layout.components); + } + console.log("📋 화면 설정에서 찾은 채번 규칙:", screenNumberingRules); + } catch (error) { + console.warn("⚠️ 화면 레이아웃 조회 실패:", error); + } + } + // 품목코드 필드를 찾아서 무조건 공백으로 초기화 let resetFieldName = ""; for (const field of itemCodeFields) { if (copiedData[field] !== undefined) { const originalValue = copiedData[field]; const ruleIdKey = `${field}_numberingRuleId`; - const hasNumberingRule = - rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; + + // 1순위: 원본 데이터에서 채번 규칙 ID 확인 + // 2순위: 화면 설정에서 채번 규칙 ID 확인 + const numberingRuleId = rowData[ruleIdKey] || screenNumberingRules[field]; + const hasNumberingRule = numberingRuleId !== undefined && numberingRuleId !== null && numberingRuleId !== ""; // 품목코드를 무조건 공백으로 초기화 copiedData[field] = ""; // 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성) if (hasNumberingRule) { - copiedData[ruleIdKey] = rowData[ruleIdKey]; + copiedData[ruleIdKey] = numberingRuleId; console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`); - console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`); + console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`); } else { console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`); } @@ -3317,9 +3387,9 @@ export class ButtonActionExecutor { switch (editMode) { case "modal": - // 모달로 복사 폼 열기 (편집 모달 재사용) - console.log("📋 모달로 복사 폼 열기"); - await this.openEditModal(config, rowData, context); + // 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로) + console.log("📋 모달로 복사 폼 열기 (INSERT 모드)"); + await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true break; case "navigate": @@ -3330,8 +3400,8 @@ export class ButtonActionExecutor { default: // 기본값: 모달 - console.log("📋 기본 모달로 복사 폼 열기"); - this.openEditModal(config, rowData, context); + console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)"); + this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true } } catch (error: any) { console.error("❌ openCopyForm 실행 중 오류:", error); @@ -4934,26 +5004,35 @@ export class ButtonActionExecutor { const { oldValue, newValue } = confirmed; - // 미리보기 표시 (옵션) + // 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색) if (config.mergeShowPreview !== false) { const { apiClient } = await import("@/lib/api/client"); - const previewResponse = await apiClient.post("/code-merge/preview", { - columnName, + toast.loading("영향받는 데이터 검색 중...", { duration: Infinity }); + + const previewResponse = await apiClient.post("/code-merge/preview-by-value", { oldValue, }); + toast.dismiss(); + if (previewResponse.data.success) { const preview = previewResponse.data.data; const totalRows = preview.totalAffectedRows; + // 상세 정보 생성 + const detailList = preview.preview + .map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`) + .join("\n"); + const confirmMerge = confirm( - "⚠️ 코드 병합 확인\n\n" + + "코드 병합 확인\n\n" + `${oldValue} → ${newValue}\n\n` + "영향받는 데이터:\n" + - `- 테이블 수: ${preview.preview.length}개\n` + + `- 테이블/컬럼 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + - `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + + (preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") + + "모든 테이블에서 해당 값이 변경됩니다.\n\n" + "계속하시겠습니까?", ); @@ -4963,13 +5042,12 @@ export class ButtonActionExecutor { } } - // 병합 실행 + // 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼) toast.loading("코드 병합 중...", { duration: Infinity }); const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.post("/code-merge/merge-all-tables", { - columnName, + const response = await apiClient.post("/code-merge/merge-by-value", { oldValue, newValue, }); @@ -4978,9 +5056,17 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; + + // 변경된 테이블/컬럼 목록 생성 + const changedList = data.affectedData + .map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`) + .join(", "); + toast.success( - "코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, + `코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`, ); + + console.log("코드 병합 결과:", data.affectedData); // 화면 새로고침 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/screen-management.ts b/frontend/types/screen-management.ts index 646632f5..89127c1a 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -365,6 +365,8 @@ export interface EntityTypeConfig { separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') // UI 모드 uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo" + // 다중 선택 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) } /**