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/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/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/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/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 98db1eee..7df10fdb 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( @@ -4630,4 +4665,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/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/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/index.ts b/frontend/lib/registry/components/index.ts index e28e1755..9ca202ed 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인 // 🆕 연관 데이터 버튼 컴포넌트 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 +// 🆕 피벗 그리드 컴포넌트 +import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블 + /** * 컴포넌트 초기화 함수 */ 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..b81057a3 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -0,0 +1,644 @@ +"use client"; + +/** + * PivotGrid 메인 컴포넌트 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +import React, { useState, useMemo, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { + PivotGridProps, + PivotResult, + PivotFieldConfig, + PivotCellData, + PivotFlatRow, + PivotCellValue, + PivotGridState, +} from "./types"; +import { processPivotData, pathToKey } from "./utils/pivotEngine"; +import { + ChevronRight, + ChevronDown, + Download, + Settings, + RefreshCw, + Maximize2, + Minimize2, +} 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; +} + +const DataCell: React.FC = ({ + values, + isTotal = false, + onClick, +}) => { + if (!values || values.length === 0) { + return ( + + ); + } + + // 단일 데이터 필드인 경우 + if (values.length === 1) { + return ( + + ); + } + + // 다중 데이터 필드인 경우 + return ( + <> + {values.map((val, idx) => ( + + ))} + + ); +}; + +// ==================== 메인 컴포넌트 ==================== + +export const PivotGridComponent: React.FC = ({ + title, + fields = [], + totals = { + showRowGrandTotals: true, + showColumnGrandTotals: true, + showRowTotals: true, + showColumnTotals: true, + }, + style = { + theme: "default", + headerStyle: "default", + cellPadding: "normal", + borderStyle: "light", + alternateRowColors: true, + highlightTotals: true, + }, + allowExpandAll = true, + height = "auto", + maxHeight, + exportConfig, + data: externalData, + onCellClick, + onExpandChange, +}) => { + // ==================== 상태 ==================== + + const [pivotState, setPivotState] = useState({ + expandedRowPaths: [], + expandedColumnPaths: [], + sortConfig: null, + filterConfig: {}, + }); + + const [isFullscreen, setIsFullscreen] = useState(false); + + // 데이터 + 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 pivotResult = useMemo(() => { + if (!data || data.length === 0 || fields.length === 0) { + return null; + } + + return processPivotData( + data, + fields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + }, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // ==================== 이벤트 핸들러 ==================== + + // 행 확장/축소 + 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] + ); + + // 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]); + + // ==================== 렌더링 ==================== + + // 빈 상태 + if (!data || data.length === 0) { + return ( +
+ +

데이터가 없습니다

+

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

+
+ ); + } + + // 필드 미설정 + if (fields.length === 0) { + return ( +
+ +

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

+

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

+
+ ); + } + + // 피벗 결과 없음 + if (!pivotResult) { + return ( +
+ +
+ ); + } + + const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult; + + return ( +
+ {/* 헤더 툴바 */} +
+
+ {title &&

{title}

} + + ({data.length}건) + +
+ +
+ {allowExpandAll && ( + <> + + + + + )} + + {exportConfig?.excel && ( + + )} + + +
+
+ + {/* 피벗 테이블 */} +
+
+ 선택 + ))} - - 선택 - + 선택 +
+

검색 중...

+ 검색 결과가 없습니다
+ handleSelect(item)} + onClick={(e) => e.stopPropagation()} + /> + - {item[col] || "-"} - - -
+ +
+
+ {row.hasChildren && ( + + )} + {!row.hasChildren && } + {row.caption} +
+
+ - + + {values[0].formattedValue} + + {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) || []; + + return ( + handleCellClick(row.path, col.path, values) + : undefined + } + /> + ); + })} + + {/* 행 총계 */} + {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} +
+ 총계 +
+
+
+ ); +}; + +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..a0e322d9 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -0,0 +1,751 @@ +"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 { + const response = await apiClient.get("/api/table-management/list"); + 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 { + const response = await apiClient.get( + `/api/table-management/columns/${config.dataSource.tableName}` + ); + 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({ 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..7c34192a --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -0,0 +1,84 @@ +"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"; + +/** + * PivotGrid 컴포넌트 정의 + */ +const PivotGridDefinition = createComponentDefinition({ + id: "pivot-grid", + name: "피벗 그리드", + nameEng: "PivotGrid Component", + description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: PivotGridComponent, + defaultConfig: { + dataSource: { + type: "table", + tableName: "", + }, + fields: [], + 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 { + 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/index.ts b/frontend/lib/registry/components/pivot-grid/index.ts new file mode 100644 index 00000000..16044dbc --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/index.ts @@ -0,0 +1,61 @@ +/** + * PivotGrid 컴포넌트 모듈 + * 다차원 데이터 분석을 위한 피벗 테이블 + */ + +// 타입 내보내기 +export type { + // 기본 타입 + PivotAreaType, + AggregationType, + 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..e0ea3199 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -0,0 +1,346 @@ +/** + * PivotGrid 컴포넌트 타입 정의 + * 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트 + */ + +// ==================== 기본 타입 ==================== + +// 필드 영역 타입 +export type PivotAreaType = "row" | "column" | "data" | "filter"; + +// 집계 함수 타입 +export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct"; + +// 정렬 방향 +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; // 집계 함수 + + // 정렬 설정 + 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 PivotStyleConfig { + theme: "default" | "compact" | "modern"; + headerStyle: "default" | "dark" | "light"; + cellPadding: "compact" | "normal" | "comfortable"; + borderStyle: "none" | "light" | "heavy"; + alternateRowColors?: boolean; + highlightTotals?: boolean; // 총합계 강조 +} + +// ==================== 내보내기 설정 ==================== + +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/index.ts b/frontend/lib/registry/components/pivot-grid/utils/index.ts new file mode 100644 index 00000000..f832187e --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./aggregation"; +export * from "./pivotEngine"; + + 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..18113066 --- /dev/null +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -0,0 +1,621 @@ +/** + * PivotGrid 데이터 처리 엔진 + * 원시 데이터를 피벗 구조로 변환합니다. + */ + +import { + PivotFieldConfig, + PivotResult, + PivotHeaderNode, + PivotFlatRow, + PivotFlatColumn, + PivotCellValue, + DateGroupInterval, + AggregationType, +} 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; +} + +// ==================== 총합계 계산 ==================== + +/** + * 총합계 계산 + */ +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); + + // 데이터 매트릭스 생성 + const dataMatrix = buildDataMatrix( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + // 총합계 계산 + const grandTotals = calculateGrandTotals( + filteredData, + rowFields, + columnFields, + dataFields, + flatRows, + flatColumnLeaves + ); + + 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/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index afc5c13e..c3991ae3 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: 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") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -899,16 +900,54 @@ 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]); + } } }); @@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) + } else { + console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); + setRightData([]); } } } @@ -1409,27 +1451,8 @@ export const SplitPanelLayoutComponent: React.FC // 커스텀 모달 화면 열기 const rightTableName = componentConfig.rightPanel?.tableName || ""; - // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) - let primaryKeyName = "id"; - let primaryKeyValue: any; - - if (item.id !== undefined && item.id !== null) { - primaryKeyName = "id"; - primaryKeyValue = item.id; - } else if (item.ID !== undefined && item.ID !== null) { - primaryKeyName = "ID"; - primaryKeyValue = item.ID; - } else { - // 첫 번째 필드를 Primary Key로 간주 - const firstKey = Object.keys(item)[0]; - primaryKeyName = firstKey; - primaryKeyValue = item[firstKey]; - } - console.log("✅ 수정 모달 열기:", { tableName: rightTableName, - primaryKeyName, - primaryKeyValue, screenId: modalScreenId, fullItem: item, }); @@ -1448,27 +1471,25 @@ export const SplitPanelLayoutComponent: React.FC hasGroupByColumns: groupByColumns.length > 0, }); - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) + // 🔧 수정: URL 파라미터 대신 editData로 직접 전달 + // 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨 window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, - urlParams: { - mode: "edit", - editId: primaryKeyValue, - tableName: rightTableName, - ...(groupByColumns.length > 0 && { + editData: item, // 전체 데이터를 직접 전달 + ...(groupByColumns.length > 0 && { + urlParams: { groupByColumns: JSON.stringify(groupByColumns), - }), - }, + }, + }), }, }), ); - console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { + console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { screenId: modalScreenId, - editId: primaryKeyValue, - tableName: rightTableName, + editData: item, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 5ca50ffb..832107c4 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -429,6 +429,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); @@ -1633,234 +1698,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 6dd5e735..4af6988b 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -815,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}`); @@ -825,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("✅ 채번 규칙 할당 완료"); @@ -3062,6 +3083,7 @@ export class ButtonActionExecutor { config: ButtonActionConfig, rowData: any, context: ButtonActionContext, + isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달 ): Promise { const { groupByColumns = [] } = config; @@ -3135,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, // 🆕 버튼 설정 전달 (제어로직 실행용) @@ -3253,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})`); } @@ -3326,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": @@ -3339,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); @@ -4943,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" + "계속하시겠습니까?", ); @@ -4972,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, }); @@ -4987,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/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) } /**