From 924c95ab89e309a229d4e755c64c58810c18f98c Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 29 Jan 2026 14:45:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20V2=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 통합 관리합니다. - V2 컴포넌트의 overrides 스키마를 정의하고, 관련된 설정 패널을 통합하였습니다. - 화면 복제 기능을 개선하여 DB 구조 개편 후의 효율적인 화면 관리를 지원하며, 버튼의 `targetScreenId` 매핑 버그를 수정하였습니다. - 프리뷰 모드에서 URL 파라미터의 company_code를 우선 사용하도록 변경하였습니다. - UnifiedRepeater 및 UnifiedSelect 컴포넌트를 추가하여 다양한 데이터 관리 기능을 지원합니다. --- PLAN.MD | 22 + .../controllers/screenManagementController.ts | 5 +- .../src/services/screenManagementService.ts | 1258 +++++++++++------ .../app/(main)/screens/[screenId]/page.tsx | 1010 ++++++------- frontend/components/screen/ScreenDesigner.tsx | 14 +- .../components/screen/ScreenSettingModal.tsx | 2 + .../components/unified/UnifiedRepeater.tsx | 948 +++++++++++++ frontend/components/unified/UnifiedSelect.tsx | 814 +++++++++++ frontend/components/v2/V2Group.tsx | 8 +- frontend/components/v2/index.ts | 17 +- .../components/v2/registerV2Components.ts | 2 +- frontend/lib/registry/components/index.ts | 1 - .../table-list/TableListComponent.tsx | 31 +- frontend/lib/utils/layoutV2Converter.ts | 38 +- frontend/types/v2-repeater.ts | 32 +- 15 files changed, 3179 insertions(+), 1023 deletions(-) create mode 100644 frontend/components/unified/UnifiedRepeater.tsx create mode 100644 frontend/components/unified/UnifiedSelect.tsx diff --git a/PLAN.MD b/PLAN.MD index d4ecf2a6..0eff7965 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,15 +1,18 @@ # 프로젝트: V2/V2 컴포넌트 설정 스키마 정비 ## 개요 + 레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다. ## 핵심 기능 + 1. [x] 레거시 컴포넌트 스키마 제거 2. [x] V2 컴포넌트 overrides 스키마 정의 (16개) 3. [x] V2 컴포넌트 overrides 스키마 정의 (9개) 4. [x] componentConfig.ts 한 파일에서 통합 관리 ## 정의된 V2 컴포넌트 (18개) + - v2-table-list, v2-button-primary, v2-text-display - v2-split-panel-layout, v2-section-card, v2-section-paper - v2-divider-line, v2-repeat-container, v2-rack-structure @@ -19,45 +22,56 @@ - v2-v2-repeater ## 정의된 V2 컴포넌트 (9개) + - v2-input, v2-select, v2-date - v2-list, v2-layout, v2-group - v2-media, v2-biz, v2-hierarchy ## 테스트 계획 + ### 1단계: 기본 기능 + - [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과 - [x] V2 컴포넌트 기본값과 스키마가 매칭됨 ### 2단계: 에러 케이스 + - [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback) - [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체) ## 에러 처리 계획 + - 스키마 파싱 실패 시 로그/에러 메시지 표준화 - 기본값 누락 시 안전한 fallback 적용 ## 진행 상태 + - [x] 레거시 컴포넌트 제거 완료 - [x] V2/V2 스키마 정의 완료 - [x] 한 파일 통합 관리 완료 + # 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) ## 개요 + 채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다. ## 핵심 변경사항 ### DB 구조 변경 (완료) + - 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 - 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 - 복제 순서 의존성 문제 해결 ### 복제 옵션 정리 (완료) + - [x] **삭제**: 코드 카테고리 + 코드 복사 옵션 - [x] **삭제**: 연쇄관계 설정 복사 옵션 - [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사" ### 현재 복제 옵션 (3개) + 1. **채번 규칙 복사** - 채번규칙 복제 2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values) 3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제 @@ -67,20 +81,24 @@ ## 테스트 계획 ### 1. 화면 간 연결 복제 테스트 + - [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제 - [ ] 복제 후 연결 관계가 유지되는지 확인 - [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인 ### 2. 제어관리 복제 테스트 + - [ ] 다른 회사로 제어관리 복제 - [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인 ### 3. 추가 옵션 복제 테스트 + - [ ] 채번규칙 복사 정상 작동 확인 - [ ] 카테고리 값 복사 정상 작동 확인 - [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인 ### 4. 기본 복제 테스트 + - [ ] 단일 화면 복제 (모달 포함) - [ ] 그룹 전체 복제 (재귀적) - [ ] 메뉴 동기화 정상 작동 @@ -88,6 +106,7 @@ --- ## 관련 파일 + - `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 - `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 - `backend-node/src/services/screenManagementService.ts` - 복제 서비스 @@ -95,6 +114,7 @@ - `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서 ## 진행 상태 + - [완료] DB 구조 개편 (menu_objid 의존성 제거) - [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경) - [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가) @@ -109,9 +129,11 @@ ### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정 **문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음 + - 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제 **수정 파일**: `backend-node/src/services/screenManagementService.ts` + - `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가 - 쿼리에 `targetScreenId` 검색 조건 추가 - 문자열/숫자 타입 모두 처리 diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 4e679626..83dd2b32 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -696,10 +696,11 @@ export const getLayoutV1 = async (req: AuthenticatedRequest, res: Response) => { export const getLayoutV2 = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId } = req.params; - const { companyCode } = req.user as any; + const { companyCode, userType } = req.user as any; const layout = await screenManagementService.getLayoutV2( parseInt(screenId), - companyCode + companyCode, + userType ); res.json({ success: true, data: layout }); } catch (error) { diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9338188f..7b4f9278 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -18,7 +18,10 @@ import { import { generateId } from "../utils/generateId"; import logger from "../utils/logger"; -import { reconstructConfig, extractConfigDiff } from "../utils/componentDefaults"; +import { + reconstructConfig, + extractConfigDiff, +} from "../utils/componentDefaults"; // 화면 복사 요청 인터페이스 interface CopyScreenRequest { @@ -47,7 +50,7 @@ export class ScreenManagementService { */ async createScreen( screenData: CreateScreenRequest, - userCompanyCode: string + userCompanyCode: string, ): Promise { console.log(`=== 화면 생성 요청 ===`); console.log(`요청 데이터:`, screenData); @@ -58,12 +61,12 @@ export class ScreenManagementService { `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' LIMIT 1`, - [screenData.screenCode] + [screenData.screenCode], ); console.log( `화면 코드 '${screenData.screenCode}' 중복 검사 결과:`, - existingResult.length > 0 ? "중복됨" : "사용 가능" + existingResult.length > 0 ? "중복됨" : "사용 가능", ); if (existingResult.length > 0) { @@ -92,7 +95,7 @@ export class ScreenManagementService { (screenData as any).restApiConnectionId || null, (screenData as any).restApiEndpoint || null, (screenData as any).restApiJsonPath || "data", - ] + ], ); return this.mapToScreenDefinition(screen); @@ -105,7 +108,7 @@ export class ScreenManagementService { companyCode: string, page: number = 1, size: number = 20, - searchTerm?: string // 검색어 추가 + searchTerm?: string, // 검색어 추가 ): Promise> { const offset = (page - 1) * size; @@ -137,12 +140,12 @@ export class ScreenManagementService { WHERE ${whereSQL} ORDER BY created_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, - [...params, size, offset] + [...params, size, offset], ), query<{ count: string }>( `SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`, - params + params, ), ]); @@ -150,7 +153,7 @@ export class ScreenManagementService { // 테이블 라벨 정보를 한 번에 조회 (Raw Query) const tableNames = Array.from( - new Set(screens.map((s: any) => s.table_name).filter(Boolean)) + new Set(screens.map((s: any) => s.table_name).filter(Boolean)), ); let tableLabelMap = new Map(); @@ -164,21 +167,21 @@ export class ScreenManagementService { }>( `SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`, - tableNames + tableNames, ); tableLabelMap = new Map( tableLabels.map((tl) => [ tl.table_name, tl.table_label || tl.table_name, - ]) + ]), ); // 테스트: company_mng 라벨 직접 확인 if (tableLabelMap.has("company_mng")) { console.log( "✅ company_mng 라벨 찾음:", - tableLabelMap.get("company_mng") + tableLabelMap.get("company_mng"), ); } else { console.log("❌ company_mng 라벨 없음"); @@ -190,7 +193,7 @@ export class ScreenManagementService { return { data: screens.map((screen) => - this.mapToScreenDefinition(screen, tableLabelMap) + this.mapToScreenDefinition(screen, tableLabelMap), ), pagination: { page, @@ -220,7 +223,7 @@ export class ScreenManagementService { `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY created_date DESC`, - params + params, ); return screens.map((screen) => this.mapToScreenDefinition(screen)); @@ -234,7 +237,7 @@ export class ScreenManagementService { `SELECT * FROM screen_definitions WHERE screen_id = $1 AND is_active != 'D' LIMIT 1`, - [screenId] + [screenId], ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; @@ -245,7 +248,7 @@ export class ScreenManagementService { */ async getScreen( screenId: number, - companyCode: string + companyCode: string, ): Promise { // 동적 WHERE 절 생성 const whereConditions: string[] = [ @@ -266,7 +269,7 @@ export class ScreenManagementService { `SELECT * FROM screen_definitions WHERE ${whereSQL} LIMIT 1`, - params + params, ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; @@ -278,12 +281,12 @@ export class ScreenManagementService { async updateScreen( screenId: number, updateData: UpdateScreenRequest, - userCompanyCode: string + userCompanyCode: string, ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (existingResult.length === 0) { @@ -316,7 +319,7 @@ export class ScreenManagementService { updateData.updatedBy, new Date(), screenId, - ] + ], ); return this.mapToScreenDefinition(screen); @@ -340,12 +343,12 @@ export class ScreenManagementService { restApiEndpoint?: string; restApiJsonPath?: string; }, - userCompanyCode: string + userCompanyCode: string, ): Promise { // 권한 확인 const existingResult = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (existingResult.length === 0) { @@ -389,7 +392,7 @@ export class ScreenManagementService { updateData.restApiEndpoint || null, updateData.restApiJsonPath || null, screenId, - ] + ], ); console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { @@ -405,7 +408,7 @@ export class ScreenManagementService { */ async checkScreenDependencies( screenId: number, - userCompanyCode: string + userCompanyCode: string, ): Promise<{ hasDependencies: boolean; dependencies: Array<{ @@ -420,7 +423,7 @@ export class ScreenManagementService { // 권한 확인 const targetScreens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (targetScreens.length === 0) { @@ -443,7 +446,7 @@ export class ScreenManagementService { if (userCompanyCode !== "*") { whereConditions.push( - `sd.company_code IN ($${params.length + 1}, $${params.length + 2})` + `sd.company_code IN ($${params.length + 1}, $${params.length + 2})`, ); params.push(userCompanyCode, "*"); } @@ -459,7 +462,7 @@ export class ScreenManagementService { LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE ${whereSQL} ORDER BY sd.screen_id, sl.layout_id`, - params + params, ); const dependencies: Array<{ @@ -537,7 +540,7 @@ export class ScreenManagementService { } catch (error) { console.error( `화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`, - error + error, ); continue; } @@ -554,7 +557,7 @@ export class ScreenManagementService { FROM screen_menu_assignments sma LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid WHERE sma.screen_id = $1 AND sma.is_active = 'Y'`, - [screenId] + [screenId], ); // 메뉴에 할당된 경우 의존성에 추가 @@ -587,7 +590,7 @@ export class ScreenManagementService { userCompanyCode: string, deletedBy: string, deleteReason?: string, - force: boolean = false + force: boolean = false, ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ @@ -595,7 +598,7 @@ export class ScreenManagementService { is_active: string; }>( `SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (existingResult.length === 0) { @@ -620,7 +623,7 @@ export class ScreenManagementService { if (!force) { const dependencyCheck = await this.checkScreenDependencies( screenId, - userCompanyCode + userCompanyCode, ); if (dependencyCheck.hasDependencies) { const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any; @@ -649,7 +652,7 @@ export class ScreenManagementService { new Date(), deletedBy, screenId, - ] + ], ); // 메뉴 할당도 비활성화 @@ -657,7 +660,7 @@ export class ScreenManagementService { `UPDATE screen_menu_assignments SET is_active = 'N' WHERE screen_id = $1 AND is_active = 'Y'`, - [screenId] + [screenId], ); }); } @@ -668,7 +671,7 @@ export class ScreenManagementService { async restoreScreen( screenId: number, userCompanyCode: string, - restoredBy: string + restoredBy: string, ): Promise { // 권한 확인 const screens = await query<{ @@ -677,7 +680,7 @@ export class ScreenManagementService { screen_code: string; }>( `SELECT company_code, is_active, screen_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -703,12 +706,12 @@ export class ScreenManagementService { `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' AND screen_id != $2 LIMIT 1`, - [existingScreen.screen_code, screenId] + [existingScreen.screen_code, screenId], ); if (duplicateScreens.length > 0) { throw new Error( - "같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요." + "같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요.", ); } @@ -720,7 +723,7 @@ export class ScreenManagementService { SET is_active = 'Y', deleted_date = NULL, deleted_by = NULL, delete_reason = NULL, updated_date = $1, updated_by = $2 WHERE screen_id = $3`, - [new Date(), restoredBy, screenId] + [new Date(), restoredBy, screenId], ); // 메뉴 할당도 다시 활성화 @@ -728,7 +731,7 @@ export class ScreenManagementService { `UPDATE screen_menu_assignments SET is_active = 'Y' WHERE screen_id = $1 AND is_active = 'N'`, - [screenId] + [screenId], ); }); } @@ -748,7 +751,7 @@ export class ScreenManagementService { FROM screen_definitions WHERE is_active = 'D' ) AND is_active = 'Y'`, - [] + [], ); const updatedCount = result.length; @@ -764,7 +767,7 @@ export class ScreenManagementService { */ async permanentDeleteScreen( screenId: number, - userCompanyCode: string + userCompanyCode: string, ): Promise { // 권한 확인 const screens = await query<{ @@ -772,7 +775,7 @@ export class ScreenManagementService { is_active: string; }>( `SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -800,11 +803,11 @@ export class ScreenManagementService { ]); await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, - [screenId] + [screenId], ); await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, - [screenId] + [screenId], ); }); } @@ -815,7 +818,7 @@ export class ScreenManagementService { async getDeletedScreens( companyCode: string, page: number = 1, - size: number = 20 + size: number = 20, ): Promise< PaginatedResponse< ScreenDefinition & { @@ -842,11 +845,11 @@ export class ScreenManagementService { WHERE ${whereSQL} ORDER BY deleted_date DESC NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, - [...params, size, offset] + [...params, size, offset], ), query<{ count: string }>( `SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`, - params + params, ), ]); @@ -854,7 +857,7 @@ export class ScreenManagementService { // 테이블 라벨 정보를 한 번에 조회 const tableNames = Array.from( - new Set(screens.map((s: any) => s.table_name).filter(Boolean)) + new Set(screens.map((s: any) => s.table_name).filter(Boolean)), ); let tableLabelMap = new Map(); @@ -866,14 +869,14 @@ export class ScreenManagementService { table_label: string | null; }>( `SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`, - tableNames + tableNames, ); tableLabelMap = new Map( tableLabels.map((tl: any) => [ tl.table_name, tl.table_label || tl.table_name, - ]) + ]), ); } @@ -901,7 +904,7 @@ export class ScreenManagementService { userCompanyCode: string, deletedBy: string, deleteReason?: string, - force: boolean = false + force: boolean = false, ): Promise<{ deletedCount: number; skippedCount: number; @@ -925,7 +928,7 @@ export class ScreenManagementService { screen_name: string; }>( `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (existingResult.length === 0) { @@ -966,7 +969,7 @@ export class ScreenManagementService { if (!force) { const dependencyCheck = await this.checkScreenDependencies( screenId, - userCompanyCode + userCompanyCode, ); if (dependencyCheck.hasDependencies) { skippedCount++; @@ -981,7 +984,7 @@ export class ScreenManagementService { // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 await transaction(async (client) => { const now = new Date(); - + // 소프트 삭제 (휴지통으로 이동) await client.query( `UPDATE screen_definitions @@ -992,18 +995,20 @@ export class ScreenManagementService { updated_date = $4, updated_by = $5 WHERE screen_id = $6`, - [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + [now, deletedBy, deleteReason || null, now, deletedBy, screenId], ); // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, - [screenId] + [screenId], ); }); deletedCount++; - logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + logger.info( + `화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`, + ); } catch (error) { skippedCount++; errors.push({ @@ -1015,7 +1020,7 @@ export class ScreenManagementService { } logger.info( - `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`, ); return { deletedCount, skippedCount, errors }; @@ -1026,7 +1031,7 @@ export class ScreenManagementService { */ async bulkPermanentDeleteScreens( screenIds: number[], - userCompanyCode: string + userCompanyCode: string, ): Promise<{ deletedCount: number; skippedCount: number; @@ -1059,7 +1064,7 @@ export class ScreenManagementService { const screensToDelete = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE ${whereSQL}`, - params + params, ); let deletedCount = 0; @@ -1070,7 +1075,7 @@ export class ScreenManagementService { for (const screenId of screenIds) { try { const screenToDelete = screensToDelete.find( - (s: any) => s.screen_id === screenId + (s: any) => s.screen_id === screenId, ); if (!screenToDelete) { @@ -1087,19 +1092,19 @@ export class ScreenManagementService { // screen_layouts 삭제 await client.query( `DELETE FROM screen_layouts WHERE screen_id = $1`, - [screenId] + [screenId], ); // screen_menu_assignments 삭제 await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, - [screenId] + [screenId], ); // screen_definitions 삭제 await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, - [screenId] + [screenId], ); }); @@ -1137,7 +1142,7 @@ export class ScreenManagementService { WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`, - [] + [], ); // 각 테이블의 컬럼 정보도 함께 조회 @@ -1146,7 +1151,7 @@ export class ScreenManagementService { for (const table of tables) { const columns = await this.getTableColumns( table.table_name, - companyCode + companyCode, ); if (columns.length > 0) { tableInfos.push({ @@ -1169,7 +1174,7 @@ export class ScreenManagementService { */ async getTableInfo( tableName: string, - companyCode: string + companyCode: string, ): Promise { try { console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`); @@ -1181,7 +1186,7 @@ export class ScreenManagementService { WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = $1`, - [tableName] + [tableName], ); if (tableExists.length === 0) { @@ -1204,7 +1209,7 @@ export class ScreenManagementService { }; console.log( - `단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}개` + `단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}개`, ); return tableInfo; } catch (error) { @@ -1218,7 +1223,7 @@ export class ScreenManagementService { */ async getTableColumns( tableName: string, - companyCode: string + companyCode: string, ): Promise { try { // 테이블 컬럼 정보 조회 @@ -1243,13 +1248,15 @@ export class ScreenManagementService { WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, - [tableName] + [tableName], ); // 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음) // 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리 - console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`); - + console.log( + `🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`, + ); + const typeInfo = await query<{ column_name: string; input_type: string | null; @@ -1259,14 +1266,21 @@ export class ScreenManagementService { FROM table_type_columns WHERE table_name = $1 AND company_code = $2 - ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지) - [tableName, companyCode] + ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지) + [tableName, companyCode], ); - console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`); - const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code'); + console.log( + `📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`, + ); + const currencyCodeType = typeInfo.find( + (t) => t.column_name === "currency_code", + ); if (currencyCodeType) { - console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType); + console.log( + `💰 [getTableColumns] currency_code 발견:`, + currencyCodeType, + ); } else { console.log(`⚠️ [getTableColumns] currency_code 없음`); } @@ -1279,7 +1293,7 @@ export class ScreenManagementService { `SELECT column_name, column_label FROM table_type_columns WHERE table_name = $1 AND company_code = '*'`, - [tableName] + [tableName], ); // 🆕 category_column_mapping에서 코드 카테고리 정보 조회 @@ -1291,12 +1305,12 @@ export class ScreenManagementService { FROM category_column_mapping WHERE table_name = $1 AND company_code = $2`, - [tableName, companyCode] + [tableName, companyCode], ); // 컬럼 정보 매핑 const columnMap = new Map(); - + // 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성 columns.forEach((column: any) => { columnMap.set(column.column_name, { @@ -1311,7 +1325,9 @@ export class ScreenManagementService { }); }); - console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`); + console.log( + `🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`, + ); // table_type_columns에서 input_type 추가 (중복 시 최신 것만) const addedTypes = new Set(); @@ -1323,20 +1339,25 @@ export class ScreenManagementService { col.webType = type.input_type; // webType도 동일하게 설정 col.detailSettings = type.detail_settings; addedTypes.add(colName); - - if (colName === 'currency_code') { - console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`); + + if (colName === "currency_code") { + console.log( + `✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`, + ); } } }); - console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`); + console.log( + `🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`, + ); // table_type_columns에서 라벨 추가 labelInfo.forEach((label) => { const col = columnMap.get(label.column_name); if (col) { - col.columnLabel = label.column_label || this.getColumnLabel(label.column_name); + col.columnLabel = + label.column_label || this.getColumnLabel(label.column_name); } }); @@ -1360,12 +1381,14 @@ export class ScreenManagementService { })); // 디버깅: currency_code의 최종 inputType 확인 - const currencyCodeResult = result.find(r => r.columnName === 'currency_code'); + const currencyCodeResult = result.find( + (r) => r.columnName === "currency_code", + ); if (currencyCodeResult) { console.log(`🎯 [getTableColumns] 최종 currency_code:`, { inputType: currencyCodeResult.inputType, webType: currencyCodeResult.webType, - dataType: currencyCodeResult.dataType + dataType: currencyCodeResult.dataType, }); } @@ -1463,7 +1486,7 @@ export class ScreenManagementService { async saveLayout( screenId: number, layoutData: LayoutData, - companyCode: string + companyCode: string, ): Promise { console.log(`=== 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}`); @@ -1475,7 +1498,7 @@ export class ScreenManagementService { // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -1493,7 +1516,7 @@ export class ScreenManagementService { if (mainTableName) { await query( `UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`, - [mainTableName, screenId] + [mainTableName, screenId], ); console.log(`✅ 화면 기본 테이블 업데이트: ${mainTableName}`); } @@ -1524,7 +1547,7 @@ export class ScreenManagementService { 0, JSON.stringify(metadata), -1, // 메타데이터는 맨 앞에 배치 - ] + ], ); console.log(`메타데이터 저장 완료:`, metadata); @@ -1559,7 +1582,14 @@ export class ScreenManagementService { // 🔍 디버깅: webTypeConfig.dataflowConfig 확인 if ((component as any).webTypeConfig?.dataflowConfig) { - console.log(`🔍 컴포넌트 ${component.id}의 dataflowConfig:`, JSON.stringify((component as any).webTypeConfig.dataflowConfig, null, 2)); + console.log( + `🔍 컴포넌트 ${component.id}의 dataflowConfig:`, + JSON.stringify( + (component as any).webTypeConfig.dataflowConfig, + null, + 2, + ), + ); } await query( @@ -1577,7 +1607,7 @@ export class ScreenManagementService { Math.round(component.size.width), // 정수로 반올림 Math.round(component.size.height), // 정수로 반올림 JSON.stringify(properties), - ] + ], ); } @@ -1590,15 +1620,18 @@ export class ScreenManagementService { */ async getLayout( screenId: number, - companyCode: string + companyCode: string, ): Promise { console.log(`=== 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}`); // 권한 확인 및 테이블명 조회 - const screens = await query<{ company_code: string | null; table_name: string | null }>( + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -1615,7 +1648,7 @@ export class ScreenManagementService { let v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, - [screenId, companyCode] + [screenId, companyCode], ); // 회사별 레이아웃 없으면 공통(*) 조회 @@ -1623,7 +1656,7 @@ export class ScreenManagementService { v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = '*'`, - [screenId] + [screenId], ); } @@ -1631,7 +1664,7 @@ export class ScreenManagementService { if (v2Layout && v2Layout.layout_data) { console.log(`V2 레이아웃 발견, V2 형식으로 반환`); const layoutData = v2Layout.layout_data; - + // V2 형식의 components를 LayoutData 형식으로 변환 const components = (layoutData.components || []).map((comp: any) => ({ id: comp.id, @@ -1650,14 +1683,14 @@ export class ScreenManagementService { if (!screenResolution && components.length > 0) { let maxRight = 0; let maxBottom = 0; - + for (const comp of layoutData.components || []) { const right = (comp.position?.x || 0) + (comp.size?.width || 200); const bottom = (comp.position?.y || 0) + (comp.size?.height || 100); maxRight = Math.max(maxRight, right); maxBottom = Math.max(maxBottom, bottom); } - + // 여백 100px 추가, 최소 1200x800 보장 screenResolution = { width: Math.max(1200, maxRight + 100), @@ -1685,17 +1718,17 @@ export class ScreenManagementService { `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST, layout_id ASC`, - [screenId] + [screenId], ); console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); // 메타데이터와 컴포넌트 분리 const metadataLayout = layouts.find( - (layout) => layout.component_type === "_metadata" + (layout) => layout.component_type === "_metadata", ); const componentLayouts = layouts.filter( - (layout) => layout.component_type !== "_metadata" + (layout) => layout.component_type !== "_metadata", ); // 기본 메타데이터 설정 @@ -1729,28 +1762,32 @@ export class ScreenManagementService { } // 🔥 최신 inputType 정보 조회 (table_type_columns에서) - const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const inputTypeMap = await this.getLatestInputTypes( + componentLayouts, + companyCode, + ); const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; - + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 const tableName = properties?.tableName; const columnName = properties?.columnName; - const latestTypeInfo = tableName && columnName - ? inputTypeMap.get(`${tableName}.${columnName}`) - : null; - + const latestTypeInfo = + tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + // 🆕 V2 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호) const savedComponentType = properties?.componentType; const isV2Component = savedComponentType?.startsWith("v2-"); - + const component = { id: layout.component_id, // 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, V2 컴포넌트는 제외) - type: isV2Component - ? layout.component_type as any // V2는 저장된 값 유지 - : (latestTypeInfo?.componentType || layout.component_type as any), + type: isV2Component + ? (layout.component_type as any) // V2는 저장된 값 유지 + : latestTypeInfo?.componentType || (layout.component_type as any), position: { x: layout.position_x, y: layout.position_y, @@ -1760,16 +1797,17 @@ export class ScreenManagementService { parentId: layout.parent_id, ...properties, // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, V2 컴포넌트는 제외) - ...(!isV2Component && latestTypeInfo && { - widgetType: latestTypeInfo.inputType, - inputType: latestTypeInfo.inputType, - componentType: latestTypeInfo.componentType, - componentConfig: { - ...properties?.componentConfig, - type: latestTypeInfo.componentType, + ...(!isV2Component && + latestTypeInfo && { + widgetType: latestTypeInfo.inputType, inputType: latestTypeInfo.inputType, - }, - }), + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1804,7 +1842,7 @@ export class ScreenManagementService { /** * V1 레이아웃 조회 (component_url + custom_config 기반) * screen_layouts_v1 테이블에서 조회 - * + * * 🔒 확정 사항: * - component_url: 컴포넌트 파일 경로 (필수, NOT NULL) * - custom_config: 회사별 커스텀 설정 (slot 포함) @@ -1812,15 +1850,18 @@ export class ScreenManagementService { */ async getLayoutV1( screenId: number, - companyCode: string + companyCode: string, ): Promise { console.log(`=== V1 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); // 권한 확인 및 테이블명 조회 - const screens = await query<{ company_code: string | null; table_name: string | null }>( + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -1839,7 +1880,7 @@ export class ScreenManagementService { WHERE screen_id = $1 AND (company_code = $2 OR $2 = '*') ORDER BY display_order ASC NULLS LAST, layout_id ASC`, - [screenId, companyCode] + [screenId, companyCode], ); console.log(`V1 DB에서 조회된 레이아웃 수: ${layouts.length}`); @@ -1863,23 +1904,23 @@ export class ScreenManagementService { // "@/lib/registry/components/split-panel-layout" → "split-panel-layout" const componentUrl = layout.component_url || ""; const componentType = componentUrl.split("/").pop() || "unknown"; - + // custom_config가 곧 componentConfig const componentConfig = layout.custom_config || {}; - + const component = { id: layout.component_id, type: componentType as any, componentType: componentType, - componentUrl: componentUrl, // URL도 전달 + componentUrl: componentUrl, // URL도 전달 position: { x: layout.position_x, y: layout.position_y, z: 1, }, - size: { - width: layout.width, - height: layout.height + size: { + width: layout.width, + height: layout.height, }, parentId: layout.parent_id, componentConfig, @@ -1959,16 +2000,21 @@ export class ScreenManagementService { */ private async getLatestInputTypes( layouts: any[], - companyCode: string + companyCode: string, ): Promise> { - const inputTypeMap = new Map(); + const inputTypeMap = new Map< + string, + { inputType: string; componentType: string } + >(); // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 const tableColumnPairs = new Set(); for (const layout of layouts) { const properties = layout.properties as any; if (properties?.tableName && properties?.columnName) { - tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + tableColumnPairs.add( + `${properties.tableName}|${properties.columnName}`, + ); } } @@ -1977,22 +2023,28 @@ export class ScreenManagementService { } // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 - const pairs = Array.from(tableColumnPairs).map(pair => { - const [tableName, columnName] = pair.split('|'); + const pairs = Array.from(tableColumnPairs).map((pair) => { + const [tableName, columnName] = pair.split("|"); return { tableName, columnName }; }); // 배치 쿼리로 한 번에 조회 - const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); - const params = pairs.flatMap(p => [p.tableName, p.columnName]); - + const placeholders = pairs + .map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`) + .join(", "); + const params = pairs.flatMap((p) => [p.tableName, p.columnName]); + try { - const results = await query<{ table_name: string; column_name: string; input_type: string }>( + const results = await query<{ + table_name: string; + column_name: string; + input_type: string; + }>( `SELECT table_name, column_name, input_type FROM table_type_columns WHERE (table_name, column_name) IN (${placeholders}) AND company_code = $${params.length + 1}`, - [...params, companyCode] + [...params, companyCode], ); for (const row of results) { @@ -2021,7 +2073,7 @@ export class ScreenManagementService { async getTemplatesByCompany( companyCode: string, type?: string, - isPublic?: boolean + isPublic?: boolean, ): Promise { const whereConditions: string[] = []; const params: any[] = []; @@ -2050,7 +2102,7 @@ export class ScreenManagementService { `SELECT * FROM screen_templates ${whereSQL} ORDER BY created_date DESC`, - params + params, ); return templates.map(this.mapToScreenTemplate); @@ -2060,7 +2112,7 @@ export class ScreenManagementService { * 템플릿 생성 (✅ Raw Query 전환 완료) */ async createTemplate( - templateData: Partial + templateData: Partial, ): Promise { const [template] = await query( `INSERT INTO screen_templates ( @@ -2078,7 +2130,7 @@ export class ScreenManagementService { : null, templateData.isPublic || false, templateData.createdBy || null, - ] + ], ); return this.mapToScreenTemplate(template); @@ -2093,14 +2145,14 @@ export class ScreenManagementService { */ async assignScreenToMenu( screenId: number, - assignmentData: MenuAssignmentRequest + assignmentData: MenuAssignmentRequest, ): Promise { // 중복 할당 방지 const existing = await query<{ assignment_id: number }>( `SELECT assignment_id FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3 LIMIT 1`, - [screenId, assignmentData.menuObjid, assignmentData.companyCode] + [screenId, assignmentData.menuObjid, assignmentData.companyCode], ); if (existing.length > 0) { @@ -2118,13 +2170,13 @@ export class ScreenManagementService { assignmentData.companyCode, assignmentData.displayOrder || 0, assignmentData.createdBy || null, - ] + ], ); // 화면 정보 조회 (screen_code 가져오기) const screen = await queryOne<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions WHERE screen_id = $1`, - [screenId] + [screenId], ); if (screen) { @@ -2132,11 +2184,12 @@ export class ScreenManagementService { // 관리자 메뉴인지 확인 const menu = await queryOne<{ menu_type: string }>( `SELECT menu_type FROM menu_info WHERE objid = $1`, - [assignmentData.menuObjid] + [assignmentData.menuObjid], ); - const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin"); - const menuUrl = isAdminMenu + const isAdminMenu = + menu && (menu.menu_type === "0" || menu.menu_type === "admin"); + const menuUrl = isAdminMenu ? `/screens/${screenId}?mode=admin` : `/screens/${screenId}`; @@ -2144,7 +2197,7 @@ export class ScreenManagementService { `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, - [menuUrl, screen.screen_code, assignmentData.menuObjid] + [menuUrl, screen.screen_code, assignmentData.menuObjid], ); logger.info("화면 할당 완료 (menu_info 업데이트)", { @@ -2161,7 +2214,7 @@ export class ScreenManagementService { */ async getScreensByMenu( menuObjid: number, - companyCode: string + companyCode: string, ): Promise { const screens = await query( `SELECT sd.* FROM screen_menu_assignments sma @@ -2170,7 +2223,7 @@ export class ScreenManagementService { AND sma.company_code = $2 AND sma.is_active = 'Y' ORDER BY sma.display_order ASC`, - [menuObjid, companyCode] + [menuObjid, companyCode], ); return screens.map((screen) => this.mapToScreenDefinition(screen)); @@ -2182,7 +2235,7 @@ export class ScreenManagementService { */ async getMenuByScreen( screenId: number, - companyCode: string + companyCode: string, ): Promise<{ menuObjid: number; menuName?: string } | null> { const result = await queryOne<{ menu_objid: string; @@ -2196,7 +2249,7 @@ export class ScreenManagementService { AND sma.is_active = 'Y' ORDER BY sma.created_date ASC LIMIT 1`, - [screenId, companyCode] + [screenId, companyCode], ); if (!result) { @@ -2215,13 +2268,13 @@ export class ScreenManagementService { async unassignScreenFromMenu( screenId: number, menuObjid: number, - companyCode: string + companyCode: string, ): Promise { // screen_menu_assignments에서 할당 삭제 await query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`, - [screenId, menuObjid, companyCode] + [screenId, menuObjid, companyCode], ); // menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거) @@ -2229,7 +2282,7 @@ export class ScreenManagementService { `UPDATE menu_info SET menu_url = NULL, screen_code = NULL WHERE objid = $1`, - [menuObjid] + [menuObjid], ); logger.info("화면 할당 해제 완료 (menu_info 업데이트)", { @@ -2271,7 +2324,7 @@ export class ScreenManagementService { AND c.column_name = ttc.column_name AND ttc.company_code = '*' WHERE c.table_name = $1 ORDER BY COALESCE(ttc.display_order, c.ordinal_position)`, - [tableName] + [tableName], ); return columns as ColumnInfo[]; @@ -2284,7 +2337,7 @@ export class ScreenManagementService { tableName: string, columnName: string, webType: WebType, - additionalSettings?: Partial + additionalSettings?: Partial, ): Promise { // UPSERT를 INSERT ... ON CONFLICT로 변환 (table_type_columns 사용) await query( @@ -2323,7 +2376,7 @@ export class ScreenManagementService { additionalSettings?.description || null, new Date(), new Date(), - ] + ], ); } @@ -2447,7 +2500,7 @@ export class ScreenManagementService { private mapToScreenDefinition( data: any, - tableLabelMap?: Map + tableLabelMap?: Map, ): ScreenDefinition { const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name; @@ -2495,10 +2548,13 @@ export class ScreenManagementService { async generateScreenCode(companyCode: string): Promise { return await transaction(async (client) => { // 회사 코드를 숫자로 변환하여 advisory lock ID로 사용 - const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0); - + const lockId = Buffer.from(companyCode).reduce( + (acc, byte) => acc + byte, + 0, + ); + // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) - await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); + await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]); // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) // LIMIT 제거하고 숫자 추출하여 최대값 찾기 @@ -2506,16 +2562,18 @@ export class ScreenManagementService { `SELECT screen_code FROM screen_definitions WHERE screen_code LIKE $1 ORDER BY screen_code DESC`, - [`${companyCode}_%`] + [`${companyCode}_%`], ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`, ); - console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log( + `🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`, + ); console.log(`🔍 패턴: ${pattern}`); for (const screen of existingScreens.rows) { @@ -2533,8 +2591,10 @@ export class ScreenManagementService { const nextNumber = maxNumber + 1; // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 const newCode = `${companyCode}_${nextNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); - + console.log( + `🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`, + ); + return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 }); @@ -2546,16 +2606,22 @@ export class ScreenManagementService { */ async generateMultipleScreenCodes( companyCode: string, - count: number + count: number, ): Promise { return await transaction(async (client) => { // Advisory lock 획득 - const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0); - await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); + const lockId = Buffer.from(companyCode).reduce( + (acc, byte) => acc + byte, + 0, + ); + await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]); // 현재 최대 번호 조회 (숫자 추출 후 정렬) // 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX - const existingScreens = await client.query<{ screen_code: string; num: number }>( + const existingScreens = await client.query<{ + screen_code: string; + num: number; + }>( `SELECT screen_code, COALESCE( NULLIF( @@ -2570,7 +2636,10 @@ export class ScreenManagementService { AND deleted_date IS NULL ORDER BY num DESC LIMIT 1`, - [companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`] + [ + companyCode, + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`, + ], ); let maxNumber = 0; @@ -2588,8 +2657,10 @@ export class ScreenManagementService { codes.push(`${companyCode}_${paddedNumber}`); } - console.log(`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(', ')}]`); - + console.log( + `🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(", ")}]`, + ); + return codes; }); } @@ -2600,7 +2671,7 @@ export class ScreenManagementService { */ async checkDuplicateScreenName( companyCode: string, - screenName: string + screenName: string, ): Promise { const result = await query( `SELECT COUNT(*) as count @@ -2608,7 +2679,7 @@ export class ScreenManagementService { WHERE company_code = $1 AND screen_name = $2 AND deleted_date IS NULL`, - [companyCode, screenName] + [companyCode, screenName], ); const count = parseInt(result[0]?.count || "0", 10); @@ -2622,10 +2693,10 @@ export class ScreenManagementService { * - 중첩된 화면들도 모두 감지 (재귀) */ async detectLinkedModalScreens( - screenId: number + screenId: number, ): Promise<{ screenId: number; screenName: string; screenCode: string }[]> { console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`); - + const allLinkedScreenIds = new Set(); const visited = new Set(); // 무한 루프 방지 const queue: number[] = [screenId]; // BFS 큐 @@ -2633,15 +2704,17 @@ export class ScreenManagementService { // BFS로 연결된 모든 화면 탐색 while (queue.length > 0) { const currentScreenId = queue.shift()!; - + // 이미 방문한 화면은 스킵 (순환 참조 방지) if (visited.has(currentScreenId)) { console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`); continue; } - + visited.add(currentScreenId); - console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`); + console.log( + `\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`, + ); // 현재 화면의 모든 레이아웃 조회 const layouts = await query( @@ -2650,7 +2723,7 @@ export class ScreenManagementService { WHERE screen_id = $1 AND component_type = 'component' AND properties IS NOT NULL`, - [currentScreenId] + [currentScreenId], ); console.log(` 📦 레이아웃 개수: ${layouts.length}`); @@ -2659,15 +2732,29 @@ export class ScreenManagementService { for (const layout of layouts) { try { const properties = layout.properties; - + // 1. 버튼 컴포넌트의 액션 확인 - if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) { + if ( + properties?.componentType === "button" || + properties?.componentType?.startsWith("button-") + ) { const action = properties?.componentConfig?.action; - - const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; - if (modalActionTypes.includes(action?.type) && action?.targetScreenId) { + + const modalActionTypes = [ + "popup", + "modal", + "edit", + "openModalWithData", + ]; + if ( + modalActionTypes.includes(action?.type) && + action?.targetScreenId + ) { const targetScreenId = parseInt(action.targetScreenId); - if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) { + if ( + !isNaN(targetScreenId) && + targetScreenId !== currentScreenId + ) { // 메인 화면이 아닌 경우에만 추가 if (targetScreenId !== screenId) { allLinkedScreenIds.add(targetScreenId); @@ -2675,20 +2762,25 @@ export class ScreenManagementService { // 아직 방문하지 않은 화면이면 큐에 추가 if (!visited.has(targetScreenId)) { queue.push(targetScreenId); - console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`); + console.log( + ` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`, + ); } } } } - + // 2. conditional-container 컴포넌트의 sections 확인 if (properties?.componentType === "conditional-container") { const sections = properties?.componentConfig?.sections || []; - + for (const section of sections) { if (section?.screenId) { const sectionScreenId = parseInt(section.screenId); - if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) { + if ( + !isNaN(sectionScreenId) && + sectionScreenId !== currentScreenId + ) { // 메인 화면이 아닌 경우에만 추가 if (sectionScreenId !== screenId) { allLinkedScreenIds.add(sectionScreenId); @@ -2696,7 +2788,9 @@ export class ScreenManagementService { // 아직 방문하지 않은 화면이면 큐에 추가 if (!visited.has(sectionScreenId)) { queue.push(sectionScreenId); - console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`); + console.log( + ` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`, + ); } } } @@ -2708,9 +2802,13 @@ export class ScreenManagementService { } } - console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`); + console.log( + `\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`, + ); console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`); - console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`); + console.log( + ` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`, + ); // 감지된 화면 ID들의 정보 조회 if (allLinkedScreenIds.size === 0) { @@ -2720,19 +2818,21 @@ export class ScreenManagementService { const screenIds = Array.from(allLinkedScreenIds); const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", "); - + const linkedScreens = await query( `SELECT screen_id, screen_name, screen_code FROM screen_definitions WHERE screen_id IN (${placeholders}) AND deleted_date IS NULL ORDER BY screen_name`, - screenIds + screenIds, ); console.log(`\n📋 최종 감지된 화면 목록:`); linkedScreens.forEach((s: any) => { - console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`); + console.log( + ` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`, + ); }); return linkedScreens.map((s) => ({ @@ -2750,17 +2850,22 @@ export class ScreenManagementService { */ private collectNumberingRuleIdsFromLayouts(layouts: any[]): Set { const ruleIds = new Set(); - + for (const layout of layouts) { const props = layout.properties; if (!props) continue; - + // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input 컴포넌트) - const autoGenRuleId = props?.componentConfig?.autoGeneration?.options?.numberingRuleId; - if (autoGenRuleId && typeof autoGenRuleId === 'string' && autoGenRuleId.startsWith('rule-')) { + const autoGenRuleId = + props?.componentConfig?.autoGeneration?.options?.numberingRuleId; + if ( + autoGenRuleId && + typeof autoGenRuleId === "string" && + autoGenRuleId.startsWith("rule-") + ) { ruleIds.add(autoGenRuleId); } - + // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) const sections = props?.componentConfig?.sections; if (Array.isArray(sections)) { @@ -2769,7 +2874,11 @@ export class ScreenManagementService { if (Array.isArray(fields)) { for (const field of fields) { const ruleId = field?.numberingRule?.ruleId; - if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) { + if ( + ruleId && + typeof ruleId === "string" && + ruleId.startsWith("rule-") + ) { ruleIds.add(ruleId); } } @@ -2782,7 +2891,11 @@ export class ScreenManagementService { if (Array.isArray(optFields)) { for (const field of optFields) { const ruleId = field?.numberingRule?.ruleId; - if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) { + if ( + ruleId && + typeof ruleId === "string" && + ruleId.startsWith("rule-") + ) { ruleIds.add(ruleId); } } @@ -2791,20 +2904,28 @@ export class ScreenManagementService { } } } - + // 3. componentConfig.action.excelNumberingRuleId (엑셀 업로드) const excelRuleId = props?.componentConfig?.action?.excelNumberingRuleId; - if (excelRuleId && typeof excelRuleId === 'string' && excelRuleId.startsWith('rule-')) { + if ( + excelRuleId && + typeof excelRuleId === "string" && + excelRuleId.startsWith("rule-") + ) { ruleIds.add(excelRuleId); } - + // 4. componentConfig.action.numberingRuleId (버튼 액션) const actionRuleId = props?.componentConfig?.action?.numberingRuleId; - if (actionRuleId && typeof actionRuleId === 'string' && actionRuleId.startsWith('rule-')) { + if ( + actionRuleId && + typeof actionRuleId === "string" && + actionRuleId.startsWith("rule-") + ) { ruleIds.add(actionRuleId); } } - + return ruleIds; } @@ -2823,51 +2944,53 @@ export class ScreenManagementService { ruleIds: Set, sourceCompanyCode: string, targetCompanyCode: string, - client: any + client: any, ): Promise> { const ruleIdMap = new Map(); - + if (ruleIds.size === 0) { return ruleIdMap; } - + console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`); - + // 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블) const ruleIdArray = Array.from(ruleIds); const sourceRulesResult = await client.query( `SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`, - [ruleIdArray] + [ruleIdArray], ); - + if (sourceRulesResult.rows.length === 0) { console.log(` 📭 복사할 채번 규칙 없음 (해당 rule_id 없음)`); return ruleIdMap; } - + console.log(` 📋 원본 채번 규칙: ${sourceRulesResult.rows.length}개`); - + // 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준) const existingRulesResult = await client.query( `SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`, - [targetCompanyCode] + [targetCompanyCode], ); const existingRulesByName = new Map( - existingRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id]) + existingRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id]), ); - + // 3. 각 규칙 복사 또는 재사용 for (const rule of sourceRulesResult.rows) { const existingId = existingRulesByName.get(rule.rule_name); - + if (existingId) { // 기존 규칙 재사용 ruleIdMap.set(rule.rule_id, existingId); - console.log(` ♻️ 기존 채번 규칙 재사용: ${rule.rule_name} (${rule.rule_id} → ${existingId})`); + console.log( + ` ♻️ 기존 채번 규칙 재사용: ${rule.rule_name} (${rule.rule_id} → ${existingId})`, + ); } else { // 새로 복사 - 새 rule_id 생성 const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - + // numbering_rules_test 복사 (current_sequence = 0으로 초기화) await client.query( `INSERT INTO numbering_rules_test ( @@ -2892,15 +3015,15 @@ export class ScreenManagementService { null, // last_generated_date 초기화 rule.category_column, rule.category_value_id, - ] + ], ); - + // numbering_rule_parts_test 복사 const partsResult = await client.query( `SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`, - [rule.rule_id] + [rule.rule_id], ); - + for (const part of partsResult.rows) { await client.query( `INSERT INTO numbering_rule_parts_test ( @@ -2916,15 +3039,17 @@ export class ScreenManagementService { part.manual_config ? JSON.stringify(part.manual_config) : null, targetCompanyCode, new Date(), - ] + ], ); } - + ruleIdMap.set(rule.rule_id, newRuleId); - console.log(` ➕ 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id} → ${newRuleId}), 파트 ${partsResult.rows.length}개`); + console.log( + ` ➕ 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id} → ${newRuleId}), 파트 ${partsResult.rows.length}개`, + ); } } - + console.log(` ✅ 채번 규칙 복사 완료: 매핑 ${ruleIdMap.size}개`); return ruleIdMap; } @@ -2935,21 +3060,27 @@ export class ScreenManagementService { * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) * - componentConfig.action.excelNumberingRuleId (엑셀 업로드) */ - private updateNumberingRuleIdsInProperties(properties: any, ruleIdMap: Map): any { + private updateNumberingRuleIdsInProperties( + properties: any, + ruleIdMap: Map, + ): any { if (!properties || ruleIdMap.size === 0) return properties; - + const updated = JSON.parse(JSON.stringify(properties)); - + // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input) if (updated?.componentConfig?.autoGeneration?.options?.numberingRuleId) { - const oldId = updated.componentConfig.autoGeneration.options.numberingRuleId; + const oldId = + updated.componentConfig.autoGeneration.options.numberingRuleId; const newId = ruleIdMap.get(oldId); if (newId) { updated.componentConfig.autoGeneration.options.numberingRuleId = newId; - console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`); + console.log( + ` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`, + ); } } - + // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) if (Array.isArray(updated?.componentConfig?.sections)) { for (const section of updated.componentConfig.sections) { @@ -2961,7 +3092,9 @@ export class ScreenManagementService { const newId = ruleIdMap.get(oldId); if (newId) { field.numberingRule.ruleId = newId; - console.log(` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`); + console.log( + ` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`, + ); } } } @@ -2976,7 +3109,9 @@ export class ScreenManagementService { const newId = ruleIdMap.get(oldId); if (newId) { field.numberingRule.ruleId = newId; - console.log(` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`); + console.log( + ` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`, + ); } } } @@ -2985,7 +3120,7 @@ export class ScreenManagementService { } } } - + // 3. componentConfig.action.excelNumberingRuleId if (updated?.componentConfig?.action?.excelNumberingRuleId) { const oldId = updated.componentConfig.action.excelNumberingRuleId; @@ -2995,7 +3130,7 @@ export class ScreenManagementService { console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`); } } - + // 4. componentConfig.action.numberingRuleId (버튼 액션) if (updated?.componentConfig?.action?.numberingRuleId) { const oldId = updated.componentConfig.action.numberingRuleId; @@ -3005,7 +3140,7 @@ export class ScreenManagementService { console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`); } } - + return updated; } @@ -3013,11 +3148,14 @@ export class ScreenManagementService { * properties 내의 탭 컴포넌트 screenId 매핑 * - componentConfig.tabs[].screenId (tabs-widget) */ - private updateTabScreenIdsInProperties(properties: any, screenIdMap: Map): any { + private updateTabScreenIdsInProperties( + properties: any, + screenIdMap: Map, + ): any { if (!properties || screenIdMap.size === 0) return properties; - + const updated = JSON.parse(JSON.stringify(properties)); - + // componentConfig.tabs[].screenId (tabs-widget) if (Array.isArray(updated?.componentConfig?.tabs)) { for (const tab of updated.componentConfig.tabs) { @@ -3031,7 +3169,7 @@ export class ScreenManagementService { } } } - + return updated; } @@ -3046,12 +3184,14 @@ export class ScreenManagementService { */ async updateTabScreenReferences( targetScreenIds: number[], - screenIdMap: { [key: number]: number } + screenIdMap: { [key: number]: number }, ): Promise<{ updated: number; details: string[] }> { const result = { updated: 0, details: [] as string[] }; - + if (targetScreenIds.length === 0 || Object.keys(screenIdMap).length === 0) { - console.log(`⚠️ updateTabScreenReferences 스킵: targetScreenIds=${targetScreenIds.length}, screenIdMap keys=${Object.keys(screenIdMap).length}`); + console.log( + `⚠️ updateTabScreenReferences 스킵: targetScreenIds=${targetScreenIds.length}, screenIdMap keys=${Object.keys(screenIdMap).length}`, + ); return result; } @@ -3060,12 +3200,14 @@ export class ScreenManagementService { console.log(` - screenIdMap: ${JSON.stringify(screenIdMap)}`); const screenMap = new Map( - Object.entries(screenIdMap).map(([k, v]) => [Number(k), v]) + Object.entries(screenIdMap).map(([k, v]) => [Number(k), v]), ); await transaction(async (client) => { // 대상 화면들의 모든 레이아웃 조회 (screenId, modalScreenId, targetScreenId 참조가 있는 것) - const placeholders = targetScreenIds.map((_, i) => `$${i + 1}`).join(', '); + const placeholders = targetScreenIds + .map((_, i) => `$${i + 1}`) + .join(", "); const layoutsResult = await client.query( `SELECT layout_id, screen_id, properties FROM screen_layouts @@ -3075,14 +3217,16 @@ export class ScreenManagementService { OR properties::text LIKE '%"modalScreenId"%' OR properties::text LIKE '%"targetScreenId"%' )`, - targetScreenIds + targetScreenIds, ); - console.log(`🔍 참조 업데이트 대상 레이아웃: ${layoutsResult.rows.length}개`); + console.log( + `🔍 참조 업데이트 대상 레이아웃: ${layoutsResult.rows.length}개`, + ); for (const layout of layoutsResult.rows) { let properties = layout.properties; - if (typeof properties === 'string') { + if (typeof properties === "string") { try { properties = JSON.parse(properties); } catch (e) { @@ -3093,63 +3237,86 @@ export class ScreenManagementService { let hasChanges = false; // 재귀적으로 모든 screenId/modalScreenId 참조 업데이트 - const updateReferences = async (obj: any, path: string = ''): Promise => { - if (!obj || typeof obj !== 'object') return; + const updateReferences = async ( + obj: any, + path: string = "", + ): Promise => { + if (!obj || typeof obj !== "object") return; for (const key of Object.keys(obj)) { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; // screenId 업데이트 - if (key === 'screenId' && typeof value === 'number') { + if (key === "screenId" && typeof value === "number") { const newId = screenMap.get(value); if (newId) { obj[key] = newId; hasChanges = true; - result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`); - console.log(`🔗 screenId 매핑: ${value} → ${newId} (${currentPath})`); - + result.details.push( + `layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`, + ); + console.log( + `🔗 screenId 매핑: ${value} → ${newId} (${currentPath})`, + ); + // screenName도 함께 업데이트 (있는 경우) if (obj.screenName !== undefined) { const newScreenResult = await client.query( `SELECT screen_name FROM screen_definitions WHERE screen_id = $1`, - [newId] + [newId], ); if (newScreenResult.rows.length > 0) { obj.screenName = newScreenResult.rows[0].screen_name; } } } else { - console.log(`⚠️ screenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`); + console.log( + `⚠️ screenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, + ); } } // modalScreenId 업데이트 - if (key === 'modalScreenId' && typeof value === 'number') { + if (key === "modalScreenId" && typeof value === "number") { const newId = screenMap.get(value); if (newId) { obj[key] = newId; hasChanges = true; - result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`); - console.log(`🔗 modalScreenId 매핑: ${value} → ${newId} (${currentPath})`); + result.details.push( + `layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`, + ); + console.log( + `🔗 modalScreenId 매핑: ${value} → ${newId} (${currentPath})`, + ); } else { - console.log(`⚠️ modalScreenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`); + console.log( + `⚠️ modalScreenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, + ); } } // targetScreenId 업데이트 (버튼 액션에서 사용, 문자열 또는 숫자) - if (key === 'targetScreenId') { - const oldId = typeof value === 'string' ? parseInt(value, 10) : value; + if (key === "targetScreenId") { + const oldId = + typeof value === "string" ? parseInt(value, 10) : value; if (!isNaN(oldId)) { const newId = screenMap.get(oldId); if (newId) { // 원래 타입 유지 (문자열이면 문자열, 숫자면 숫자) - obj[key] = typeof value === 'string' ? newId.toString() : newId; + obj[key] = + typeof value === "string" ? newId.toString() : newId; hasChanges = true; - result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${oldId} → ${newId}`); - console.log(`🔗 targetScreenId 매핑: ${oldId} → ${newId} (${currentPath})`); + result.details.push( + `layout_id=${layout.layout_id}: ${currentPath} ${oldId} → ${newId}`, + ); + console.log( + `🔗 targetScreenId 매핑: ${oldId} → ${newId} (${currentPath})`, + ); } else { - console.log(`⚠️ targetScreenId ${oldId} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`); + console.log( + `⚠️ targetScreenId ${oldId} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, + ); } } } @@ -3161,7 +3328,7 @@ export class ScreenManagementService { } } // 객체 재귀 - else if (typeof value === 'object' && value !== null) { + else if (typeof value === "object" && value !== null) { await updateReferences(value, currentPath); } } @@ -3172,13 +3339,15 @@ export class ScreenManagementService { if (hasChanges) { await client.query( `UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`, - [JSON.stringify(properties), layout.layout_id] + [JSON.stringify(properties), layout.layout_id], ); result.updated++; } } - console.log(`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`); + console.log( + `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, + ); }); return result; @@ -3194,7 +3363,7 @@ export class ScreenManagementService { private async autoMapTabScreenIds( properties: any, targetCompanyCode: string, - client: any + client: any, ): Promise { if (!Array.isArray(properties?.componentConfig?.tabs)) { return properties; @@ -3214,7 +3383,7 @@ export class ScreenManagementService { if (!screenNameToFind) { const sourceResult = await client.query( `SELECT screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [oldScreenId] + [oldScreenId], ); if (sourceResult.rows.length > 0) { screenNameToFind = sourceResult.rows[0].screen_name; @@ -3226,9 +3395,10 @@ export class ScreenManagementService { // 2. 대상 회사에서 유사한 이름의 화면 찾기 // 원본 화면 이름에서 회사 접두어를 제거하고 핵심 이름으로 검색 // 예: "탑씰 품목 카테고리설정" → "카테고리설정"으로 검색 - const nameParts = screenNameToFind.split(' '); - const coreNamePart = nameParts.length > 1 ? nameParts.slice(-1)[0] : screenNameToFind; - + const nameParts = screenNameToFind.split(" "); + const coreNamePart = + nameParts.length > 1 ? nameParts.slice(-1)[0] : screenNameToFind; + const targetResult = await client.query( `SELECT screen_id, screen_name FROM screen_definitions @@ -3238,7 +3408,7 @@ export class ScreenManagementService { AND screen_name LIKE $2 ORDER BY screen_id DESC LIMIT 1`, - [targetCompanyCode, `%${coreNamePart}`] + [targetCompanyCode, `%${coreNamePart}`], ); if (targetResult.rows.length > 0) { @@ -3246,7 +3416,9 @@ export class ScreenManagementService { tab.screenId = newScreen.screen_id; tab.screenName = newScreen.screen_name; hasChanges = true; - console.log(`🔗 탭 screenId 자동 매핑: ${oldScreenId} (${oldScreenName}) → ${newScreen.screen_id} (${newScreen.screen_name})`); + console.log( + `🔗 탭 screenId 자동 매핑: ${oldScreenId} (${oldScreenName}) → ${newScreen.screen_id} (${newScreen.screen_name})`, + ); } } @@ -3258,23 +3430,23 @@ export class ScreenManagementService { */ private collectFlowIdsFromLayouts(layouts: any[]): Set { const flowIds = new Set(); - + for (const layout of layouts) { const props = layout.properties; if (!props) continue; - + // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; if (flowId && !isNaN(parseInt(flowId))) { flowIds.add(parseInt(flowId)); } - + // webTypeConfig.dataflowConfig.selectedDiagramId const diagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; if (diagramId && !isNaN(parseInt(diagramId))) { flowIds.add(parseInt(diagramId)); } - + // webTypeConfig.dataflowConfig.flowControls[].flowId const flowControls = props?.webTypeConfig?.dataflowConfig?.flowControls; if (Array.isArray(flowControls)) { @@ -3284,7 +3456,7 @@ export class ScreenManagementService { } } } - + // componentConfig.action.excelAfterUploadFlows[].flowId const excelFlows = props?.componentConfig?.action?.excelAfterUploadFlows; if (Array.isArray(excelFlows)) { @@ -3295,7 +3467,7 @@ export class ScreenManagementService { } } } - + return flowIds; } @@ -3308,49 +3480,51 @@ export class ScreenManagementService { flowIds: Set, sourceCompanyCode: string, targetCompanyCode: string, - client: any + client: any, ): Promise> { const flowIdMap = new Map(); - + if (flowIds.size === 0) { return flowIdMap; } - + console.log(`🔄 노드 플로우 복사 시작: ${flowIds.size}개 flowId`); - + // 1. 원본 플로우 조회 (company_code = "*" 전역 플로우는 복사하지 않음) const flowIdArray = Array.from(flowIds); const sourceFlowsResult = await client.query( `SELECT * FROM node_flows WHERE flow_id = ANY($1) AND company_code = $2`, - [flowIdArray, sourceCompanyCode] + [flowIdArray, sourceCompanyCode], ); - + if (sourceFlowsResult.rows.length === 0) { console.log(` 📭 복사할 노드 플로우 없음 (원본 회사 소속 플로우 없음)`); return flowIdMap; } - + console.log(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`); - + // 2. 대상 회사의 기존 플로우 조회 (이름 기준) const existingFlowsResult = await client.query( `SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`, - [targetCompanyCode] + [targetCompanyCode], ); const existingFlowsByName = new Map( - existingFlowsResult.rows.map((f: any) => [f.flow_name, f.flow_id]) + existingFlowsResult.rows.map((f: any) => [f.flow_name, f.flow_id]), ); - + // 3. 각 플로우 복사 또는 재사용 for (const flow of sourceFlowsResult.rows) { const existingId = existingFlowsByName.get(flow.flow_name); - + if (existingId) { // 기존 플로우 재사용 flowIdMap.set(flow.flow_id, existingId); - console.log(` ♻️ 기존 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`); + console.log( + ` ♻️ 기존 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`, + ); } else { // 새로 복사 const insertResult = await client.query( @@ -3362,15 +3536,17 @@ export class ScreenManagementService { flow.flow_description, JSON.stringify(flow.flow_data), targetCompanyCode, - ] + ], ); - + const newFlowId = insertResult.rows[0].flow_id; flowIdMap.set(flow.flow_id, newFlowId); - console.log(` ➕ 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`); + console.log( + ` ➕ 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`, + ); } } - + console.log(` ✅ 노드 플로우 복사 완료: 매핑 ${flowIdMap.size}개`); return flowIdMap; } @@ -3378,31 +3554,38 @@ export class ScreenManagementService { /** * properties 내의 flowId, selectedDiagramId 등을 매핑 */ - private updateFlowIdsInProperties(properties: any, flowIdMap: Map): any { + private updateFlowIdsInProperties( + properties: any, + flowIdMap: Map, + ): any { if (!properties || flowIdMap.size === 0) return properties; - + const updated = JSON.parse(JSON.stringify(properties)); - + // webTypeConfig.dataflowConfig.flowConfig.flowId if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { - const oldId = parseInt(updated.webTypeConfig.dataflowConfig.flowConfig.flowId); + const oldId = parseInt( + updated.webTypeConfig.dataflowConfig.flowConfig.flowId, + ); const newId = flowIdMap.get(oldId); if (newId) { updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`); } } - + // webTypeConfig.dataflowConfig.selectedDiagramId if (updated?.webTypeConfig?.dataflowConfig?.selectedDiagramId) { - const oldId = parseInt(updated.webTypeConfig.dataflowConfig.selectedDiagramId); + const oldId = parseInt( + updated.webTypeConfig.dataflowConfig.selectedDiagramId, + ); const newId = flowIdMap.get(oldId); if (newId) { updated.webTypeConfig.dataflowConfig.selectedDiagramId = newId; console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`); } } - + // webTypeConfig.dataflowConfig.flowControls[].flowId if (Array.isArray(updated?.webTypeConfig?.dataflowConfig?.flowControls)) { for (const control of updated.webTypeConfig.dataflowConfig.flowControls) { @@ -3416,21 +3599,25 @@ export class ScreenManagementService { } } } - + // componentConfig.action.excelAfterUploadFlows[].flowId - if (Array.isArray(updated?.componentConfig?.action?.excelAfterUploadFlows)) { + if ( + Array.isArray(updated?.componentConfig?.action?.excelAfterUploadFlows) + ) { for (const flow of updated.componentConfig.action.excelAfterUploadFlows) { if (flow?.flowId) { const oldId = parseInt(flow.flowId); const newId = flowIdMap.get(oldId); if (newId) { flow.flowId = String(newId); - console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`); + console.log( + ` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`, + ); } } } } - + return updated; } @@ -3439,7 +3626,7 @@ export class ScreenManagementService { */ async copyScreen( sourceScreenId: number, - copyData: CopyScreenRequest + copyData: CopyScreenRequest, ): Promise { // 트랜잭션으로 처리 return await transaction(async (client) => { @@ -3468,7 +3655,7 @@ export class ScreenManagementService { const sourceScreens = await client.query( sourceScreenQuery, - sourceScreenParams + sourceScreenParams, ); if (sourceScreens.rows.length === 0) { @@ -3480,14 +3667,15 @@ export class ScreenManagementService { // 2. 대상 회사 코드 결정 // copyData.targetCompanyCode가 있으면 사용 (회사 간 복사) // 없으면 원본과 같은 회사에 복사 - const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; + const targetCompanyCode = + copyData.targetCompanyCode || sourceScreen.company_code; // 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만) const existingScreens = await client.query( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, - [copyData.screenCode, targetCompanyCode] + [copyData.screenCode, targetCompanyCode], ); if (existingScreens.rows.length > 0) { @@ -3496,8 +3684,9 @@ export class ScreenManagementService { // 4. 새 화면 생성 (대상 회사에 생성) // 삭제된 화면(is_active = 'D')을 복사할 경우 활성 상태('Y')로 변경 - const newIsActive = sourceScreen.is_active === 'D' ? 'Y' : sourceScreen.is_active; - + const newIsActive = + sourceScreen.is_active === "D" ? "Y" : sourceScreen.is_active; + const newScreenResult = await client.query( `INSERT INTO screen_definitions ( screen_code, screen_name, description, company_code, table_name, @@ -3515,7 +3704,7 @@ export class ScreenManagementService { new Date(), copyData.createdBy, new Date(), - ] + ], ); const newScreen = newScreenResult.rows[0]; @@ -3525,45 +3714,51 @@ export class ScreenManagementService { `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST`, - [sourceScreenId] + [sourceScreenId], ); const sourceLayouts = sourceLayoutsResult.rows; // 5. 노드 플로우 복사 (회사가 다른 경우) let flowIdMap = new Map(); - if (sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode) { + if ( + sourceLayouts.length > 0 && + sourceScreen.company_code !== targetCompanyCode + ) { // 레이아웃에서 사용하는 flowId 수집 const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts); - + if (flowIds.size > 0) { console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`); - + // 노드 플로우 복사 및 매핑 생성 flowIdMap = await this.copyNodeFlowsForScreen( flowIds, sourceScreen.company_code, targetCompanyCode, - client + client, ); } } // 5.1. 채번 규칙 복사 (회사가 다른 경우) let ruleIdMap = new Map(); - if (sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode) { + if ( + sourceLayouts.length > 0 && + sourceScreen.company_code !== targetCompanyCode + ) { // 레이아웃에서 사용하는 채번 규칙 ID 수집 const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts); - + if (ruleIds.size > 0) { console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`); - + // 채번 규칙 복사 및 매핑 생성 ruleIdMap = await this.copyNumberingRulesForScreen( ruleIds, sourceScreen.company_code, targetCompanyCode, - client + client, ); } } @@ -3598,12 +3793,18 @@ export class ScreenManagementService { // flowId 매핑 적용 (회사가 다른 경우) if (flowIdMap.size > 0) { - properties = this.updateFlowIdsInProperties(properties, flowIdMap); + properties = this.updateFlowIdsInProperties( + properties, + flowIdMap, + ); } // 채번 규칙 ID 매핑 적용 (회사가 다른 경우) if (ruleIdMap.size > 0) { - properties = this.updateNumberingRuleIdsInProperties(properties, ruleIdMap); + properties = this.updateNumberingRuleIdsInProperties( + properties, + ruleIdMap, + ); } // 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음 @@ -3627,7 +3828,7 @@ export class ScreenManagementService { JSON.stringify(properties), sourceLayout.display_order, new Date(), - ] + ], ); } } catch (error) { @@ -3676,7 +3877,9 @@ export class ScreenManagementService { modalScreens: ScreenDefinition[]; }> { const targetCompany = data.targetCompanyCode || data.companyCode; - console.log(`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`); + console.log( + `🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`, + ); // 1. 메인 화면 복사 const mainScreen = await this.copyScreen(data.sourceScreenId, { @@ -3688,7 +3891,9 @@ export class ScreenManagementService { targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달 }); - console.log(`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`); + console.log( + `✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`, + ); // 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑) const modalScreens: ScreenDefinition[] = []; @@ -3708,22 +3913,25 @@ export class ScreenManagementService { screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId); console.log( - `✅ 모달 화면 복사 완료: ${modalData.sourceScreenId} → ${copiedModal.screenId} (${copiedModal.screenCode})` + `✅ 모달 화면 복사 완료: ${modalData.sourceScreenId} → ${copiedModal.screenId} (${copiedModal.screenCode})`, ); } // 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트 // 모든 복사가 완료되고 커밋된 후에 실행 - console.log(`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`, - Array.from(screenIdMapping.entries()) - ); - - const updateCount = await this.updateButtonTargetScreenIds( - mainScreen.screenId, - screenIdMapping + console.log( + `🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`, + Array.from(screenIdMapping.entries()), ); - console.log(`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`); + const updateCount = await this.updateButtonTargetScreenIds( + mainScreen.screenId, + screenIdMapping, + ); + + console.log( + `🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`, + ); return { mainScreen, @@ -3737,10 +3945,12 @@ export class ScreenManagementService { */ private async updateButtonTargetScreenIds( screenId: number, - screenIdMapping: Map + screenIdMapping: Map, ): Promise { - console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`); - + console.log( + `🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`, + ); + // 화면의 모든 레이아웃 조회 const layouts = await query( `SELECT layout_id, properties @@ -3748,7 +3958,7 @@ export class ScreenManagementService { WHERE screen_id = $1 AND component_type = 'component' AND properties IS NOT NULL`, - [screenId] + [screenId], ); console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`); @@ -3768,13 +3978,20 @@ export class ScreenManagementService { const action = properties?.componentConfig?.action; // targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData) - const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"]; + const modalActionTypes = [ + "popup", + "modal", + "edit", + "openModalWithData", + ]; if ( modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const oldScreenId = parseInt(action.targetScreenId); - console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`); + console.log( + `🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`, + ); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { @@ -3787,7 +4004,7 @@ export class ScreenManagementService { needsUpdate = true; console.log( - `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})` + `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`, ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); @@ -3798,11 +4015,13 @@ export class ScreenManagementService { // 2. conditional-container 컴포넌트의 sections[].screenId 업데이트 if (properties?.componentType === "conditional-container") { const sections = properties?.componentConfig?.sections || []; - + for (const section of sections) { if (section?.screenId) { const oldScreenId = parseInt(section.screenId); - console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`); + console.log( + `🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`, + ); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { @@ -3814,7 +4033,7 @@ export class ScreenManagementService { needsUpdate = true; console.log( - `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})` + `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`, ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); @@ -3829,7 +4048,7 @@ export class ScreenManagementService { `UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`, - [JSON.stringify(properties), layout.layout_id] + [JSON.stringify(properties), layout.layout_id], ); updateCount++; console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`); @@ -3840,13 +4059,15 @@ export class ScreenManagementService { } } - console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`); + console.log( + `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, + ); return updateCount; } /** * 화면-메뉴 할당 복제 (screen_menu_assignments) - * + * * @param sourceCompanyCode 원본 회사 코드 * @param targetCompanyCode 대상 회사 코드 * @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑 @@ -3855,7 +4076,7 @@ export class ScreenManagementService { async copyScreenMenuAssignments( sourceCompanyCode: string, targetCompanyCode: string, - screenIdMap: Record + screenIdMap: Record, ): Promise<{ copiedCount: number; skippedCount: number; details: string[] }> { const result = { copiedCount: 0, @@ -3864,7 +4085,10 @@ export class ScreenManagementService { }; return await transaction(async (client) => { - logger.info("🔗 화면-메뉴 할당 복제 시작", { sourceCompanyCode, targetCompanyCode }); + logger.info("🔗 화면-메뉴 할당 복제 시작", { + sourceCompanyCode, + targetCompanyCode, + }); // 1. 원본 회사의 screen_groups (menu_objid 포함) 조회 const sourceGroupsResult = await client.query<{ @@ -3875,7 +4099,7 @@ export class ScreenManagementService { `SELECT id, group_name, menu_objid FROM screen_groups WHERE company_code = $1 AND menu_objid IS NOT NULL`, - [sourceCompanyCode] + [sourceCompanyCode], ); // 2. 대상 회사의 screen_groups (menu_objid 포함) 조회 @@ -3887,21 +4111,23 @@ export class ScreenManagementService { `SELECT id, group_name, menu_objid FROM screen_groups WHERE company_code = $1 AND menu_objid IS NOT NULL`, - [targetCompanyCode] + [targetCompanyCode], ); // 3. 그룹 이름 기반으로 menu_objid 매핑 생성 const menuObjidMap = new Map(); // 원본 menu_objid -> 새 menu_objid for (const sourceGroup of sourceGroupsResult.rows) { if (!sourceGroup.menu_objid) continue; - + const matchingTarget = targetGroupsResult.rows.find( - (t) => t.group_name === sourceGroup.group_name + (t) => t.group_name === sourceGroup.group_name, ); - + if (matchingTarget?.menu_objid) { menuObjidMap.set(sourceGroup.menu_objid, matchingTarget.menu_objid); - logger.debug(`메뉴 매핑: ${sourceGroup.group_name} | ${sourceGroup.menu_objid} → ${matchingTarget.menu_objid}`); + logger.debug( + `메뉴 매핑: ${sourceGroup.group_name} | ${sourceGroup.menu_objid} → ${matchingTarget.menu_objid}`, + ); } } @@ -3917,7 +4143,7 @@ export class ScreenManagementService { `SELECT screen_id, menu_objid::text, display_order, is_active FROM screen_menu_assignments WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); logger.info(`📌 원본 할당: ${assignmentsResult.rowCount}개`); @@ -3953,16 +4179,19 @@ export class ScreenManagementService { targetCompanyCode, assignment.display_order, assignment.is_active, - ] + ], ); // 🔧 menu_info.menu_url도 새 화면 ID로 업데이트 - const menuInfo = await client.query<{ menu_type: string; screen_code: string | null }>( + const menuInfo = await client.query<{ + menu_type: string; + screen_code: string | null; + }>( `SELECT mi.menu_type, sd.screen_code FROM menu_info mi LEFT JOIN screen_definitions sd ON sd.screen_id = $1 WHERE mi.objid = $2`, - [newScreenId, newMenuObjid] + [newScreenId, newMenuObjid], ); if (menuInfo.rows.length > 0) { @@ -3976,13 +4205,17 @@ export class ScreenManagementService { `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, - [newMenuUrl, screenCode, newMenuObjid] + [newMenuUrl, screenCode, newMenuObjid], + ); + logger.debug( + `✅ menu_info.menu_url 업데이트: ${newMenuObjid} → ${newMenuUrl}`, ); - logger.debug(`✅ menu_info.menu_url 업데이트: ${newMenuObjid} → ${newMenuUrl}`); } result.copiedCount++; - logger.debug(`✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`); + logger.debug( + `✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`, + ); } catch (error: any) { logger.error(`❌ 할당 복제 실패: ${error.message}`); result.skippedCount++; @@ -3990,7 +4223,9 @@ export class ScreenManagementService { } } - logger.info(`✅ 화면-메뉴 할당 복제 완료: ${result.copiedCount}개 복제, ${result.skippedCount}개 스킵`); + logger.info( + `✅ 화면-메뉴 할당 복제 완료: ${result.copiedCount}개 복제, ${result.skippedCount}개 스킵`, + ); return result; }); } @@ -4001,8 +4236,12 @@ export class ScreenManagementService { async copyCodeCategoryAndCodes( sourceCompanyCode: string, targetCompanyCode: string, - menuObjidMap?: Map - ): Promise<{ copiedCategories: number; copiedCodes: number; details: string[] }> { + menuObjidMap?: Map, + ): Promise<{ + copiedCategories: number; + copiedCodes: number; + details: string[]; + }> { const result = { copiedCategories: 0, copiedCodes: 0, @@ -4010,16 +4249,25 @@ export class ScreenManagementService { }; return transaction(async (client) => { - logger.info(`📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + logger.info( + `📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, + ); // 1. 기존 대상 회사 데이터 삭제 - await client.query(`DELETE FROM code_info WHERE company_code = $1`, [targetCompanyCode]); - await client.query(`DELETE FROM code_category WHERE company_code = $1`, [targetCompanyCode]); + await client.query(`DELETE FROM code_info WHERE company_code = $1`, [ + targetCompanyCode, + ]); + await client.query(`DELETE FROM code_category WHERE company_code = $1`, [ + targetCompanyCode, + ]); // 2. menuObjidMap 생성 (없는 경우) if (!menuObjidMap || menuObjidMap.size === 0) { menuObjidMap = new Map(); - const groupPairs = await client.query<{ source_objid: string; target_objid: string }>( + const groupPairs = await client.query<{ + source_objid: string; + target_objid: string; + }>( `SELECT DISTINCT sg1.menu_objid::text as source_objid, sg2.menu_objid::text as target_objid @@ -4027,25 +4275,38 @@ export class ScreenManagementService { JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name WHERE sg1.company_code = $1 AND sg2.company_code = $2 AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`, - [sourceCompanyCode, targetCompanyCode] + [sourceCompanyCode, targetCompanyCode], + ); + groupPairs.rows.forEach((p) => + menuObjidMap!.set(p.source_objid, p.target_objid), ); - groupPairs.rows.forEach(p => menuObjidMap!.set(p.source_objid, p.target_objid)); } // 3. 코드 카테고리 복제 const categories = await client.query( `SELECT * FROM code_category WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); for (const cat of categories.rows) { - const newMenuObjid = cat.menu_objid ? menuObjidMap.get(cat.menu_objid.toString()) || cat.menu_objid : null; - + const newMenuObjid = cat.menu_objid + ? menuObjidMap.get(cat.menu_objid.toString()) || cat.menu_objid + : null; + await client.query( `INSERT INTO code_category (category_code, category_name, category_name_eng, description, sort_order, is_active, company_code, menu_objid, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'system')`, - [cat.category_code, cat.category_name, cat.category_name_eng, cat.description, cat.sort_order, cat.is_active, targetCompanyCode, newMenuObjid] + [ + cat.category_code, + cat.category_name, + cat.category_name_eng, + cat.description, + cat.sort_order, + cat.is_active, + targetCompanyCode, + newMenuObjid, + ], ); result.copiedCategories++; } @@ -4053,22 +4314,38 @@ export class ScreenManagementService { // 4. 코드 정보 복제 const codes = await client.query( `SELECT * FROM code_info WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); for (const code of codes.rows) { - const newMenuObjid = code.menu_objid ? menuObjidMap.get(code.menu_objid.toString()) || code.menu_objid : null; - + const newMenuObjid = code.menu_objid + ? menuObjidMap.get(code.menu_objid.toString()) || code.menu_objid + : null; + await client.query( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, is_active, company_code, menu_objid, parent_code_value, depth, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'system')`, - [code.code_category, code.code_value, code.code_name, code.code_name_eng, code.description, code.sort_order, code.is_active, targetCompanyCode, newMenuObjid, code.parent_code_value, code.depth] + [ + code.code_category, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + targetCompanyCode, + newMenuObjid, + code.parent_code_value, + code.depth, + ], ); result.copiedCodes++; } - logger.info(`✅ 코드 카테고리/코드 복제 완료: 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개`); + logger.info( + `✅ 코드 카테고리/코드 복제 완료: 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개`, + ); return result; }); } @@ -4080,8 +4357,12 @@ export class ScreenManagementService { */ async copyCategoryMapping( sourceCompanyCode: string, - targetCompanyCode: string - ): Promise<{ copiedMappings: number; copiedValues: number; details: string[] }> { + targetCompanyCode: string, + ): Promise<{ + copiedMappings: number; + copiedValues: number; + details: string[]; + }> { const result = { copiedMappings: 0, copiedValues: 0, @@ -4089,20 +4370,25 @@ export class ScreenManagementService { }; return transaction(async (client) => { - logger.info(`📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + logger.info( + `📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, + ); // 1. 기존 대상 회사 데이터 삭제 - await client.query(`DELETE FROM category_values_test WHERE company_code = $1`, [targetCompanyCode]); + await client.query( + `DELETE FROM category_values_test WHERE company_code = $1`, + [targetCompanyCode], + ); // 2. category_values_test 복제 const values = await client.query( `SELECT * FROM category_values_test WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); // value_id 매핑 (parent_value_id 참조 업데이트용) const valueIdMap = new Map(); - + for (const v of values.rows) { const insertResult = await client.query( `INSERT INTO category_values_test @@ -4112,17 +4398,27 @@ export class ScreenManagementService { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system') RETURNING value_id`, [ - v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, null, // parent_value_id는 나중에 업데이트 - v.depth, v.path, v.description, v.color, v.icon, - v.is_active, v.is_default, targetCompanyCode - ] + v.depth, + v.path, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + targetCompanyCode, + ], ); - + valueIdMap.set(v.value_id, insertResult.rows[0].value_id); result.copiedValues++; } - + // 3. parent_value_id 업데이트 (새 value_id로 매핑) for (const v of values.rows) { if (v.parent_value_id) { @@ -4131,7 +4427,7 @@ export class ScreenManagementService { if (newParentId && newValueId) { await client.query( `UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`, - [newParentId, newValueId] + [newParentId, newValueId], ); } } @@ -4148,7 +4444,7 @@ export class ScreenManagementService { */ async copyTableTypeColumns( sourceCompanyCode: string, - targetCompanyCode: string + targetCompanyCode: string, ): Promise<{ copiedCount: number; details: string[] }> { const result = { copiedCount: 0, @@ -4156,15 +4452,20 @@ export class ScreenManagementService { }; return transaction(async (client) => { - logger.info(`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + logger.info( + `📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, + ); // 1. 기존 대상 회사 데이터 삭제 - await client.query(`DELETE FROM table_type_columns WHERE company_code = $1`, [targetCompanyCode]); + await client.query( + `DELETE FROM table_type_columns WHERE company_code = $1`, + [targetCompanyCode], + ); // 2. 복제 (column_labels 통합 후 모든 컬럼 포함) const columns = await client.query( `SELECT * FROM table_type_columns WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); for (const col of columns.rows) { @@ -4190,8 +4491,8 @@ export class ScreenManagementService { col.reference_table, col.reference_column, col.display_column, - targetCompanyCode - ] + targetCompanyCode, + ], ); result.copiedCount++; } @@ -4206,7 +4507,7 @@ export class ScreenManagementService { */ async copyCascadingRelation( sourceCompanyCode: string, - targetCompanyCode: string + targetCompanyCode: string, ): Promise<{ copiedCount: number; details: string[] }> { const result = { copiedCount: 0, @@ -4214,30 +4515,52 @@ export class ScreenManagementService { }; return transaction(async (client) => { - logger.info(`📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + logger.info( + `📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, + ); // 1. 기존 대상 회사 데이터 삭제 - await client.query(`DELETE FROM cascading_relation WHERE company_code = $1`, [targetCompanyCode]); + await client.query( + `DELETE FROM cascading_relation WHERE company_code = $1`, + [targetCompanyCode], + ); // 2. 복제 const relations = await client.query( `SELECT * FROM cascading_relation WHERE company_code = $1`, - [sourceCompanyCode] + [sourceCompanyCode], ); for (const rel of relations.rows) { // 새로운 relation_code 생성 const newRelationCode = `${rel.relation_code}_${targetCompanyCode}`; - + await client.query( `INSERT INTO cascading_relation (relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 'system')`, - [newRelationCode, rel.relation_name, rel.description, rel.parent_table, rel.parent_value_column, rel.parent_label_column, - rel.child_table, rel.child_filter_column, rel.child_value_column, rel.child_label_column, rel.child_order_column, rel.child_order_direction, - rel.empty_parent_message, rel.no_options_message, rel.loading_message, rel.clear_on_parent_change, targetCompanyCode, rel.is_active] + [ + newRelationCode, + rel.relation_name, + rel.description, + rel.parent_table, + rel.parent_value_column, + rel.parent_label_column, + rel.child_table, + rel.child_filter_column, + rel.child_value_column, + rel.child_label_column, + rel.child_order_column, + rel.child_order_direction, + rel.empty_parent_message, + rel.no_options_message, + rel.loading_message, + rel.clear_on_parent_change, + targetCompanyCode, + rel.is_active, + ], ); result.copiedCount++; } @@ -4258,15 +4581,22 @@ export class ScreenManagementService { */ async getLayoutV2( screenId: number, - companyCode: string + companyCode: string, + userType?: string, ): Promise { console.log(`=== V2 레이아웃 로드 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); + console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = userType === "SUPER_ADMIN"; // 권한 확인 - const screens = await query<{ company_code: string | null; table_name: string | null }>( + const screens = await query<{ + company_code: string | null; + table_name: string | null; + }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -4275,24 +4605,48 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } - // V2 테이블에서 조회 (회사별 우선, 없으면 공통(*) 조회) - let layout = await queryOne<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, - [screenId, companyCode] - ); + let layout: { layout_data: any } | null = null; - // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 - if (!layout && companyCode !== "*") { + // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin) { + // 1. 화면 정의의 회사 코드로 레이아웃 조회 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, - [screenId] + WHERE screen_id = $1 AND company_code = $2`, + [screenId, existingScreen.company_code], ); + + // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 + if (!layout) { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 + ORDER BY updated_at DESC + LIMIT 1`, + [screenId], + ); + } + } else { + // 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회) + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + if (!layout && companyCode !== "*") { + layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + } } if (!layout) { @@ -4300,7 +4654,9 @@ export class ScreenManagementService { return null; } - console.log(`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`); + console.log( + `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, + ); return layout.layout_data; } @@ -4312,7 +4668,7 @@ export class ScreenManagementService { async saveLayoutV2( screenId: number, layoutData: any, - companyCode: string + companyCode: string, ): Promise { console.log(`=== V2 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); @@ -4321,7 +4677,7 @@ export class ScreenManagementService { // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, - [screenId] + [screenId], ); if (screens.length === 0) { @@ -4338,7 +4694,7 @@ export class ScreenManagementService { const dataToSave = { version: "2.0", ...layoutData, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; // UPSERT (있으면 업데이트, 없으면 삽입) @@ -4347,7 +4703,7 @@ export class ScreenManagementService { VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW()`, - [screenId, companyCode, JSON.stringify(dataToSave)] + [screenId, companyCode, JSON.stringify(dataToSave)], ); console.log(`V2 레이아웃 저장 완료`); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 102479bc..eb7ecce5 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -35,16 +35,16 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - + // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); - + // 프리뷰 모드 감지 (iframe에서 로드될 때) const isPreviewMode = searchParams.get("preview") === "true"; // 🆕 현재 로그인한 사용자 정보 const { user, userName, companyCode: authCompanyCode } = useAuth(); - + // 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용 const companyCode = previewCompanyCode || authCompanyCode; @@ -153,7 +153,7 @@ function ScreenViewPage() { try { // V2 API 먼저 시도 const v2Response = await screenApi.getLayoutV2(screenId); - + if (v2Response && isValidV2Layout(v2Response)) { // V2 레이아웃: Zod 기반 변환 (기본값 병합) const convertedLayout = convertV2ToLegacy(v2Response); @@ -252,7 +252,7 @@ function ScreenViewPage() { // 조건 필드들의 값을 추적하여 변경 시에만 실행 const conditionalFieldValues = useMemo(() => { if (!layout?.components) return ""; - + // 조건부 설정에 사용되는 필드들의 현재 값을 JSON 문자열로 만들어 비교 const conditionFields = new Set(); layout.components.forEach((component) => { @@ -261,12 +261,12 @@ function ScreenViewPage() { conditionFields.add(conditional.field); } }); - + const values: Record = {}; conditionFields.forEach((field) => { values[field] = (formData as Record)[field]; }); - + return JSON.stringify(values); }, [layout?.components, formData]); @@ -279,17 +279,13 @@ function ScreenViewPage() { const conditional = (component as any).conditional; if (!conditional?.enabled) return; - const conditionalResult = evaluateConditional( - conditional, - formData as Record, - layout.components, - ); + const conditionalResult = evaluateConditional(conditional, formData as Record, layout.components); // 숨김 또는 비활성화 상태인 경우 if (!conditionalResult.visible || conditionalResult.disabled) { const fieldName = (component as any).columnName || component.id; const currentValue = (formData as Record)[fieldName]; - + // 값이 있으면 초기화 대상에 추가 if (currentValue !== undefined && currentValue !== "" && currentValue !== null) { fieldsToReset.push(fieldName); @@ -327,7 +323,7 @@ function ScreenViewPage() { // 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용) let containerWidth: number; let containerHeight: number; - + if (isPreviewMode) { // iframe에서는 window 크기를 직접 사용 containerWidth = window.innerWidth; @@ -338,7 +334,7 @@ function ScreenViewPage() { } let newScale: number; - + if (isPreviewMode) { // 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이) const scaleX = containerWidth / designWidth; @@ -414,496 +410,506 @@ function ScreenViewPage() { -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
-
- )} - - {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} - {layoutReady && layout && layout.components.length > 0 ? ( - -
- {/* 최상위 컴포넌트들 렌더링 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); - - // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 - // 모든 컴포넌트는 원본 위치 그대로 사용 - const widthOffset = 0; - - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - // 🔍 전체 버튼 목록 확인 - const allButtons = topLevelComponents.filter((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - return isButton; - }); - - topLevelComponents.forEach((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; - - // 🔧 임시: 버튼 그룹 기능 완전 비활성화 - // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 - const DISABLE_BUTTON_GROUPS = false; - - if ( - !DISABLE_BUTTON_GROUPS && - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; - } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); - } - // else: 모든 버튼을 개별 렌더링 - } - }); - - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - - // TableSearchWidget들을 먼저 찾기 - const tableSearchWidgets = regularComponents.filter( - (c) => (c as any).componentId === "table-search-widget", - ); - - // 조건부 컨테이너들을 찾기 - const conditionalContainers = regularComponents.filter( - (c) => - (c as any).componentId === "conditional-container" || - (c as any).componentType === "conditional-container", - ); - - // 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬 - // ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화 - const autoLayoutComponents = regularComponents; - - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정 - const adjustedComponents = autoLayoutComponents.map((component) => { - const isTableSearchWidget = (component as any).componentId === "table-search-widget"; - const isConditionalContainer = (component as any).componentId === "conditional-container"; - - if (isTableSearchWidget || isConditionalContainer) { - // 자기 자신은 조정하지 않음 - return component; - } - - let totalHeightAdjustment = 0; - - // TableSearchWidget 높이 조정 - for (const widget of tableSearchWidgets) { - const isBelow = component.position.y > widget.position.y; - const heightDiff = getHeightDiff(screenId, widget.id); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - } - } - - // 조건부 컨테이너 높이 조정 - for (const container of conditionalContainers) { - const isBelow = component.position.y > container.position.y; - const actualHeight = conditionalContainerHeights[container.id]; - const originalHeight = container.size?.height || 200; - const heightDiff = actualHeight ? actualHeight - originalHeight : 0; - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - } - } - - if (totalHeightAdjustment > 0) { - return { - ...component, - position: { - ...component.position, - y: component.position.y + totalHeightAdjustment, - }, - }; - } - - return component; - }); - - return ( - <> - {/* 일반 컴포넌트들 */} - {adjustedComponents.map((component) => { - // 조건부 표시 설정이 있는 경우에만 평가 - const conditional = (component as any).conditional; - let conditionalDisabled = false; - - if (conditional?.enabled) { - const conditionalResult = evaluateConditional( - conditional, - formData as Record, - layout?.components || [], - ); - - // 조건에 따라 숨김 처리 - if (!conditionalResult.visible) { - return null; - } - - // 조건에 따라 비활성화 처리 - conditionalDisabled = conditionalResult.disabled; - } - - // 화면 관리 해상도를 사용하므로 위치 조정 불필요 - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - onHeightChange={(componentId, newHeight) => { - setConditionalContainerHeights((prev) => ({ - ...prev, - [componentId]: newHeight, - })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || - component.type === "container" || - component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; - - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={( - _, - selectedData, - sortBy, - sortOrder, - columnOrder, - tableDisplayData, - ) => { - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔍 버튼 그룹 설정 확인 - console.log("🔍 버튼 그룹 설정:", { - groupId, - buttonCount: buttons.length, - buttons: buttons.map((b) => ({ - id: b.id, - label: b.label, - x: b.position.x, - y: b.position.y, - })), - groupConfig: { - layoutBehavior: groupConfig.layoutBehavior, - groupDirection: groupConfig.groupDirection, - groupAlign: groupConfig.groupAlign, - groupGap: groupConfig.groupGap, - }, - }); - - // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, - // 각 버튼의 상대 위치는 원래 위치를 유지 - const firstButtonPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼 그룹 위치에도 widthOffset 적용 - const adjustedGroupPosition = { - ...firstButtonPosition, - x: firstButtonPosition.x + widthOffset, - }; - - // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); - } - - return ( -
- { - // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 - const relativeButton = { - ...button, - position: { - x: button.position.x - firstButtonPosition.x, - y: button.position.y - firstButtonPosition.y, - z: button.position.z || 1, - }, - }; - - return ( -
-
- {}} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - tableDisplayData={tableDisplayData} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); - setFlowSelectedStepId(null); - }} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} -
-
- ) : ( - // 빈 화면일 때 -
-
-
- 📄 +
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
- )} + )} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> -
+ {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} + {layoutReady && layout && layout.components.length > 0 ? ( + +
+ {/* 최상위 컴포넌트들 렌더링 */} + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); + + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; + + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); + + topLevelComponents.forEach((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; + + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; + + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { + if (!buttonGroups[flowConfig.groupId]) { + buttonGroups[flowConfig.groupId] = []; + } + buttonGroups[flowConfig.groupId].push(component); + processedButtonIds.add(component.id); + } + // else: 모든 버튼을 개별 렌더링 + } + }); + + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget", + ); + + // 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => + (c as any).componentId === "conditional-container" || + (c as any).componentType === "conditional-container", + ); + + // 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬 + // ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화 + const autoLayoutComponents = regularComponents; + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정 + const adjustedComponents = autoLayoutComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; + + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 + return component; + } + + let totalHeightAdjustment = 0; + + // TableSearchWidget 높이 조정 + for (const widget of tableSearchWidgets) { + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } + } + + // 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? actualHeight - originalHeight : 0; + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } + } + + if (totalHeightAdjustment > 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + + return ( + <> + {/* 일반 컴포넌트들 */} + {adjustedComponents.map((component) => { + // 조건부 표시 설정이 있는 경우에만 평가 + const conditional = (component as any).conditional; + let conditionalDisabled = false; + + if (conditional?.enabled) { + const conditionalResult = evaluateConditional( + conditional, + formData as Record, + layout?.components || [], + ); + + // 조건에 따라 숨김 처리 + if (!conditionalResult.visible) { + return null; + } + + // 조건에 따라 비활성화 처리 + conditionalDisabled = conditionalResult.disabled; + } + + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 + return ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; + + return ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ); + })} + + ); + })} + + {/* 🆕 플로우 버튼 그룹들 */} + {Object.entries(buttonGroups).map(([groupId, buttons]) => { + if (buttons.length === 0) return null; + + const firstButton = buttons[0]; + const groupConfig = (firstButton as any).webTypeConfig + ?.flowVisibilityConfig as FlowVisibilityConfig; + + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + let groupWidth = 0; + let groupHeight = 0; + + if (direction === "horizontal") { + groupWidth = buttons.reduce((total, button, index) => { + const buttonWidth = button.size?.width || 100; + const gapWidth = index < buttons.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); + } else { + groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); + groupHeight = buttons.reduce((total, button, index) => { + const buttonHeight = button.size?.height || 40; + const gapHeight = index < buttons.length - 1 ? gap : 0; + return total + buttonHeight + gapHeight; + }, 0); + } + + return ( +
+ { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 + const relativeButton = { + ...button, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + tableDisplayData={tableDisplayData} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} +
+
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+
+ )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + /> +
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index cd5db47f..b05f03b6 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1081,10 +1081,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // detailSettings 파싱 (문자열이면 JSON 파싱) let detailSettings = col.detailSettings || col.detail_settings; if (typeof detailSettings === "string") { - try { - detailSettings = JSON.parse(detailSettings); - } catch (e) { - console.warn("detailSettings 파싱 실패:", e); + // JSON 형식인 경우에만 파싱 시도 (중괄호로 시작하는 경우) + if (detailSettings.trim().startsWith("{")) { + try { + detailSettings = JSON.parse(detailSettings); + } catch (e) { + console.warn("detailSettings 파싱 실패:", e); + detailSettings = {}; + } + } else { + // JSON이 아닌 일반 문자열인 경우 빈 객체로 처리 detailSettings = {}; } } diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 36e2bbe5..b2d59539 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -528,6 +528,7 @@ export function ScreenSettingModal({ {/* ScreenDesigner 전체 화면 모달 */} + 화면 디자이너
+ 플로우 편집기
{/* 헤더 */}
diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx new file mode 100644 index 00000000..c604e465 --- /dev/null +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -0,0 +1,948 @@ +"use client"; + +/** + * UnifiedRepeater 컴포넌트 + * + * 렌더링 모드: + * - inline: 현재 테이블 컬럼 직접 입력 + * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 + * + * RepeaterTable 및 ItemSelectionModal 재사용 + */ + +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + UnifiedRepeaterConfig, + UnifiedRepeaterProps, + RepeaterColumnConfig as UnifiedColumnConfig, + DEFAULT_REPEATER_CONFIG, +} from "@/types/unified-repeater"; +import { apiClient } from "@/lib/api/client"; +import { allocateNumberingCode } from "@/lib/api/numberingRule"; +import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; + +// modal-repeater-table 컴포넌트 재사용 +import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; +import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal"; +import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types"; + +// 전역 UnifiedRepeater 등록 (buttonActions에서 사용) +declare global { + interface Window { + __unifiedRepeaterInstances?: Set; + } +} + +export const UnifiedRepeater: React.FC = ({ + config: propConfig, + parentId, + data: initialData, + onDataChange, + onRowClick, + className, +}) => { + // 설정 병합 + const config: UnifiedRepeaterConfig = useMemo( + () => ({ + ...DEFAULT_REPEATER_CONFIG, + ...propConfig, + dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource }, + features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features }, + modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal }, + }), + [propConfig], + ); + + // 상태 + const [data, setData] = useState(initialData || []); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [modalOpen, setModalOpen] = useState(false); + + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 + const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); + + // 소스 테이블 컬럼 라벨 매핑 + const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); + + // 🆕 소스 테이블의 카테고리 타입 컬럼 목록 + const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]); + + // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + + // 현재 테이블 컬럼 정보 (inputType 매핑용) + const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); + + // 동적 데이터 소스 상태 + const [activeDataSources, setActiveDataSources] = useState>({}); + + // 🆕 최신 엔티티 참조 정보 (column_labels에서 조회) + const [resolvedSourceTable, setResolvedSourceTable] = useState(""); + const [resolvedReferenceKey, setResolvedReferenceKey] = useState("id"); + + const isModalMode = config.renderMode === "modal"; + + // 전역 리피터 등록 + // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) + useEffect(() => { + const targetTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTableName) { + if (!window.__unifiedRepeaterInstances) { + window.__unifiedRepeaterInstances = new Set(); + } + window.__unifiedRepeaterInstances.add(targetTableName); + } + + return () => { + if (targetTableName && window.__unifiedRepeaterInstances) { + window.__unifiedRepeaterInstances.delete(targetTableName); + } + }; + }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); + + // 저장 이벤트 리스너 + useEffect(() => { + const handleSaveEvent = async (event: CustomEvent) => { + // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const eventParentId = event.detail?.parentId; + const mainFormData = event.detail?.mainFormData; + + // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) + const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; + + if (!tableName || data.length === 0) { + return; + } + + // UnifiedRepeater 저장 시작 + const saveInfo = { + tableName, + useCustomTable: config.useCustomTable, + mainTableName: config.mainTableName, + foreignKeyColumn: config.foreignKeyColumn, + masterRecordId, + dataLength: data.length, + }; + console.log("UnifiedRepeater 저장 시작", saveInfo); + + try { + // 테이블 유효 컬럼 조회 + let validColumns: Set = new Set(); + try { + const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = + columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || []; + validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name)); + } catch { + console.warn("테이블 컬럼 정보 조회 실패"); + } + + for (let i = 0; i < data.length; i++) { + const row = data[i]; + + // 내부 필드 제거 + const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); + + // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) + let mergedData: Record; + if (config.useCustomTable && config.mainTableName) { + // 커스텀 테이블: 리피터 데이터만 저장 + mergedData = { ...cleanRow }; + + // 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용 + if (config.foreignKeyColumn) { + // foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용 + // 없으면 마스터 레코드 ID 사용 (기존 동작) + const sourceColumn = config.foreignKeySourceColumn; + let fkValue: any; + + if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) { + // mainFormData에서 참조 컬럼 값 가져오기 + fkValue = mainFormData[sourceColumn]; + } else { + // 기본: 마스터 레코드 ID 사용 + fkValue = masterRecordId; + } + + if (fkValue !== undefined && fkValue !== null) { + mergedData[config.foreignKeyColumn] = fkValue; + } + } + } else { + // 기존 방식: 메인 폼 데이터 병합 + const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; + mergedData = { + ...mainFormDataWithoutId, + ...cleanRow, + }; + } + + // 유효하지 않은 컬럼 제거 + const filteredData: Record = {}; + for (const [key, value] of Object.entries(mergedData)) { + if (validColumns.size === 0 || validColumns.has(key)) { + filteredData[key] = value; + } + } + + await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); + } + } catch (error) { + console.error("❌ UnifiedRepeater 저장 실패:", error); + throw error; + } + }; + + // V2 EventBus 구독 + const unsubscribe = v2EventBus.subscribe( + V2_EVENTS.REPEATER_SAVE, + async (payload) => { + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + if (payload.tableName === tableName) { + await handleSaveEvent({ detail: payload } as CustomEvent); + } + }, + { componentId: `unified-repeater-${config.dataSource?.tableName}` }, + ); + + // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) + window.addEventListener("repeaterSave" as any, handleSaveEvent); + return () => { + unsubscribe(); + window.removeEventListener("repeaterSave" as any, handleSaveEvent); + }; + }, [ + data, + config.dataSource?.tableName, + config.useCustomTable, + config.mainTableName, + config.foreignKeyColumn, + parentId, + ]); + + // 현재 테이블 컬럼 정보 로드 + useEffect(() => { + const loadCurrentTableColumnInfo = async () => { + const tableName = config.dataSource?.tableName; + if (!tableName) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + + const columnMap: Record = {}; + columns.forEach((col: any) => { + const name = col.columnName || col.column_name || col.name; + columnMap[name] = { + inputType: col.inputType || col.input_type || col.webType || "text", + displayName: col.displayName || col.display_name || col.label || name, + detailSettings: col.detailSettings || col.detail_settings, + }; + }); + setCurrentTableColumnInfo(columnMap); + } catch (error) { + console.error("컬럼 정보 로드 실패:", error); + } + }; + loadCurrentTableColumnInfo(); + }, [config.dataSource?.tableName]); + + // 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서) + useEffect(() => { + const resolveEntityReference = async () => { + const tableName = config.dataSource?.tableName; + const foreignKey = config.dataSource?.foreignKey; + + if (!isModalMode || !tableName || !foreignKey) { + // config에 저장된 값을 기본값으로 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + return; + } + + try { + // 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회 + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + + const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey); + + if (fkColumn) { + // column_labels의 reference_table 사용 (항상 최신값) + const refTable = + fkColumn.detailSettings?.referenceTable || + fkColumn.reference_table || + fkColumn.referenceTable || + config.dataSource?.sourceTable || + ""; + const refKey = + fkColumn.detailSettings?.referenceColumn || + fkColumn.reference_column || + fkColumn.referenceColumn || + config.dataSource?.referenceKey || + "id"; + + setResolvedSourceTable(refTable); + setResolvedReferenceKey(refKey); + } else { + // FK 컬럼을 찾지 못한 경우 config 값 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + } + } catch (error) { + console.error("엔티티 참조 정보 조회 실패:", error); + // 오류 시 config 값 사용 + setResolvedSourceTable(config.dataSource?.sourceTable || ""); + setResolvedReferenceKey(config.dataSource?.referenceKey || "id"); + } + }; + + resolveEntityReference(); + }, [ + config.dataSource?.tableName, + config.dataSource?.foreignKey, + config.dataSource?.sourceTable, + config.dataSource?.referenceKey, + isModalMode, + ]); + + // 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용 + // 🆕 카테고리 타입 컬럼도 함께 감지 + useEffect(() => { + const loadSourceColumnLabels = async () => { + if (!isModalMode || !resolvedSourceTable) return; + + try { + const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`); + const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + + const labels: Record = {}; + const categoryCols: string[] = []; + + columns.forEach((col: any) => { + const name = col.columnName || col.column_name || col.name; + labels[name] = col.displayName || col.display_name || col.label || name; + + // 🆕 카테고리 타입 컬럼 감지 + const inputType = col.inputType || col.input_type || ""; + if (inputType === "category") { + categoryCols.push(name); + } + }); + + setSourceColumnLabels(labels); + setSourceCategoryColumns(categoryCols); + } catch (error) { + console.error("소스 컬럼 라벨 로드 실패:", error); + } + }; + loadSourceColumnLabels(); + }, [resolvedSourceTable, isModalMode]); + + // UnifiedColumnConfig → RepeaterColumnConfig 변환 + // 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분) + const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => { + return config.columns + .filter((col: UnifiedColumnConfig) => col.visible !== false) + .map((col: UnifiedColumnConfig): RepeaterColumnConfig => { + const colInfo = currentTableColumnInfo[col.key]; + const inputType = col.inputType || colInfo?.inputType || "text"; + + // 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용) + if (col.isSourceDisplay) { + const label = col.title || sourceColumnLabels[col.key] || col.key; + return { + field: `_display_${col.key}`, + label, + type: "text", + editable: false, + calculated: true, + width: col.width === "auto" ? undefined : col.width, + }; + } + + // 일반 입력 컬럼 + let type: "text" | "number" | "date" | "select" | "category" = "text"; + if (inputType === "number" || inputType === "decimal") type = "number"; + else if (inputType === "date" || inputType === "datetime") type = "date"; + else if (inputType === "code") type = "select"; + else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 + + // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) + // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + let categoryRef: string | undefined; + if (inputType === "category") { + // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } + } + + return { + field: col.key, + label: col.title || colInfo?.displayName || col.key, + type, + editable: col.editable !== false, + width: col.width === "auto" ? undefined : col.width, + required: false, + categoryRef, // 🆕 카테고리 참조 ID 전달 + hidden: col.hidden, // 🆕 히든 처리 + autoFill: col.autoFill, // 🆕 자동 입력 설정 + }; + }); + }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); + + // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + useEffect(() => { + const loadCategoryLabels = async () => { + if (sourceCategoryColumns.length === 0 || data.length === 0) { + return; + } + + // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 + const allCodes = new Set(); + for (const row of data) { + for (const col of sourceCategoryColumns) { + // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 + const val = row[`_display_${col}`] || row[col]; + if (val && typeof val === "string") { + const codes = val + .split(",") + .map((c: string) => c.trim()) + .filter(Boolean); + for (const code of codes) { + if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) { + allCodes.add(code); + } + } + } + } + } + + if (allCodes.size === 0) { + return; + } + + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(allCodes), + }); + + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ + ...prev, + ...response.data.data, + })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [data, sourceCategoryColumns]); + + // 데이터 변경 핸들러 + const handleDataChange = useCallback( + (newData: any[]) => { + setData(newData); + + // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + // 각 행에 _targetTable 추가 + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } + } + + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 + setAutoWidthTrigger((prev) => prev + 1); + }, + [onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], + ); + + // 행 변경 핸들러 + const handleRowChange = useCallback( + (index: number, newRow: any) => { + const newData = [...data]; + newData[index] = newRow; + setData(newData); + + // 🆕 _targetTable 메타데이터 포함 + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } + } + }, + [data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], + ); + + // 행 삭제 핸들러 + const handleRowDelete = useCallback( + (index: number) => { + const newData = data.filter((_, i) => i !== index); + handleDataChange(newData); // 🆕 handleDataChange 사용 + + // 선택 상태 업데이트 + const newSelected = new Set(); + selectedRows.forEach((i) => { + if (i < index) newSelected.add(i); + else if (i > index) newSelected.add(i - 1); + }); + setSelectedRows(newSelected); + }, + [data, selectedRows, handleDataChange], + ); + + // 일괄 삭제 핸들러 + const handleBulkDelete = useCallback(() => { + const newData = data.filter((_, index) => !selectedRows.has(index)); + handleDataChange(newData); // 🆕 handleDataChange 사용 + setSelectedRows(new Set()); + }, [data, selectedRows, handleDataChange]); + + // 행 추가 (inline 모드) + // 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외) + const generateAutoFillValueSync = useCallback( + (col: any, rowIndex: number, mainFormData?: Record) => { + if (!col.autoFill || col.autoFill.type === "none") return undefined; + + const now = new Date(); + + switch (col.autoFill.type) { + case "currentDate": + return now.toISOString().split("T")[0]; // YYYY-MM-DD + + case "currentDateTime": + return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + + case "sequence": + return rowIndex + 1; // 1부터 시작하는 순번 + + case "numbering": + // 채번은 별도 비동기 처리 필요 + return null; // null 반환하여 비동기 처리 필요함을 표시 + + case "fromMainForm": + if (col.autoFill.sourceField && mainFormData) { + return mainFormData[col.autoFill.sourceField]; + } + return ""; + + case "fixed": + return col.autoFill.fixedValue ?? ""; + + default: + return undefined; + } + }, + [], + ); + + // 🆕 채번 API 호출 (비동기) + const generateNumberingCode = useCallback(async (ruleId: string): Promise => { + try { + const result = await allocateNumberingCode(ruleId); + if (result.success && result.data?.generatedCode) { + return result.data.generatedCode; + } + console.error("채번 실패:", result.error); + return ""; + } catch (error) { + console.error("채번 API 호출 실패:", error); + return ""; + } + }, []); + + // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 + const handleAddRow = useCallback(async () => { + if (isModalMode) { + setModalOpen(true); + } else { + const newRow: any = { _id: `new_${Date.now()}` }; + const currentRowCount = data.length; + + // 먼저 동기적 자동 입력 값 적용 + for (const col of config.columns) { + const autoValue = generateAutoFillValueSync(col, currentRowCount); + if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { + // 채번 규칙: 즉시 API 호출 + newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); + } else if (autoValue !== undefined) { + newRow[col.key] = autoValue; + } else { + newRow[col.key] = ""; + } + } + + const newData = [...data, newRow]; + handleDataChange(newData); + } + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]); + + // 모달에서 항목 선택 - 비동기로 변경 + const handleSelectItems = useCallback( + async (items: Record[]) => { + const fkColumn = config.dataSource?.foreignKey; + const currentRowCount = data.length; + + // 채번이 필요한 컬럼 찾기 + const numberingColumns = config.columns.filter( + (col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId, + ); + + const newRows = await Promise.all( + items.map(async (item, index) => { + const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; + + // FK 값 저장 (resolvedReferenceKey 사용) + if (fkColumn && item[resolvedReferenceKey]) { + row[fkColumn] = item[resolvedReferenceKey]; + } + + // 모든 컬럼 처리 (순서대로) + for (const col of config.columns) { + if (col.isSourceDisplay) { + // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) + row[`_display_${col.key}`] = item[col.key] || ""; + } else { + // 자동 입력 값 적용 + const autoValue = generateAutoFillValueSync(col, currentRowCount + index); + if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { + // 채번 규칙: 즉시 API 호출 + row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); + } else if (autoValue !== undefined) { + row[col.key] = autoValue; + } else if (row[col.key] === undefined) { + // 입력 컬럼: 빈 값으로 초기화 + row[col.key] = ""; + } + } + } + + return row; + }), + ); + + const newData = [...data, ...newRows]; + handleDataChange(newData); + setModalOpen(false); + }, + [ + config.dataSource?.foreignKey, + resolvedReferenceKey, + config.columns, + data, + handleDataChange, + generateAutoFillValueSync, + generateNumberingCode, + ], + ); + + // 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링 + const sourceColumns = useMemo(() => { + return config.columns + .filter((col) => col.isSourceDisplay && col.visible !== false) + .map((col) => col.key) + .filter((key) => key && key !== "none"); + }, [config.columns]); + + // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 + const dataRef = useRef(data); + dataRef.current = data; + + useEffect(() => { + const handleBeforeFormSave = async (event: Event) => { + const customEvent = event as CustomEvent; + const formData = customEvent.detail?.formData; + + if (!formData || !dataRef.current.length) return; + + // 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환 + const processedData = await Promise.all( + dataRef.current.map(async (row) => { + const newRow = { ...row }; + + for (const key of Object.keys(newRow)) { + const value = newRow[key]; + if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) { + // __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출 + const match = value.match(/__NUMBERING_RULE__(.+)__/); + if (match) { + const ruleId = match[1]; + try { + const result = await allocateNumberingCode(ruleId); + if (result.success && result.data?.generatedCode) { + newRow[key] = result.data.generatedCode; + } else { + console.error("채번 실패:", result.error); + newRow[key] = ""; // 채번 실패 시 빈 값 + } + } catch (error) { + console.error("채번 API 호출 실패:", error); + newRow[key] = ""; + } + } + } + } + + return newRow; + }), + ); + + // 처리된 데이터를 formData에 추가 + const fieldName = config.fieldName || "repeaterData"; + formData[fieldName] = processedData; + }; + + // V2 EventBus 구독 + const unsubscribe = v2EventBus.subscribe( + V2_EVENTS.FORM_SAVE_COLLECT, + async (payload) => { + // formData 객체가 있으면 데이터 수집 + const fakeEvent = { + detail: { formData: payload.formData }, + } as CustomEvent; + await handleBeforeFormSave(fakeEvent); + }, + { componentId: `unified-repeater-${config.dataSource?.tableName}` }, + ); + + // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) + window.addEventListener("beforeFormSave", handleBeforeFormSave); + + return () => { + unsubscribe(); + window.removeEventListener("beforeFormSave", handleBeforeFormSave); + }; + }, [config.fieldName]); + + // 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용) + useEffect(() => { + // componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달 + const handleComponentDataTransfer = async (event: Event) => { + const customEvent = event as CustomEvent; + const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {}; + + // 이 컴포넌트가 대상인지 확인 + if (targetComponentId !== parentId && targetComponentId !== config.fieldName) { + return; + } + + if (!transferData || transferData.length === 0) { + return; + } + + // 데이터 매핑 처리 + const mappedData = transferData.map((item: any, index: number) => { + const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; + + if (mappingRules && mappingRules.length > 0) { + // 매핑 규칙이 있으면 적용 + mappingRules.forEach((rule: any) => { + newRow[rule.targetField] = item[rule.sourceField]; + }); + } else { + // 매핑 규칙 없으면 그대로 복사 + Object.assign(newRow, item); + } + + return newRow; + }); + + // mode에 따라 데이터 처리 + if (mode === "replace") { + handleDataChange(mappedData); + } else if (mode === "merge") { + // 중복 제거 후 병합 (id 기준) + const existingIds = new Set(data.map((row) => row.id || row._id)); + const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id)); + handleDataChange([...data, ...newItems]); + } else { + // 기본: append + handleDataChange([...data, ...mappedData]); + } + }; + + // splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달 + const handleSplitPanelDataTransfer = async (event: Event) => { + const customEvent = event as CustomEvent; + const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {}; + + if (!transferData || transferData.length === 0) { + return; + } + + // 데이터 매핑 처리 + const mappedData = transferData.map((item: any, index: number) => { + const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; + + if (mappingRules && mappingRules.length > 0) { + mappingRules.forEach((rule: any) => { + newRow[rule.targetField] = item[rule.sourceField]; + }); + } else { + Object.assign(newRow, item); + } + + return newRow; + }); + + // mode에 따라 데이터 처리 + if (mode === "replace") { + handleDataChange(mappedData); + } else { + handleDataChange([...data, ...mappedData]); + } + }; + + // V2 EventBus 구독 + const unsubscribeComponent = v2EventBus.subscribe( + V2_EVENTS.COMPONENT_DATA_TRANSFER, + (payload) => { + const fakeEvent = { + detail: { + targetComponentId: payload.targetComponentId, + transferData: [payload.data], + mappingRules: [], + mode: "append", + }, + } as CustomEvent; + handleComponentDataTransfer(fakeEvent); + }, + { componentId: `unified-repeater-${config.dataSource?.tableName}` }, + ); + + const unsubscribeSplitPanel = v2EventBus.subscribe( + V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER, + (payload) => { + const fakeEvent = { + detail: { + transferData: [payload.data], + mappingRules: [], + mode: "append", + }, + } as CustomEvent; + handleSplitPanelDataTransfer(fakeEvent); + }, + { componentId: `unified-repeater-${config.dataSource?.tableName}` }, + ); + + // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) + window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener); + window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); + + return () => { + unsubscribeComponent(); + unsubscribeSplitPanel(); + window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener); + window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); + }; + }, [parentId, config.fieldName, data, handleDataChange]); + + return ( +
+ {/* 헤더 영역 */} +
+
+ + {data.length > 0 && `${data.length}개 항목`} + {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} + +
+
+ {selectedRows.size > 0 && ( + + )} + +
+
+ + {/* Repeater 테이블 */} + { + setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); + }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + equalizeWidthsTrigger={autoWidthTrigger} + categoryColumns={sourceCategoryColumns} + categoryLabelMap={categoryLabelMap} + /> + + {/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} + {isModalMode && ( + + )} +
+ ); +}; + +UnifiedRepeater.displayName = "UnifiedRepeater"; + +// V2ErrorBoundary로 래핑된 안전한 버전 export +export const SafeUnifiedRepeater: React.FC = (props) => { + return ( + + + + ); +}; + +export default UnifiedRepeater; diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx new file mode 100644 index 00000000..99a82e17 --- /dev/null +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -0,0 +1,814 @@ +"use client"; + +/** + * UnifiedSelect + * + * 통합 선택 컴포넌트 + * - dropdown: 드롭다운 선택 + * - radio: 라디오 버튼 그룹 + * - check: 체크박스 그룹 + * - tag: 태그 선택 + * - toggle: 토글 스위치 + * - swap: 스왑 선택 (좌우 이동) + */ + +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { UnifiedSelectProps, SelectOption } from "@/types/unified-components"; +import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; +import { apiClient } from "@/lib/api/client"; +import UnifiedFormContext from "./UnifiedFormContext"; + +/** + * 드롭다운 선택 컴포넌트 + */ +const DropdownSelect = forwardRef< + HTMLButtonElement, + { + options: SelectOption[]; + value?: string | string[]; + onChange?: (value: string | string[]) => void; + placeholder?: string; + searchable?: boolean; + multiple?: boolean; + maxSelect?: number; + allowClear?: boolean; + disabled?: boolean; + className?: string; + } +>( + ( + { + options, + value, + onChange, + placeholder = "선택", + searchable, + multiple, + maxSelect, + allowClear = true, + disabled, + className, + }, + ref, + ) => { + const [open, setOpen] = useState(false); + + // 단일 선택 + 검색 불가능 → 기본 Select 사용 + if (!searchable && !multiple) { + return ( + + ); + } + + // 검색 가능 또는 다중 선택 → Combobox 사용 + const selectedValues = useMemo(() => { + if (!value) return []; + return Array.isArray(value) ? value : [value]; + }, [value]); + + const selectedLabels = useMemo(() => { + return selectedValues.map((v) => options.find((o) => o.value === v)?.label).filter(Boolean) as string[]; + }, [selectedValues, options]); + + const handleSelect = useCallback( + (selectedValue: string) => { + if (multiple) { + const newValues = selectedValues.includes(selectedValue) + ? selectedValues.filter((v) => v !== selectedValue) + : maxSelect && selectedValues.length >= maxSelect + ? selectedValues + : [...selectedValues, selectedValue]; + onChange?.(newValues); + } else { + onChange?.(selectedValue); + setOpen(false); + } + }, + [multiple, selectedValues, maxSelect, onChange], + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(multiple ? [] : ""); + }, + [multiple, onChange], + ); + + return ( + + + + + + { + // value는 CommandItem의 value (라벨) + // search는 검색어 + if (!search) return 1; + const normalizedValue = value.toLowerCase(); + const normalizedSearch = search.toLowerCase(); + if (normalizedValue.includes(normalizedSearch)) return 1; + return 0; + }} + > + {searchable && } + + 검색 결과가 없습니다. + + {options.map((option) => { + const displayLabel = option.label || option.value || "(빈 값)"; + return ( + handleSelect(option.value)}> + + {displayLabel} + + ); + })} + + + + + + ); + }, +); +DropdownSelect.displayName = "DropdownSelect"; + +/** + * 라디오 선택 컴포넌트 + */ +const RadioSelect = forwardRef< + HTMLDivElement, + { + options: SelectOption[]; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + className?: string; + } +>(({ options, value, onChange, disabled, className }, ref) => { + return ( + + {options.map((option) => ( +
+ + +
+ ))} +
+ ); +}); +RadioSelect.displayName = "RadioSelect"; + +/** + * 체크박스 선택 컴포넌트 + */ +const CheckSelect = forwardRef< + HTMLDivElement, + { + options: SelectOption[]; + value?: string[]; + onChange?: (value: string[]) => void; + maxSelect?: number; + disabled?: boolean; + className?: string; + } +>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => { + const handleChange = useCallback( + (optionValue: string, checked: boolean) => { + if (checked) { + if (maxSelect && value.length >= maxSelect) return; + onChange?.([...value, optionValue]); + } else { + onChange?.(value.filter((v) => v !== optionValue)); + } + }, + [value, maxSelect, onChange], + ); + + return ( +
+ {options.map((option) => ( +
+ handleChange(option.value, checked as boolean)} + disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))} + /> + +
+ ))} +
+ ); +}); +CheckSelect.displayName = "CheckSelect"; + +/** + * 태그 선택 컴포넌트 + */ +const TagSelect = forwardRef< + HTMLDivElement, + { + options: SelectOption[]; + value?: string[]; + onChange?: (value: string[]) => void; + maxSelect?: number; + disabled?: boolean; + className?: string; + } +>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => { + const handleToggle = useCallback( + (optionValue: string) => { + const isSelected = value.includes(optionValue); + if (isSelected) { + onChange?.(value.filter((v) => v !== optionValue)); + } else { + if (maxSelect && value.length >= maxSelect) return; + onChange?.([...value, optionValue]); + } + }, + [value, maxSelect, onChange], + ); + + return ( +
+ {options.map((option) => { + const isSelected = value.includes(option.value); + return ( + !disabled && handleToggle(option.value)} + > + {option.label} + {isSelected && } + + ); + })} +
+ ); +}); +TagSelect.displayName = "TagSelect"; + +/** + * 토글 선택 컴포넌트 (Boolean용) + */ +const ToggleSelect = forwardRef< + HTMLDivElement, + { + options: SelectOption[]; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + className?: string; + } +>(({ options, value, onChange, disabled, className }, ref) => { + // 토글은 2개 옵션만 지원 + const [offOption, onOption] = + options.length >= 2 + ? [options[0], options[1]] + : [ + { value: "false", label: "아니오" }, + { value: "true", label: "예" }, + ]; + + const isOn = value === onOption.value; + + return ( +
+ {offOption.label} + onChange?.(checked ? onOption.value : offOption.value)} + disabled={disabled} + /> + {onOption.label} +
+ ); +}); +ToggleSelect.displayName = "ToggleSelect"; + +/** + * 스왑 선택 컴포넌트 (좌우 이동 방식) + */ +const SwapSelect = forwardRef< + HTMLDivElement, + { + options: SelectOption[]; + value?: string[]; + onChange?: (value: string[]) => void; + maxSelect?: number; + disabled?: boolean; + className?: string; + } +>(({ options, value = [], onChange, disabled, className }, ref) => { + const available = useMemo(() => options.filter((o) => !value.includes(o.value)), [options, value]); + + const selected = useMemo(() => options.filter((o) => value.includes(o.value)), [options, value]); + + const handleMoveRight = useCallback( + (optionValue: string) => { + onChange?.([...value, optionValue]); + }, + [value, onChange], + ); + + const handleMoveLeft = useCallback( + (optionValue: string) => { + onChange?.(value.filter((v) => v !== optionValue)); + }, + [value, onChange], + ); + + const handleMoveAllRight = useCallback(() => { + onChange?.(options.map((o) => o.value)); + }, [options, onChange]); + + const handleMoveAllLeft = useCallback(() => { + onChange?.([]); + }, [onChange]); + + return ( +
+ {/* 왼쪽: 선택 가능 */} +
+
선택 가능
+
+ {available.map((option) => ( +
!disabled && handleMoveRight(option.value)} + > + {option.label} +
+ ))} + {available.length === 0 &&
항목 없음
} +
+
+ + {/* 중앙: 이동 버튼 */} +
+ + +
+ + {/* 오른쪽: 선택됨 */} +
+
선택됨
+
+ {selected.map((option) => ( +
!disabled && handleMoveLeft(option.value)} + > + {option.label} + +
+ ))} + {selected.length === 0 &&
선택 없음
} +
+
+
+ ); +}); +SwapSelect.displayName = "SwapSelect"; + +/** + * 메인 UnifiedSelect 컴포넌트 + */ +export const UnifiedSelect = forwardRef((props, ref) => { + const { + id, + label, + required, + readonly, + disabled, + style, + size, + config: configProp, + value, + onChange, + tableName, + columnName, + } = props; + + // config가 없으면 기본값 사용 + const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] }; + + const [options, setOptions] = useState(config.options || []); + const [loading, setLoading] = useState(false); + const [optionsLoaded, setOptionsLoaded] = useState(false); + + // 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용) + const rawSource = config.source; + const categoryTable = (config as any).categoryTable; + const categoryColumn = (config as any).categoryColumn; + + // category 소스 유지 (category_values_test 테이블에서 로드) + const source = rawSource; + const codeGroup = config.codeGroup; + + const entityTable = config.entityTable; + const entityValueColumn = config.entityValueColumn || config.entityValueField; + const entityLabelColumn = config.entityLabelColumn || config.entityLabelField; + const table = config.table; + const valueColumn = config.valueColumn; + const labelColumn = config.labelColumn; + const apiEndpoint = config.apiEndpoint; + const staticOptions = config.options; + + // 계층 코드 연쇄 선택 관련 + const hierarchical = config.hierarchical; + const parentField = config.parentField; + + // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) + const formContext = useContext(UnifiedFormContext); + + // 부모 필드의 값 계산 + const parentValue = useMemo(() => { + if (!hierarchical || !parentField) return null; + + // FormContext가 있으면 거기서 값 가져오기 + if (formContext) { + const val = formContext.getValue(parentField); + return val as string | null; + } + + return null; + }, [hierarchical, parentField, formContext]); + + // 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용) + useEffect(() => { + // 계층 구조인 경우 부모 값이 변경되면 다시 로드 + if (hierarchical && source === "code") { + setOptionsLoaded(false); + } + }, [parentValue, hierarchical, source]); + + useEffect(() => { + // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) + if (optionsLoaded && source !== "static") { + return; + } + + const loadOptions = async () => { + if (source === "static") { + setOptions(staticOptions || []); + setOptionsLoaded(true); + return; + } + + setLoading(true); + try { + let fetchedOptions: SelectOption[] = []; + + if (source === "code" && codeGroup) { + // 계층 구조 사용 시 자식 코드만 로드 + if (hierarchical) { + const params = new URLSearchParams(); + if (parentValue) { + params.append("parentCodeValue", parentValue); + } + const queryString = params.toString(); + const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`; + const response = await apiClient.get(url); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({ + value: item.value, + label: item.label, + })); + } + } else { + // 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options) + const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ + value: item.value, + label: item.label, + })); + } + } + } else if (source === "db" && table) { + // DB 테이블에서 로드 + const response = await apiClient.get(`/entity/${table}/options`, { + params: { + value: valueColumn || "id", + label: labelColumn || "name", + }, + }); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data; + } + } else if (source === "entity" && entityTable) { + // 엔티티(참조 테이블)에서 로드 + const valueCol = entityValueColumn || "id"; + const labelCol = entityLabelColumn || "name"; + const response = await apiClient.get(`/entity/${entityTable}/options`, { + params: { + value: valueCol, + label: labelCol, + }, + }); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data; + } + } else if (source === "api" && apiEndpoint) { + // 외부 API에서 로드 + const response = await apiClient.get(apiEndpoint); + const data = response.data; + if (Array.isArray(data)) { + fetchedOptions = data; + } + } else if (source === "category") { + // 카테고리에서 로드 (category_values_test 테이블) + // tableName, columnName은 props에서 가져옴 + const catTable = categoryTable || tableName; + const catColumn = categoryColumn || columnName; + + if (catTable && catColumn) { + const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + const data = response.data; + if (data.success && data.data) { + // 트리 구조를 평탄화하여 옵션으로 변환 + // value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함 + const flattenTree = ( + items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], + depth: number = 0, + ): SelectOption[] => { + const result: SelectOption[] = []; + for (const item of items) { + const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + result.push({ + value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치) + label: prefix + item.valueLabel, + }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children, depth + 1)); + } + } + return result; + }; + fetchedOptions = flattenTree(data.data); + } + } + } else if (source === "select" || source === "distinct") { + // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 + // tableName, columnName은 props에서 가져옴 + // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 + const isValidColumnName = columnName && !columnName.startsWith("comp_"); + if (tableName && isValidColumnName) { + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } else if (!isValidColumnName) { + // columnName이 없거나 유효하지 않으면 빈 옵션 + console.warn("UnifiedSelect: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", { + tableName, + columnName, + }); + } + } + + setOptions(fetchedOptions); + setOptionsLoaded(true); + } catch (error) { + console.error("옵션 로딩 실패:", error); + setOptions([]); + } finally { + setLoading(false); + } + }; + + loadOptions(); + }, [ + source, + entityTable, + entityValueColumn, + entityLabelColumn, + codeGroup, + table, + valueColumn, + labelColumn, + apiEndpoint, + staticOptions, + optionsLoaded, + hierarchical, + parentValue, + ]); + + // 모드별 컴포넌트 렌더링 + const renderSelect = () => { + if (loading) { + return
로딩 중...
; + } + + const isDisabled = disabled || readonly; + + switch (config.mode) { + case "dropdown": + return ( + + ); + + case "radio": + return ( + onChange?.(v)} + disabled={isDisabled} + /> + ); + + case "check": + return ( + + ); + + case "tag": + return ( + + ); + + case "toggle": + return ( + onChange?.(v)} + disabled={isDisabled} + /> + ); + + case "swap": + return ( + + ); + + default: + return ; + } + }; + + const showLabel = label && style?.labelDisplay !== false; + const componentWidth = size?.width || style?.width; + const componentHeight = size?.height || style?.height; + + return ( +
+ {showLabel && ( + + )} +
{renderSelect()}
+
+ ); +}); + +UnifiedSelect.displayName = "UnifiedSelect"; + +export default UnifiedSelect; diff --git a/frontend/components/v2/V2Group.tsx b/frontend/components/v2/V2Group.tsx index ccd5f8b6..8e156ff1 100644 --- a/frontend/components/v2/V2Group.tsx +++ b/frontend/components/v2/V2Group.tsx @@ -253,11 +253,13 @@ const ModalGroup = forwardRef - {(title || description) && ( + {title || description ? ( {title && {title}} {description && {description}} + ) : ( + 모달 )} {children} @@ -313,11 +315,13 @@ const FormModalGroup = forwardRef - {(title || description) && ( + {title || description ? ( {title && {title}} {description && {description}} + ) : ( + 폼 모달 )}
{children}
diff --git a/frontend/components/v2/index.ts b/frontend/components/v2/index.ts index 563089e9..b564a4bb 100644 --- a/frontend/components/v2/index.ts +++ b/frontend/components/v2/index.ts @@ -1,6 +1,6 @@ /** * V2 Components 모듈 인덱스 - * + * * V2 통합 컴포넌트 시스템 */ @@ -31,9 +31,9 @@ export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel"; export { V2ComponentsDemo } from "./V2ComponentsDemo"; // 폼 컨텍스트 및 액션 -export { - V2FormProvider, - useV2Form, +export { + V2FormProvider, + useV2Form, useV2FormOptional, useV2Field, useCascadingOptions, @@ -66,20 +66,20 @@ export type { AutoFillConfig, CascadingConfig, MutualExclusionConfig, - + // V2Input 타입 V2InputType, V2InputFormat, V2InputConfig, V2InputProps, - + // V2Select 타입 V2SelectMode, V2SelectSource, SelectOption, V2SelectConfig, V2SelectProps, - + // V2Date 타입 V2DateType, V2DateConfig, @@ -118,8 +118,7 @@ export type { HierarchyNode, V2HierarchyConfig, V2HierarchyProps, - + // 통합 Props V2ComponentProps, } from "@/types/v2-components"; - diff --git a/frontend/components/v2/registerV2Components.ts b/frontend/components/v2/registerV2Components.ts index d4db6d53..89018262 100644 --- a/frontend/components/v2/registerV2Components.ts +++ b/frontend/components/v2/registerV2Components.ts @@ -2,7 +2,7 @@ /** * V2 컴포넌트 레지스트리 등록 - * + * * 9개의 V2 컴포넌트를 ComponentRegistry에 등록합니다. */ diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 3afee0f4..8a883540 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -80,7 +80,6 @@ import "./section-card/SectionCardRenderer"; import "./tabs/tabs-component"; import "./location-swap-selector/LocationSwapSelectorRenderer"; import "./rack-structure/RackStructureRenderer"; -import "./v2-repeater/V2RepeaterRenderer"; import "./pivot-grid/PivotGridRenderer"; import "./aggregation-widget/AggregationWidgetRenderer"; import "./repeat-container/RepeatContainerRenderer"; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index aed8af40..d0f9d5aa 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1062,7 +1062,6 @@ export const TableListComponent: React.FC = ({ // 부모 컴포넌트에 초기 컬럼 순서 전달 if (onSelectedRowsChange && parsedOrder.length > 0) { - // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) const initialData = data.map((row: any) => { const reordered: any = {}; @@ -2630,21 +2629,26 @@ export const TableListComponent: React.FC = ({ // 카테고리 매핑된 값 처리 if (value !== null && value !== undefined) { const valueStr = String(value); - + // 디버그 로그 (카테고리 값인 경우만) if (valueStr.startsWith("CATEGORY_")) { console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", { columnName: col.columnName, value: valueStr, hasMappings: !!categoryMappings[col.columnName], - mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [], + mappingsKeys: categoryMappings[col.columnName] + ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) + : [], }); } - + if (categoryMappings[col.columnName]) { // 쉼표로 구분된 중복 값 처리 if (valueStr.includes(",")) { - const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v); + const values = valueStr + .split(",") + .map((v) => v.trim()) + .filter((v) => v); const labels = values.map((v) => { const mapping = categoryMappings[col.columnName][v]; return mapping ? mapping.label : v; @@ -2657,7 +2661,7 @@ export const TableListComponent: React.FC = ({ return mapping.label; } } - + return value; } @@ -5778,7 +5782,9 @@ export const TableListComponent: React.FC = ({ }} className={cn( "hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors", - (headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && "text-primary bg-primary/10", + (headerFilters[column.columnName]?.size > 0 || + headerLikeFilters[column.columnName]) && + "text-primary bg-primary/10", )} title="필터" > @@ -5795,7 +5801,8 @@ export const TableListComponent: React.FC = ({ 필터: {columnLabels[column.columnName] || column.displayName} - {(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && ( + {(headerFilters[column.columnName]?.size > 0 || + headerLikeFilters[column.columnName]) && (
{/* LIKE 검색 입력 필드 */}
- + = ({ [column.columnName]: e.target.value, })); }} - className="border-input bg-background placeholder:text-muted-foreground h-7 w-full rounded-md border pl-7 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary" + className="border-input bg-background placeholder:text-muted-foreground focus:ring-primary h-7 w-full rounded-md border pr-2 pl-7 text-xs focus:ring-1 focus:outline-none" onClick={(e) => e.stopPropagation()} />
{/* 구분선 */} -
또는 값 선택:
+
+ 또는 값 선택: +
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { const isSelected = headerFilters[column.columnName]?.has(val); diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index 65da6238..d1ee0d66 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -1,17 +1,17 @@ /** * V2 레이아웃 변환 유틸리티 - * + * * 기존 LayoutData ↔ V2 LayoutData 변환 */ -import { - ComponentV2, - LayoutV2, - getComponentUrl, +import { + ComponentV2, + LayoutV2, + getComponentUrl, getComponentTypeFromUrl, getDefaultsByUrl, mergeComponentConfig, - extractCustomConfig + extractCustomConfig, } from "@/lib/schemas/componentConfig"; // 기존 ComponentData 타입 (간략화) @@ -45,7 +45,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | const componentType = getComponentTypeFromUrl(comp.url); const defaults = getDefaultsByUrl(comp.url); const mergedConfig = mergeComponentConfig(defaults, comp.overrides); - + // 🆕 overrides에서 상위 레벨 속성들 추출 const overrides = comp.overrides || {}; @@ -60,7 +60,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | // 🆕 상위 레벨 속성 복원 (테이블/컬럼 연결 정보) tableName: overrides.tableName, columnName: overrides.columnName, - label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열 + label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열 required: overrides.required, readonly: overrides.readonly, codeCategory: overrides.codeCategory, @@ -101,10 +101,10 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { // 컴포넌트 타입 결정 const componentType = comp.componentType || comp.widgetType || comp.type || "unknown"; const url = getComponentUrl(componentType); - + // 기본값 가져오기 const defaults = getDefaultsByUrl(url); - + // 🆕 컴포넌트 상위 레벨 속성들도 포함 (tableName, columnName 등) const topLevelProps: Record = {}; if (comp.tableName) topLevelProps.tableName = comp.tableName; @@ -115,11 +115,11 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory; if (comp.inputType) topLevelProps.inputType = comp.inputType; if (comp.webType) topLevelProps.webType = comp.webType; - + // 현재 설정에서 차이값만 추출 const fullConfig = comp.componentConfig || {}; const configOverrides = extractCustomConfig(fullConfig, defaults); - + // 상위 레벨 속성과 componentConfig 병합 const overrides = { ...topLevelProps, ...configOverrides }; @@ -144,22 +144,12 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { // V2 레이아웃 유효성 검사 // ============================================ export function isValidV2Layout(data: any): data is LayoutV2 { - return ( - data && - typeof data === "object" && - data.version === "2.0" && - Array.isArray(data.components) - ); + return data && typeof data === "object" && data.version === "2.0" && Array.isArray(data.components); } // ============================================ // 기존 레이아웃인지 확인 // ============================================ export function isLegacyLayout(data: any): boolean { - return ( - data && - typeof data === "object" && - Array.isArray(data.components) && - data.version !== "2.0" - ); + return data && typeof data === "object" && Array.isArray(data.components) && data.version !== "2.0"; } diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index febacfb4..d09ac9e9 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -1,6 +1,6 @@ /** * V2Repeater 컴포넌트 타입 정의 - * + * * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 (simple-repeater-table) * - modal: 소스 테이블에서 검색/선택 후 복사 (modal-repeater-table) @@ -17,24 +17,24 @@ export type ModalSize = "sm" | "md" | "lg" | "xl" | "full"; export type ColumnWidthOption = "auto" | "60px" | "80px" | "100px" | "120px" | "150px" | "200px" | "250px" | "300px"; // 자동 입력 타입 -export type AutoFillType = - | "none" // 자동 입력 없음 - | "currentDate" // 현재 날짜 - | "currentDateTime"// 현재 날짜+시간 - | "sequence" // 순번 (1, 2, 3...) - | "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택) - | "fromMainForm" // 메인 폼에서 값 복사 - | "fixed"; // 고정값 +export type AutoFillType = + | "none" // 자동 입력 없음 + | "currentDate" // 현재 날짜 + | "currentDateTime" // 현재 날짜+시간 + | "sequence" // 순번 (1, 2, 3...) + | "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택) + | "fromMainForm" // 메인 폼에서 값 복사 + | "fixed"; // 고정값 // 자동 입력 설정 export interface AutoFillConfig { type: AutoFillType; // fromMainForm 타입용 - sourceField?: string; // 메인 폼에서 복사할 필드명 + sourceField?: string; // 메인 폼에서 복사할 필드명 // fixed 타입용 fixedValue?: string | number | boolean; // numbering 타입용 - 기존 채번 규칙 ID를 참조 - numberingRuleId?: string; // 채번 규칙 ID (numbering_rules 테이블) + numberingRuleId?: string; // 채번 규칙 ID (numbering_rules 테이블) selectedMenuObjid?: number; // 🆕 채번 규칙 선택을 위한 대상 메뉴 OBJID } @@ -45,7 +45,7 @@ export interface RepeaterColumnConfig { width: ColumnWidthOption; visible: boolean; editable?: boolean; // 편집 가능 여부 (inline 모드) - hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨) + hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨) isJoinColumn?: boolean; sourceTable?: string; // 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시) @@ -77,11 +77,11 @@ export interface RepeaterModalConfig { size: ModalSize; title?: string; // 모달 제목 buttonText?: string; // 검색 버튼 텍스트 - + // 소스 테이블 표시 설정 (modal 모드) sourceDisplayColumns?: ModalDisplayColumn[]; // 모달에 표시할 소스 테이블 컬럼 (라벨 포함) searchFields?: string[]; // 검색에 사용할 필드 - + // 화면 기반 모달 (옵션) screenId?: number; titleTemplate?: { @@ -106,13 +106,13 @@ export interface RepeaterFeatureOptions { export interface RepeaterDataSource { // inline 모드: 현재 테이블 설정은 필요 없음 (컬럼만 선택) tableName?: string; // 데이터 테이블명 (레거시 호환) - + // modal 모드: 소스 테이블 설정 sourceTable?: string; // 검색할 테이블 (엔티티 참조 테이블) foreignKey?: string; // 현재 테이블의 FK 컬럼 (part_objid 등) referenceKey?: string; // 소스 테이블의 PK 컬럼 (id 등) displayColumn?: string; // 표시할 컬럼 (item_name 등) - + // 추가 필터 filter?: { column: string;