diff --git a/PLAN.MD b/PLAN.MD index 507695c6..271d0af1 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,4 +1,72 @@ -# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) +# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정) + +## 개요 +화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다. + +## 핵심 기능 + +### 1. 단일 화면 복제 +- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택 +- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가) +- [x] 연결된 모달 화면 함께 복제 +- [x] 대상 그룹 선택 가능 +- [x] 복제 후 목록 자동 새로고침 + +### 2. 그룹(폴더) 전체 복제 +- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제 +- [x] 정렬 순서(display_order) 유지 +- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시 +- [x] 정렬 순서 입력 필드 추가 +- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만 +- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto) + +### 3. 고급 옵션: 이름 일괄 변경 +- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace) +- [x] 미리보기 기능 + +### 4. 삭제 기능 +- [x] 단일 화면 삭제 (휴지통으로 이동) +- [x] 그룹 삭제 (화면 함께 삭제 옵션) +- [x] 삭제 시 로딩 프로그레스 바 표시 + +### 5. 화면 수정 기능 +- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경 +- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정 + +### 6. 테이블 설정 기능 (TableSettingModal) +- [x] 화면 설정 모달에 "테이블 설정" 탭 추가 +- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화 + - 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화 + - 코드→다른 타입: codeCategory, codeValue 초기화 +- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동) +- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시) + +### 7. 회사 코드 지원 (최고 관리자) +- [x] 대상 회사 선택 가능 +- [x] 상위 그룹 선택 시 자동 회사 코드 설정 + +## 관련 파일 +- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 +- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 +- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달 +- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함) +- `frontend/lib/api/screen.ts` - 화면 API +- `frontend/lib/api/screenGroup.ts` - 그룹 API +- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API + +## 진행 상태 +- [완료] 단일 화면 복제 + 새로고침 +- [완료] 그룹 전체 복제 (재귀적) +- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace) +- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스 +- [완료] 화면 수정 (이름/그룹/역할/순서) +- [완료] 테이블 설정 탭 추가 +- [완료] 입력 타입 변경 시 관련 필드 초기화 +- [완료] 그룹 복제 모달 스크롤 문제 수정 + +--- + +# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) ## 개요 현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index ab9bbc46..4a541456 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,7 +30,6 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 - deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -50,9 +49,6 @@ export class EntityJoinController { // search가 문자열인 경우 JSON 파싱 searchConditions = typeof search === "string" ? JSON.parse(search) : search; - - // 🔍 디버그: 파싱된 검색 조건 로깅 - logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2)); } catch (error) { logger.warn("검색 조건 파싱 오류:", error); searchConditions = {}; @@ -155,24 +151,6 @@ export class EntityJoinController { } } - // 🆕 중복 제거 설정 처리 - let parsedDeduplication: { - enabled: boolean; - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - } | undefined = undefined; - if (deduplication) { - try { - parsedDeduplication = - typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; - logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); - } catch (error) { - logger.warn("중복 제거 설정 파싱 오류:", error); - parsedDeduplication = undefined; - } - } - const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -190,26 +168,13 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 - deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); - // 🆕 중복 제거 처리 (결과 데이터에 적용) - let finalData = result; - if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { - logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); - const originalCount = result.data.length; - finalData = { - ...result, - data: this.deduplicateData(result.data, parsedDeduplication), - }; - logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); - } - res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: finalData, + data: result, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -584,98 +549,6 @@ export class EntityJoinController { }); } } - - /** - * 중복 데이터 제거 (메모리 내 처리) - */ - private deduplicateData( - data: any[], - config: { - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - } - ): any[] { - if (!data || data.length === 0) return data; - - // 그룹별로 데이터 분류 - const groups: Record = {}; - - for (const row of data) { - const groupKey = row[config.groupByColumn]; - if (groupKey === undefined || groupKey === null) continue; - - if (!groups[groupKey]) { - groups[groupKey] = []; - } - groups[groupKey].push(row); - } - - // 각 그룹에서 하나의 행만 선택 - const result: any[] = []; - - for (const [groupKey, rows] of Object.entries(groups)) { - if (rows.length === 0) continue; - - let selectedRow: any; - - switch (config.keepStrategy) { - case "latest": - // 정렬 컬럼 기준 최신 (가장 큰 값) - if (config.sortColumn) { - rows.sort((a, b) => { - const aVal = a[config.sortColumn!]; - const bVal = b[config.sortColumn!]; - if (aVal === bVal) return 0; - if (aVal > bVal) return -1; - return 1; - }); - } - selectedRow = rows[0]; - break; - - case "earliest": - // 정렬 컬럼 기준 최초 (가장 작은 값) - if (config.sortColumn) { - rows.sort((a, b) => { - const aVal = a[config.sortColumn!]; - const bVal = b[config.sortColumn!]; - if (aVal === bVal) return 0; - if (aVal < bVal) return -1; - return 1; - }); - } - selectedRow = rows[0]; - break; - - case "base_price": - // base_price가 true인 행 선택 - selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; - break; - - case "current_date": - // 오늘 날짜 기준 유효 기간 내 행 선택 - const today = new Date().toISOString().split("T")[0]; - selectedRow = rows.find((r) => { - const startDate = r.start_date; - const endDate = r.end_date; - if (!startDate) return true; - if (startDate <= today && (!endDate || endDate >= today)) return true; - return false; - }) || rows[0]; - break; - - default: - selectedRow = rows[0]; - } - - if (selectedRow) { - result.push(selectedRow); - } - } - - return result; - } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 2d7bc0e1..b89ef902 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,23 +1,24 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { MultiLangService } from "../services/multilangService"; -import { AuthenticatedRequest } from "../types/auth"; +import { + syncScreenGroupsToMenu, + syncMenuToScreenGroups, + getSyncStatus, + syncAllCompanies, +} from "../services/menuScreenSyncService"; // pool 인스턴스 가져오기 const pool = getPool(); -// 다국어 서비스 인스턴스 -const multiLangService = new MultiLangService(); - // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ // 화면 그룹 목록 조회 -export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { +export const getScreenGroups = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { page = 1, size = 20, searchTerm } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); @@ -89,10 +90,10 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) }; // 화면 그룹 상세 조회 -export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const getScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = ` SELECT sg.*, @@ -135,10 +136,10 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) = }; // 화면 그룹 생성 -export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const createScreenGroup = async (req: Request, res: Response) => { try { - const userCompanyCode = req.user!.companyCode; - const userId = req.user!.userId; + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; if (!group_name || !group_code) { @@ -196,47 +197,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); - // 다국어 카테고리 자동 생성 (그룹 경로 기반) - try { - // 그룹 경로 조회 (상위 그룹 → 현재 그룹) - const groupPathResult = await pool.query( - `WITH RECURSIVE group_path AS ( - SELECT id, parent_group_id, group_name, group_level, 1 as depth - FROM screen_groups - WHERE id = $1 - UNION ALL - SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 - FROM screen_groups g - INNER JOIN group_path gp ON g.id = gp.parent_group_id - WHERE g.parent_group_id IS NOT NULL - ) - SELECT group_name FROM group_path - ORDER BY depth DESC`, - [newGroupId] - ); - - const groupPath = groupPathResult.rows.map((r: any) => r.group_name); - - // 회사 이름 조회 - let companyName = "공통"; - if (finalCompanyCode !== "*") { - const companyResult = await pool.query( - `SELECT company_name FROM company_mng WHERE company_code = $1`, - [finalCompanyCode] - ); - if (companyResult.rows.length > 0) { - companyName = companyResult.rows[0].company_name; - } - } - - // 다국어 카테고리 생성 - await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); - logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); - } catch (multilangError: any) { - // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 - logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); - } - logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); @@ -250,10 +210,10 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 수정 -export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const updateScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const userCompanyCode = req.user!.companyCode; + const userCompanyCode = (req.user as any).companyCode; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 @@ -339,11 +299,36 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const deleteScreenGroup = async (req: Request, res: Response) => { + const client = await pool.connect(); try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; + await client.query('BEGIN'); + + // 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상) + const childGroupsResult = await client.query(` + WITH RECURSIVE child_groups AS ( + SELECT id FROM screen_groups WHERE id = $1 + UNION ALL + SELECT sg.id FROM screen_groups sg + JOIN child_groups cg ON sg.parent_group_id = cg.id + ) + SELECT id FROM child_groups + `, [id]); + const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id); + + // 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리 + if (groupIdsToDelete.length > 0) { + await client.query(` + UPDATE menu_info + SET screen_group_id = NULL + WHERE screen_group_id = ANY($1::int[]) + `, [groupIdsToDelete]); + } + + // 3. screen_groups 삭제 let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; @@ -354,18 +339,24 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response query += " RETURNING id"; - const result = await pool.query(query, params); + const result = await client.query(query, params); if (result.rows.length === 0) { + await client.query('ROLLBACK'); return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } - logger.info("화면 그룹 삭제", { companyCode, groupId: id }); + await client.query('COMMIT'); + + logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length }); res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); } catch (error: any) { + await client.query('ROLLBACK'); logger.error("화면 그룹 삭제 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); + } finally { + client.release(); } }; @@ -375,10 +366,10 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response // ============================================================ // 그룹에 화면 추가 -export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { +export const addScreenToGroup = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; const { group_id, screen_id, screen_role, display_order, is_default } = req.body; if (!group_id || !screen_id) { @@ -415,10 +406,10 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) }; // 그룹에서 화면 제거 -export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => { +export const removeScreenFromGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; @@ -446,10 +437,10 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp }; // 그룹 내 화면 순서/역할 수정 -export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => { +export const updateScreenInGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_role, display_order, is_default } = req.body; let query = ` @@ -485,9 +476,9 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon // ============================================================ // 화면 필드 조인 목록 조회 -export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => { +export const getFieldJoins = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_id } = req.query; let query = ` @@ -526,10 +517,10 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => }; // 화면 필드 조인 생성 -export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const createFieldJoin = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; const { screen_id, layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -567,10 +558,10 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) }; // 화면 필드 조인 수정 -export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const updateFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -612,10 +603,10 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) }; // 화면 필드 조인 삭제 -export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const deleteFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; @@ -646,9 +637,9 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) // ============================================================ // 데이터 흐름 목록 조회 -export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => { +export const getDataFlows = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { group_id, source_screen_id } = req.query; let query = ` @@ -696,10 +687,10 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => }; // 데이터 흐름 생성 -export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const createDataFlow = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -735,10 +726,10 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) = }; // 데이터 흐름 수정 -export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const updateDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -778,10 +769,10 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) = }; // 데이터 흐름 삭제 -export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const deleteDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; @@ -812,9 +803,9 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) = // ============================================================ // 화면-테이블 관계 목록 조회 -export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => { +export const getTableRelations = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_id, group_id } = req.query; let query = ` @@ -861,10 +852,10 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response }; // 화면-테이블 관계 생성 -export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const createTableRelation = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; if (!screen_id || !table_name) { @@ -894,10 +885,10 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon }; // 화면-테이블 관계 수정 -export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const updateTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` @@ -929,10 +920,10 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon }; // 화면-테이블 관계 삭제 -export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const deleteTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; @@ -2060,3 +2051,202 @@ export const getScreenSubTables = async (req: Request, res: Response) => { } }; + +// ============================================================ +// 메뉴-화면그룹 동기화 API +// ============================================================ + +/** + * 화면관리 → 메뉴 동기화 + * screen_groups를 menu_info로 동기화 + */ +export const syncScreenGroupsToMenuController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { targetCompanyCode } = req.body; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "동기화할 회사를 선택해주세요.", + }); + } + + logger.info("화면관리 → 메뉴 동기화 요청", { companyCode, userId }); + + const result = await syncScreenGroupsToMenu(companyCode, userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "동기화 중 오류가 발생했습니다.", + errors: result.errors, + }); + } + + res.json({ + success: true, + message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, + data: result, + }); + } catch (error: any) { + logger.error("화면관리 → 메뉴 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "동기화에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 메뉴 → 화면관리 동기화 + * menu_info를 screen_groups로 동기화 + */ +export const syncMenuToScreenGroupsController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { targetCompanyCode } = req.body; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "동기화할 회사를 선택해주세요.", + }); + } + + logger.info("메뉴 → 화면관리 동기화 요청", { companyCode, userId }); + + const result = await syncMenuToScreenGroups(companyCode, userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "동기화 중 오류가 발생했습니다.", + errors: result.errors, + }); + } + + res.json({ + success: true, + message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, + data: result, + }); + } catch (error: any) { + logger.error("메뉴 → 화면관리 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "동기화에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 동기화 상태 조회 + */ +export const getSyncStatusController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const { targetCompanyCode } = req.query; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode as string; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "조회할 회사를 선택해주세요.", + }); + } + + const status = await getSyncStatus(companyCode); + + res.json({ + success: true, + data: status, + }); + } catch (error: any) { + logger.error("동기화 상태 조회 실패:", error); + res.status(500).json({ + success: false, + message: "동기화 상태 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 전체 회사 동기화 + * 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만) + */ +export const syncAllCompaniesController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + + // 최고 관리자만 전체 동기화 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "전체 동기화는 최고 관리자만 수행할 수 있습니다.", + }); + } + + logger.info("전체 회사 동기화 요청", { userId }); + + const result = await syncAllCompanies(userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "전체 동기화 중 오류가 발생했습니다.", + }); + } + + // 결과 요약 + const totalCreated = result.results.reduce((sum, r) => sum + r.created, 0); + const totalLinked = result.results.reduce((sum, r) => sum + r.linked, 0); + + res.json({ + success: true, + message: `전체 동기화 완료: ${result.totalCompanies}개 회사 중 ${result.successCount}개 성공`, + data: { + totalCompanies: result.totalCompanies, + successCount: result.successCount, + failedCount: result.failedCount, + totalCreated, + totalLinked, + details: result.results, + }, + }); + } catch (error: any) { + logger.error("전체 회사 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "전체 동기화에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index e8c5a1bb..401fe9ce 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -804,12 +804,6 @@ export async function getTableData( } } - // 🆕 최종 검색 조건 로그 - logger.info( - `🔍 최종 검색 조건 (enhancedSearch):`, - JSON.stringify(enhancedSearch) - ); - // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), @@ -893,10 +887,7 @@ export async function addTableData( const companyCode = req.user?.companyCode; if (companyCode && !data.company_code) { // 테이블에 company_code 컬럼이 있는지 확인 - const hasCompanyCodeColumn = await tableManagementService.hasColumn( - tableName, - "company_code" - ); + const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { data.company_code = companyCode; logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); @@ -906,10 +897,7 @@ export async function addTableData( // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) const userId = req.user?.userId; if (userId && !data.writer) { - const hasWriterColumn = await tableManagementService.hasColumn( - tableName, - "writer" - ); + const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); if (hasWriterColumn) { data.writer = userId; logger.info(`writer 자동 추가 - ${userId}`); @@ -917,25 +905,13 @@ export async function addTableData( } // 데이터 추가 - const result = await tableManagementService.addTableData(tableName, data); + await tableManagementService.addTableData(tableName, data); logger.info(`테이블 데이터 추가 완료: ${tableName}`); - // 무시된 컬럼이 있으면 경고 정보 포함 - const response: ApiResponse<{ - skippedColumns?: string[]; - savedColumns?: string[]; - }> = { + const response: ApiResponse = { success: true, - message: - result.skippedColumns.length > 0 - ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` - : "테이블 데이터를 성공적으로 추가했습니다.", - data: { - skippedColumns: - result.skippedColumns.length > 0 ? result.skippedColumns : undefined, - savedColumns: result.savedColumns, - }, + message: "테이블 데이터를 성공적으로 추가했습니다.", }; res.status(201).json(response); @@ -1663,10 +1639,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1679,10 +1655,7 @@ export async function getCategoryColumnsByMenu( const { menuObjid } = req.params; const companyCode = req.user?.companyCode; - logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { - menuObjid, - companyCode, - }); + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); if (!menuObjid) { res.status(400).json({ @@ -1708,11 +1681,8 @@ export async function getCategoryColumnsByMenu( if (mappingTableExists) { // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 - logger.info( - "🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", - { menuObjid, companyCode } - ); - + logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); + // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( @@ -1734,21 +1704,17 @@ export async function getCategoryColumnsByMenu( ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; - - const ancestorMenuResult = await pool.query(ancestorMenuQuery, [ - parseInt(menuObjid), - ]); - const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [ - parseInt(menuObjid), - ]; + + const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); + const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; - - logger.info("✅ 상위 메뉴 계층 조회 완료", { - ancestorMenuObjids, + + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, ancestorMenuNames, - hierarchyDepth: ancestorMenuObjids.length, + hierarchyDepth: ancestorMenuObjids.length }); - + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT @@ -1778,31 +1744,20 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - - columnsResult = await pool.query(columnsQuery, [ - companyCode, - ancestorMenuObjids, - ]); - logger.info( - "✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", - { - rowCount: columnsResult.rows.length, - columns: columnsResult.rows.map( - (r: any) => `${r.tableName}.${r.columnName}` - ), - } - ); + + columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); + logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) + }); } else { // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { - menuObjid, - companyCode, - }); - + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + // 형제 메뉴 조회 const { getSiblingMenuObjids } = await import("../services/menuService"); const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - + // 형제 메뉴들이 사용하는 테이블 조회 const tablesQuery = ` SELECT DISTINCT sd.table_name @@ -1812,17 +1767,11 @@ export async function getCategoryColumnsByMenu( AND sma.company_code = $2 AND sd.table_name IS NOT NULL `; - - const tablesResult = await pool.query(tablesQuery, [ - siblingObjids, - companyCode, - ]); + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { - tableNames, - count: tableNames.length, - }); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); if (tableNames.length === 0) { res.json({ @@ -1832,7 +1781,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1857,15 +1806,13 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ttc.column_name `; - + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); - logger.info("✅ 레거시 방식 조회 완료", { - rowCount: columnsResult.rows.length, - }); + logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length }); } - - logger.info("✅ 카테고리 컬럼 조회 완료", { - columnCount: columnsResult.rows.length, + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length }); res.json({ @@ -1890,9 +1837,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1962,29 +1909,23 @@ export async function multiTableSave( } let mainResult: any; - + if (isUpdate && pkValue) { // UPDATE const updateColumns = Object.keys(mainData) - .filter((col) => col !== pkColumn) + .filter(col => col !== pkColumn) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainData) - .filter((col) => col !== pkColumn) - .map((col) => mainData[col]); - + .filter(col => col !== pkColumn) + .map(col => mainData[col]); + // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query( - ` + const hasUpdatedAt = await client.query(` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, - [mainTableName] - ); - const updatedAtClause = - hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 - ? ", updated_at = NOW()" - : ""; + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; const updateQuery = ` UPDATE "${mainTableName}" @@ -1993,43 +1934,29 @@ export async function multiTableSave( ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} RETURNING * `; - - const updateParams = - companyCode !== "*" - ? [...updateValues, pkValue, companyCode] - : [...updateValues, pkValue]; - - logger.info("메인 테이블 UPDATE:", { - query: updateQuery, - paramsCount: updateParams.length, - }); + + const updateParams = companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); mainResult = await client.query(updateQuery, updateParams); } else { // INSERT - const columns = Object.keys(mainData) - .map((col) => `"${col}"`) - .join(", "); - const placeholders = Object.keys(mainData) - .map((_, idx) => `$${idx + 1}`) - .join(", "); + const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); + const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); const values = Object.values(mainData); // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query( - ` + const hasUpdatedAt = await client.query(` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, - [mainTableName] - ); - const updatedAtClause = - hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 - ? ", updated_at = NOW()" - : ""; + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; const updateSetClause = Object.keys(mainData) - .filter((col) => col !== pkColumn) - .map((col) => `"${col}" = EXCLUDED."${col}"`) + .filter(col => col !== pkColumn) + .map(col => `"${col}" = EXCLUDED."${col}"`) .join(", "); const insertQuery = ` @@ -2040,10 +1967,7 @@ export async function multiTableSave( RETURNING * `; - logger.info("메인 테이블 INSERT/UPSERT:", { - query: insertQuery, - paramsCount: values.length, - }); + logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); mainResult = await client.query(insertQuery, values); } @@ -2062,15 +1986,12 @@ export async function multiTableSave( const { tableName, linkColumn, items, options } = subTableConfig; // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 - const hasSaveMainAsFirst = - options?.saveMainAsFirst && - options?.mainFieldMappings && - options.mainFieldMappings.length > 0; - + const hasSaveMainAsFirst = options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { - logger.info( - `서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})` - ); + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); continue; } @@ -2083,20 +2004,15 @@ export async function multiTableSave( // 기존 데이터 삭제 옵션 if (options?.deleteExistingBefore && linkColumn?.subColumn) { - const deleteQuery = - options?.deleteOnlySubItems && options?.mainMarkerColumn - ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` - : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; + const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; + + const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; - const deleteParams = - options?.deleteOnlySubItems && options?.mainMarkerColumn - ? [savedPkValue, options.subMarkerValue ?? false] - : [savedPkValue]; - - logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { - deleteQuery, - deleteParams, - }); + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); await client.query(deleteQuery, deleteParams); } @@ -2109,12 +2025,7 @@ export async function multiTableSave( linkColumn, mainDataKeys: Object.keys(mainData), }); - if ( - options?.saveMainAsFirst && - options?.mainFieldMappings && - options.mainFieldMappings.length > 0 && - linkColumn?.subColumn - ) { + if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; @@ -2128,8 +2039,7 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = - options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; } // company_code 추가 @@ -2152,30 +2062,20 @@ export async function multiTableSave( if (companyCode !== "*") { checkParams.push(companyCode); } - + const existingResult = await client.query(checkQuery, checkParams); - + if (existingResult.rows.length > 0) { // UPDATE const updateColumns = Object.keys(mainSubItem) - .filter( - (col) => - col !== linkColumn.subColumn && - col !== options.mainMarkerColumn && - col !== "company_code" - ) + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); - + const updateValues = Object.keys(mainSubItem) - .filter( - (col) => - col !== linkColumn.subColumn && - col !== options.mainMarkerColumn && - col !== "company_code" - ) - .map((col) => mainSubItem[col]); - + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .map(col => mainSubItem[col]); + if (updateColumns) { const updateQuery = ` UPDATE "${tableName}" @@ -2194,26 +2094,14 @@ export async function multiTableSave( } const updateResult = await client.query(updateQuery, updateParams); - subTableResults.push({ - tableName, - type: "main", - data: updateResult.rows[0], - }); + subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); } else { - subTableResults.push({ - tableName, - type: "main", - data: existingResult.rows[0], - }); + subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); } } else { // INSERT - const mainSubColumns = Object.keys(mainSubItem) - .map((col) => `"${col}"`) - .join(", "); - const mainSubPlaceholders = Object.keys(mainSubItem) - .map((_, idx) => `$${idx + 1}`) - .join(", "); + const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); const mainSubValues = Object.values(mainSubItem); const insertQuery = ` @@ -2223,11 +2111,7 @@ export async function multiTableSave( `; const insertResult = await client.query(insertQuery, mainSubValues); - subTableResults.push({ - tableName, - type: "main", - data: insertResult.rows[0], - }); + subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); } } @@ -2243,12 +2127,8 @@ export async function multiTableSave( item.company_code = companyCode; } - const subColumns = Object.keys(item) - .map((col) => `"${col}"`) - .join(", "); - const subPlaceholders = Object.keys(item) - .map((_, idx) => `$${idx + 1}`) - .join(", "); + const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); + const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); const subValues = Object.values(item); const subInsertQuery = ` @@ -2257,16 +2137,9 @@ export async function multiTableSave( RETURNING * `; - logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { - subInsertQuery, - subValuesCount: subValues.length, - }); + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); const subResult = await client.query(subInsertQuery, subValues); - subTableResults.push({ - tableName, - type: "sub", - data: subResult.rows[0], - }); + subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); } logger.info(`서브 테이블 ${tableName} 저장 완료`); @@ -2307,11 +2180,8 @@ export async function multiTableSave( } /** - * 두 테이블 간의 엔티티 관계 자동 감지 - * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy - * - * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 - * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + * 두 테이블 간 엔티티 관계 조회 + * column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회 */ export async function getTableEntityRelations( req: AuthenticatedRequest, @@ -2320,54 +2190,93 @@ export async function getTableEntityRelations( try { const { leftTable, rightTable } = req.query; - logger.info( - `=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===` - ); - if (!leftTable || !rightTable) { - const response: ApiResponse = { + res.status(400).json({ success: false, message: "leftTable과 rightTable 파라미터가 필요합니다.", - error: { - code: "MISSING_PARAMETERS", - details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", - }, - }; - res.status(400).json(response); + }); return; } - const tableManagementService = new TableManagementService(); - const relations = await tableManagementService.detectTableEntityRelations( - String(leftTable), - String(rightTable) - ); + logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable }); - logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + // 두 테이블의 컬럼 라벨 정보 조회 + const columnLabelsQuery = ` + SELECT + table_name, + column_name, + column_label, + web_type, + detail_settings + FROM column_labels + WHERE table_name IN ($1, $2) + AND web_type IN ('entity', 'category') + `; - const response: ApiResponse = { + const result = await query(columnLabelsQuery, [leftTable, rightTable]); + + // 관계 분석 + const relations: Array<{ + fromTable: string; + fromColumn: string; + toTable: string; + toColumn: string; + relationType: string; + }> = []; + + for (const row of result) { + try { + const detailSettings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings) + : row.detail_settings; + + if (detailSettings && detailSettings.referenceTable) { + const refTable = detailSettings.referenceTable; + const refColumn = detailSettings.referenceColumn || "id"; + + // leftTable과 rightTable 간의 관계인지 확인 + if ( + (row.table_name === leftTable && refTable === rightTable) || + (row.table_name === rightTable && refTable === leftTable) + ) { + relations.push({ + fromTable: row.table_name, + fromColumn: row.column_name, + toTable: refTable, + toColumn: refColumn, + relationType: row.web_type, + }); + } + } + } catch (parseError) { + logger.warn("detail_settings 파싱 오류:", { + table: row.table_name, + column: row.column_name, + error: parseError + }); + } + } + + logger.info("테이블 엔티티 관계 조회 완료", { + leftTable, + rightTable, + relationsCount: relations.length + }); + + res.json({ success: true, - message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, data: { - leftTable: String(leftTable), - rightTable: String(rightTable), + leftTable, + rightTable, relations, }, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); - - const response: ApiResponse = { + }); + } catch (error: any) { + logger.error("테이블 엔티티 관계 조회 실패:", error); + res.status(500).json({ success: false, - message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", - error: { - code: "ENTITY_RELATIONS_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }; - - res.status(500).json(response); + message: "테이블 엔티티 관계 조회에 실패했습니다.", + error: error.message, + }); } } diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index d4980fe8..614e6d61 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -31,6 +31,11 @@ import { getMultipleScreenLayoutSummary, // 화면 서브 테이블 관계 getScreenSubTables, + // 메뉴-화면그룹 동기화 + syncScreenGroupsToMenuController, + syncMenuToScreenGroupsController, + getSyncStatusController, + syncAllCompaniesController, } from "../controllers/screenGroupController"; const router = Router(); @@ -89,6 +94,18 @@ router.post("/layout-summary/batch", getMultipleScreenLayoutSummary); // ============================================================ router.post("/sub-tables/batch", getScreenSubTables); +// ============================================================ +// 메뉴-화면그룹 동기화 +// ============================================================ +// 동기화 상태 조회 +router.get("/sync/status", getSyncStatusController); +// 화면관리 → 메뉴 동기화 +router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController); +// 메뉴 → 화면관리 동기화 +router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); +// 전체 회사 동기화 (최고 관리자만) +router.post("/sync/all", syncAllCompaniesController); + export default router; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 75c57673..8c6e63f0 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -254,7 +254,10 @@ class DataService { key !== "limit" && key !== "offset" && key !== "orderBy" && - key !== "userLang" + key !== "userLang" && + key !== "page" && + key !== "pageSize" && + key !== "size" ) { // 컬럼명 검증 (SQL 인젝션 방지) if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts new file mode 100644 index 00000000..13c77ed6 --- /dev/null +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -0,0 +1,939 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +/** + * 메뉴-화면그룹 동기화 서비스 + * + * 양방향 동기화: + * 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화 + * 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화 + */ + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface SyncResult { + success: boolean; + created: number; + linked: number; + skipped: number; + errors: string[]; + details: SyncDetail[]; +} + +interface SyncDetail { + action: 'created' | 'linked' | 'skipped' | 'error'; + sourceName: string; + sourceId: number | string; + targetId?: number | string; + reason?: string; +} + +// ============================================================ +// 화면관리 → 메뉴 동기화 +// ============================================================ + +/** + * screen_groups를 menu_info로 동기화 + * + * 로직: + * 1. 해당 회사의 screen_groups 조회 (폴더 구조) + * 2. 이미 menu_objid가 연결된 것은 제외 + * 3. 이름으로 기존 menu_info 매칭 시도 + * - 매칭되면: 양쪽에 연결 ID 업데이트 + * - 매칭 안되면: menu_info에 새로 생성 + * 4. 계층 구조(parent) 유지 + */ +export async function syncScreenGroupsToMenu( + companyCode: string, + userId: string +): Promise { + const result: SyncResult = { + success: true, + created: 0, + linked: 0, + skipped: 0, + errors: [], + details: [], + }; + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId }); + + // 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것) + const screenGroupsQuery = ` + SELECT + sg.id, + sg.group_name, + sg.group_code, + sg.parent_group_id, + sg.group_level, + sg.display_order, + sg.description, + sg.icon, + sg.menu_objid, + -- 부모 그룹의 menu_objid도 조회 (계층 연결용) + parent.menu_objid as parent_menu_objid + FROM screen_groups sg + LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id + WHERE sg.company_code = $1 + ORDER BY sg.group_level ASC, sg.display_order ASC + `; + const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]); + + // 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1) + // 경로 기반 매칭을 위해 부모 이름도 조회 + const existingMenusQuery = ` + SELECT + m.objid, + m.menu_name_kor, + m.parent_obj_id, + m.screen_group_id, + p.menu_name_kor as parent_name + FROM menu_info m + LEFT JOIN menu_info p ON m.parent_obj_id = p.objid + WHERE m.company_code = $1 AND m.menu_type = 1 + `; + const existingMenusResult = await client.query(existingMenusQuery, [companyCode]); + + // 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만) + // 단순 이름 매칭도 유지 (하위 호환) + const menuByPath: Map = new Map(); + const menuByName: Map = new Map(); + existingMenusResult.rows.forEach((menu: any) => { + if (!menu.screen_group_id) { + const menuName = menu.menu_name_kor?.trim().toLowerCase() || ''; + const parentName = menu.parent_name?.trim().toLowerCase() || ''; + const pathKey = parentName ? `${parentName}>${menuName}` : menuName; + + menuByPath.set(pathKey, menu); + // 단순 이름 매핑은 첫 번째 것만 (중복 방지) + if (!menuByName.has(menuName)) { + menuByName.set(menuName, menu); + } + } + }); + + // 모든 메뉴의 objid 집합 (삭제 확인용) + const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid))); + + // 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴) + // 없으면 생성 + let userMenuRootObjid: number | null = null; + const rootMenuQuery = ` + SELECT objid FROM menu_info + WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0 + ORDER BY seq ASC + LIMIT 1 + `; + const rootMenuResult = await client.query(rootMenuQuery, [companyCode]); + + if (rootMenuResult.rows.length > 0) { + userMenuRootObjid = Number(rootMenuResult.rows[0].objid); + } else { + // 루트 메뉴가 없으면 생성 + const newObjid = Date.now(); + const createRootQuery = ` + INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) + VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'Y') + RETURNING objid + `; + const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); + userMenuRootObjid = Number(createRootResult.rows[0].objid); + logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid }); + } + + // 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해) + const groupToMenuMap: Map = new Map(); + + // screen_groups의 부모 이름 조회를 위한 매핑 + const groupIdToName: Map = new Map(); + screenGroupsResult.rows.forEach((g: any) => { + groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); + }); + + // 5. 각 screen_group 처리 + for (const group of screenGroupsResult.rows) { + const groupId = group.id; + const groupName = group.group_name?.trim(); + const groupNameLower = groupName?.toLowerCase() || ''; + + // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 + if (group.menu_objid) { + const menuExists = existingMenuObjids.has(Number(group.menu_objid)); + + if (menuExists) { + // 메뉴가 존재하면 스킵 + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: groupName, + sourceId: groupId, + targetId: group.menu_objid, + reason: '이미 메뉴와 연결됨', + }); + groupToMenuMap.set(groupId, Number(group.menu_objid)); + continue; + } else { + // 메뉴가 삭제되었으면 연결 해제하고 재생성 + logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid }); + await client.query( + `UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`, + [groupId] + ); + // 계속 진행하여 재생성 또는 재연결 + } + } + + // 부모 그룹 이름 조회 (경로 기반 매칭용) + const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : ''; + const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower; + + // 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) + let matchedMenu = menuByPath.get(pathKey); + if (!matchedMenu) { + // 경로 매칭 실패시 이름으로 시도 (하위 호환) + matchedMenu = menuByName.get(groupNameLower); + } + + if (matchedMenu) { + // 매칭된 메뉴와 연결 + const menuObjid = Number(matchedMenu.objid); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [menuObjid, groupId] + ); + + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [groupId, menuObjid] + ); + + groupToMenuMap.set(groupId, menuObjid); + result.linked++; + result.details.push({ + action: 'linked', + sourceName: groupName, + sourceId: groupId, + targetId: menuObjid, + }); + + // 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지) + menuByPath.delete(pathKey); + menuByName.delete(groupNameLower); + + } else { + // 새 메뉴 생성 + const newObjid = Date.now() + groupId; // 고유 ID 보장 + + // 부모 메뉴 objid 결정 + let parentMenuObjid = userMenuRootObjid; + if (group.parent_group_id && group.parent_menu_objid) { + parentMenuObjid = Number(group.parent_menu_objid); + } else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; + } + + // 같은 부모 아래에서 가장 높은 seq 조회 후 +1 + let nextSeq = 1; + const maxSeqQuery = ` + SELECT COALESCE(MAX(seq), 0) + 1 as next_seq + FROM menu_info + WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1 + `; + const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]); + if (maxSeqResult.rows.length > 0) { + nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1; + } + + // menu_info에 삽입 + const insertMenuQuery = ` + INSERT INTO menu_info ( + objid, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'Y', $8, $9) + RETURNING objid + `; + await client.query(insertMenuQuery, [ + newObjid, + parentMenuObjid, + groupName, + group.group_code || groupName, + nextSeq, + companyCode, + userId, + groupId, + group.description || null, + ]); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [newObjid, groupId] + ); + + groupToMenuMap.set(groupId, newObjid); + result.created++; + result.details.push({ + action: 'created', + sourceName: groupName, + sourceId: groupId, + targetId: newObjid, + }); + } + } + + await client.query('COMMIT'); + + logger.info("화면관리 → 메뉴 동기화 완료", { + companyCode, + created: result.created, + linked: result.linked, + skipped: result.skipped + }); + + return result; + + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message }); + result.success = false; + result.errors.push(error.message); + return result; + } finally { + client.release(); + } +} + + +// ============================================================ +// 메뉴 → 화면관리 동기화 +// ============================================================ + +/** + * menu_info를 screen_groups로 동기화 + * + * 로직: + * 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회 + * 2. 이미 screen_group_id가 연결된 것은 제외 + * 3. 이름으로 기존 screen_groups 매칭 시도 + * - 매칭되면: 양쪽에 연결 ID 업데이트 + * - 매칭 안되면: screen_groups에 새로 생성 (폴더로) + * 4. 계층 구조(parent) 유지 + */ +export async function syncMenuToScreenGroups( + companyCode: string, + userId: string +): Promise { + const result: SyncResult = { + success: true, + created: 0, + linked: 0, + skipped: 0, + errors: [], + details: [], + }; + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId }); + + // 0. 회사 이름 조회 (회사 폴더 찾기/생성용) + const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`; + const companyNameResult = await client.query(companyNameQuery, [companyCode]); + const companyName = companyNameResult.rows[0]?.company_name || companyCode; + + // 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1) + const menusQuery = ` + SELECT + m.objid, + m.menu_name_kor, + m.menu_name_eng, + m.parent_obj_id, + m.seq, + m.menu_url, + m.menu_desc, + m.screen_group_id, + -- 부모 메뉴의 screen_group_id도 조회 (계층 연결용) + parent.screen_group_id as parent_screen_group_id + FROM menu_info m + LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid + WHERE m.company_code = $1 AND m.menu_type = 1 + ORDER BY + CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END, + m.parent_obj_id, + m.seq + `; + const menusResult = await client.query(menusQuery, [companyCode]); + + // 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회) + const existingGroupsQuery = ` + SELECT + g.id, + g.group_name, + g.menu_objid, + g.parent_group_id, + p.group_name as parent_name + FROM screen_groups g + LEFT JOIN screen_groups p ON g.parent_group_id = p.id + WHERE g.company_code = $1 + `; + const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]); + + // 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만) + // 단순 이름 매칭도 유지 (하위 호환) + const groupByPath: Map = new Map(); + const groupByName: Map = new Map(); + existingGroupsResult.rows.forEach((group: any) => { + if (!group.menu_objid) { + const groupName = group.group_name?.trim().toLowerCase() || ''; + const parentName = group.parent_name?.trim().toLowerCase() || ''; + const pathKey = parentName ? `${parentName}>${groupName}` : groupName; + + groupByPath.set(pathKey, group); + // 단순 이름 매핑은 첫 번째 것만 (중복 방지) + if (!groupByName.has(groupName)) { + groupByName.set(groupName, group); + } + } + }); + + // 모든 그룹의 id 집합 (삭제 확인용) + const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id))); + + // 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더) + let companyFolderId: number | null = null; + const companyFolderQuery = ` + SELECT id FROM screen_groups + WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0 + ORDER BY id ASC + LIMIT 1 + `; + const companyFolderResult = await client.query(companyFolderQuery, [companyCode]); + + if (companyFolderResult.rows.length > 0) { + companyFolderId = companyFolderResult.rows[0].id; + logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName }); + } else { + // 회사 폴더가 없으면 생성 + // 루트 레벨에서 가장 높은 display_order 조회 후 +1 + let nextRootOrder = 1; + const maxRootOrderQuery = ` + SELECT COALESCE(MAX(display_order), 0) + 1 as next_order + FROM screen_groups + WHERE parent_group_id IS NULL + `; + const maxRootOrderResult = await client.query(maxRootOrderQuery); + if (maxRootOrderResult.rows.length > 0) { + nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1; + } + + const createFolderQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, parent_group_id, group_level, + display_order, company_code, writer, hierarchy_path + ) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/') + RETURNING id + `; + const createFolderResult = await client.query(createFolderQuery, [ + companyName, + companyCode.toLowerCase(), + nextRootOrder, + companyCode, + userId, + ]); + companyFolderId = createFolderResult.rows[0].id; + + // hierarchy_path 업데이트 + await client.query( + `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, + [`/${companyFolderId}/`, companyFolderId] + ); + + logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName }); + } + + // 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해) + const menuToGroupMap: Map = new Map(); + + // 부모 메뉴 중 이미 screen_group_id가 있는 것 등록 + menusResult.rows.forEach((menu: any) => { + if (menu.screen_group_id) { + menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id)); + } + }); + + // 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑 + let rootMenuObjid: number | null = null; + for (const menu of menusResult.rows) { + if (Number(menu.parent_obj_id) === 0) { + rootMenuObjid = Number(menu.objid); + // 루트 메뉴는 회사 폴더와 연결 + if (companyFolderId) { + menuToGroupMap.set(rootMenuObjid, companyFolderId); + } + break; + } + } + + // 5. 각 메뉴 처리 + for (const menu of menusResult.rows) { + const menuObjid = Number(menu.objid); + const menuName = menu.menu_name_kor?.trim(); + + // 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨) + if (Number(menu.parent_obj_id) === 0) { + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: menuName, + sourceId: menuObjid, + targetId: companyFolderId || undefined, + reason: '루트 메뉴 → 회사 폴더와 매핑됨', + }); + continue; + } + + // 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인 + if (menu.screen_group_id) { + const groupExists = existingGroupIds.has(Number(menu.screen_group_id)); + + if (groupExists) { + // 그룹이 존재하면 스킵 + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: menuName, + sourceId: menuObjid, + targetId: menu.screen_group_id, + reason: '이미 화면그룹과 연결됨', + }); + menuToGroupMap.set(menuObjid, Number(menu.screen_group_id)); + continue; + } else { + // 그룹이 삭제되었으면 연결 해제하고 재생성 + logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id }); + await client.query( + `UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`, + [menuObjid] + ); + // 계속 진행하여 재생성 또는 재연결 + } + } + + const menuNameLower = menuName?.toLowerCase() || ''; + + // 부모 메뉴 이름 조회 (경로 기반 매칭용) + const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id)); + const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || ''; + const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower; + + // 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) + let matchedGroup = groupByPath.get(pathKey); + if (!matchedGroup) { + // 경로 매칭 실패시 이름으로 시도 (하위 호환) + matchedGroup = groupByName.get(menuNameLower); + } + + if (matchedGroup) { + // 매칭된 그룹과 연결 + const groupId = Number(matchedGroup.id); + + try { + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [groupId, menuObjid] + ); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [menuObjid, groupId] + ); + + menuToGroupMap.set(menuObjid, groupId); + result.linked++; + result.details.push({ + action: 'linked', + sourceName: menuName, + sourceId: menuObjid, + targetId: groupId, + }); + + // 매칭된 그룹은 Map에서 제거 (중복 매칭 방지) + groupByPath.delete(pathKey); + groupByName.delete(menuNameLower); + } catch (linkError: any) { + logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack }); + throw linkError; + } + + } else { + // 새 screen_group 생성 + // 부모 그룹 ID 결정 + let parentGroupId: number | null = null; + let groupLevel = 1; // 기본값은 1 (회사 폴더 아래) + + // 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것) + if (menuToGroupMap.has(Number(menu.parent_obj_id))) { + parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!; + } + // 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용 + else if (Number(menu.parent_obj_id) === rootMenuObjid) { + parentGroupId = companyFolderId; + } + // 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용 + else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) { + parentGroupId = Number(menu.parent_screen_group_id); + } + + // 부모 그룹의 레벨 조회 + if (parentGroupId) { + const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`; + const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]); + if (parentLevelResult.rows.length > 0) { + groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1; + } + } + + // 같은 부모 아래에서 가장 높은 display_order 조회 후 +1 + let nextDisplayOrder = 1; + const maxOrderQuery = parentGroupId + ? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2` + : `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`; + const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode]; + const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams); + if (maxOrderResult.rows.length > 0) { + nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1; + } + + // group_code 생성 (영문명 또는 이름 기반) + const groupCode = (menu.menu_name_eng || menuName || 'group') + .replace(/\s+/g, '_') + .toLowerCase() + .substring(0, 50); + + // screen_groups에 삽입 + const insertGroupQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, parent_group_id, group_level, + display_order, company_code, writer, menu_objid, description + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + `; + + let newGroupId: number; + try { + logger.info("새 그룹 생성 시도", { + menuName, + menuObjid, + groupCode: groupCode + '_' + menuObjid, + parentGroupId, + groupLevel, + nextDisplayOrder, + companyCode, + }); + + const insertResult = await client.query(insertGroupQuery, [ + menuName, + groupCode + '_' + menuObjid, // 고유성 보장 + parentGroupId, + groupLevel, + nextDisplayOrder, + companyCode, + userId, + menuObjid, + menu.menu_desc || null, + ]); + + newGroupId = insertResult.rows[0].id; + } catch (insertError: any) { + logger.error("그룹 생성 중 에러", { + menuName, + menuObjid, + parentGroupId, + groupLevel, + error: insertError.message, + stack: insertError.stack, + code: insertError.code, + detail: insertError.detail, + }); + throw insertError; + } + + // hierarchy_path 업데이트 + let hierarchyPath = `/${newGroupId}/`; + if (parentGroupId) { + const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`; + const parentPathResult = await client.query(parentPathQuery, [parentGroupId]); + if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) { + hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/'); + } + } + await client.query( + `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, + [hierarchyPath, newGroupId] + ); + + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [newGroupId, menuObjid] + ); + + menuToGroupMap.set(menuObjid, newGroupId); + result.created++; + result.details.push({ + action: 'created', + sourceName: menuName, + sourceId: menuObjid, + targetId: newGroupId, + }); + } + } + + await client.query('COMMIT'); + + logger.info("메뉴 → 화면관리 동기화 완료", { + companyCode, + created: result.created, + linked: result.linked, + skipped: result.skipped + }); + + return result; + + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error("메뉴 → 화면관리 동기화 실패", { + companyCode, + error: error.message, + stack: error.stack, + code: error.code, + detail: error.detail, + }); + result.success = false; + result.errors.push(error.message); + return result; + } finally { + client.release(); + } +} + + +// ============================================================ +// 동기화 상태 조회 +// ============================================================ + +/** + * 동기화 상태 조회 + * + * - 연결된 항목 수 + * - 연결 안 된 항목 수 + * - 양방향 비교 + */ +export async function getSyncStatus(companyCode: string): Promise<{ + screenGroups: { total: number; linked: number; unlinked: number }; + menuItems: { total: number; linked: number; unlinked: number }; + potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>; +}> { + // screen_groups 상태 + const sgQuery = ` + SELECT + COUNT(*) as total, + COUNT(menu_objid) as linked + FROM screen_groups + WHERE company_code = $1 + `; + const sgResult = await pool.query(sgQuery, [companyCode]); + + // menu_info 상태 (사용자 메뉴만, 루트 제외) + const menuQuery = ` + SELECT + COUNT(*) as total, + COUNT(screen_group_id) as linked + FROM menu_info + WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0 + `; + const menuResult = await pool.query(menuQuery, [companyCode]); + + // 이름이 같은 잠재적 매칭 후보 조회 + const matchQuery = ` + SELECT + m.menu_name_kor as menu_name, + sg.group_name + FROM menu_info m + JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name)) + WHERE m.company_code = $1 + AND sg.company_code = $1 + AND m.menu_type = 1 + AND m.screen_group_id IS NULL + AND sg.menu_objid IS NULL + LIMIT 10 + `; + const matchResult = await pool.query(matchQuery, [companyCode]); + + const sgTotal = parseInt(sgResult.rows[0].total); + const sgLinked = parseInt(sgResult.rows[0].linked); + const menuTotal = parseInt(menuResult.rows[0].total); + const menuLinked = parseInt(menuResult.rows[0].linked); + + return { + screenGroups: { + total: sgTotal, + linked: sgLinked, + unlinked: sgTotal - sgLinked, + }, + menuItems: { + total: menuTotal, + linked: menuLinked, + unlinked: menuTotal - menuLinked, + }, + potentialMatches: matchResult.rows.map((row: any) => ({ + menuName: row.menu_name, + groupName: row.group_name, + similarity: 'exact', + })), + }; +} + + +// ============================================================ +// 전체 동기화 (모든 회사) +// ============================================================ + +interface AllCompaniesSyncResult { + success: boolean; + totalCompanies: number; + successCount: number; + failedCount: number; + results: Array<{ + companyCode: string; + companyName: string; + direction: 'screens-to-menus' | 'menus-to-screens'; + created: number; + linked: number; + skipped: number; + success: boolean; + error?: string; + }>; +} + +/** + * 모든 회사에 대해 양방향 동기화 수행 + * + * 로직: + * 1. 모든 회사 조회 + * 2. 각 회사별로 양방향 동기화 수행 + * - 화면관리 → 메뉴 동기화 + * - 메뉴 → 화면관리 동기화 + * 3. 결과 집계 + */ +export async function syncAllCompanies( + userId: string +): Promise { + const result: AllCompaniesSyncResult = { + success: true, + totalCompanies: 0, + successCount: 0, + failedCount: 0, + results: [], + }; + + try { + logger.info("전체 동기화 시작", { userId }); + + // 모든 회사 조회 (최고 관리자 전용 회사 제외) + const companiesQuery = ` + SELECT company_code, company_name + FROM company_mng + WHERE company_code != '*' + ORDER BY company_name + `; + const companiesResult = await pool.query(companiesQuery); + + result.totalCompanies = companiesResult.rows.length; + + // 각 회사별로 양방향 동기화 + for (const company of companiesResult.rows) { + const companyCode = company.company_code; + const companyName = company.company_name; + + try { + // 1. 화면관리 → 메뉴 동기화 + const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId); + result.results.push({ + companyCode, + companyName, + direction: 'screens-to-menus', + created: screensToMenusResult.created, + linked: screensToMenusResult.linked, + skipped: screensToMenusResult.skipped, + success: screensToMenusResult.success, + error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined, + }); + + // 2. 메뉴 → 화면관리 동기화 + const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId); + result.results.push({ + companyCode, + companyName, + direction: 'menus-to-screens', + created: menusToScreensResult.created, + linked: menusToScreensResult.linked, + skipped: menusToScreensResult.skipped, + success: menusToScreensResult.success, + error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined, + }); + + if (screensToMenusResult.success && menusToScreensResult.success) { + result.successCount++; + } else { + result.failedCount++; + } + + } catch (error: any) { + logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message }); + result.results.push({ + companyCode, + companyName, + direction: 'screens-to-menus', + created: 0, + linked: 0, + skipped: 0, + success: false, + error: error.message, + }); + result.failedCount++; + } + } + + logger.info("전체 동기화 완료", { + totalCompanies: result.totalCompanies, + successCount: result.successCount, + failedCount: result.failedCount, + }); + + return result; + + } catch (error: any) { + logger.error("전체 동기화 실패", { error: error.message }); + result.success = false; + return result; + } +} + diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 92a35663..783e83c0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2597,10 +2597,10 @@ export class ScreenManagementService { // 없으면 원본과 같은 회사에 복사 const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; - // 3. 화면 코드 중복 체크 (대상 회사 기준) + // 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만) const existingScreens = await client.query( `SELECT screen_id FROM screen_definitions - WHERE screen_code = $1 AND company_code = $2 + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, [copyData.screenCode, targetCompanyCode] ); diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 905d1179..cbd74337 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -53,6 +53,19 @@ export default function ScreenManagementPage() { loadScreens(); }, [loadScreens]); + // 화면 목록 새로고침 이벤트 리스너 + useEffect(() => { + const handleScreenListRefresh = () => { + console.log("🔄 화면 목록 새로고침 이벤트 수신"); + loadScreens(); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, [loadScreens]); + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 useEffect(() => { const openDesignerId = searchParams.get("openDesigner"); @@ -98,10 +111,15 @@ export default function ScreenManagementPage() { }; // 검색어로 필터링된 화면 - const filteredScreens = screens.filter((screen) => - screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || - screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) - ); + // 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시 + // 단일 키워드면 해당 키워드로 화면 필터링 + const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); + const filteredScreens = searchKeywords.length > 1 + ? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨) + : screens.filter((screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 if (isDesignMode) { @@ -170,6 +188,7 @@ export default function ScreenManagementPage() { selectedScreen={selectedScreen} onScreenSelect={handleScreenSelect} onScreenDesign={handleDesignScreen} + searchTerm={searchTerm} onGroupSelect={(group) => { setSelectedGroup(group); setSelectedScreen(null); // 화면 선택 해제 @@ -228,5 +247,3 @@ export default function ScreenManagementPage() { ); } - - diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 29288163..b61d5dae 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -23,7 +23,6 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 -import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 function ScreenViewPage() { const params = useParams(); @@ -33,7 +32,7 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - + // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -114,7 +113,7 @@ function ScreenViewPage() { // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); setEditModalConfig({ screenId: event.detail.screenId, @@ -265,8 +264,8 @@ function ScreenViewPage() { newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율 } else { // 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정) - const MARGIN_X = 32; - const availableWidth = containerWidth - MARGIN_X; + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; newScale = availableWidth / designWidth; } @@ -346,10 +345,9 @@ function ScreenViewPage() { {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( - -
); })()} -
-
+ ) : ( // 빈 화면일 때
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 1614c9b8..a252eaff 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -388,237 +388,6 @@ select { border-spacing: 0 !important; } -/* ===== POP (Production Operation Panel) Styles ===== */ - -/* POP 전용 다크 테마 변수 */ -.pop-dark { - /* 배경 색상 */ - --pop-bg-deepest: 8 12 21; - --pop-bg-deep: 10 15 28; - --pop-bg-primary: 13 19 35; - --pop-bg-secondary: 18 26 47; - --pop-bg-tertiary: 25 35 60; - --pop-bg-elevated: 32 45 75; - - /* 네온 강조색 */ - --pop-neon-cyan: 0 212 255; - --pop-neon-cyan-bright: 0 240 255; - --pop-neon-cyan-dim: 0 150 190; - --pop-neon-pink: 255 0 102; - --pop-neon-purple: 138 43 226; - - /* 상태 색상 */ - --pop-success: 0 255 136; - --pop-success-dim: 0 180 100; - --pop-warning: 255 170 0; - --pop-warning-dim: 200 130 0; - --pop-danger: 255 51 51; - --pop-danger-dim: 200 40 40; - - /* 텍스트 색상 */ - --pop-text-primary: 255 255 255; - --pop-text-secondary: 180 195 220; - --pop-text-muted: 100 120 150; - - /* 테두리 색상 */ - --pop-border: 40 55 85; - --pop-border-light: 55 75 110; -} - -/* POP 전용 라이트 테마 변수 */ -.pop-light { - --pop-bg-deepest: 245 247 250; - --pop-bg-deep: 240 243 248; - --pop-bg-primary: 250 251 253; - --pop-bg-secondary: 255 255 255; - --pop-bg-tertiary: 245 247 250; - --pop-bg-elevated: 235 238 245; - - --pop-neon-cyan: 0 122 204; - --pop-neon-cyan-bright: 0 140 230; - --pop-neon-cyan-dim: 0 100 170; - --pop-neon-pink: 220 38 127; - --pop-neon-purple: 118 38 200; - - --pop-success: 22 163 74; - --pop-success-dim: 21 128 61; - --pop-warning: 245 158 11; - --pop-warning-dim: 217 119 6; - --pop-danger: 220 38 38; - --pop-danger-dim: 185 28 28; - - --pop-text-primary: 15 23 42; - --pop-text-secondary: 71 85 105; - --pop-text-muted: 148 163 184; - - --pop-border: 226 232 240; - --pop-border-light: 203 213 225; -} - -/* POP 배경 그리드 패턴 */ -.pop-bg-pattern::before { - content: ""; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: - repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), - repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), - radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); - pointer-events: none; - z-index: 0; -} - -.pop-light .pop-bg-pattern::before { - background: - repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), - repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), - radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); -} - -/* POP 글로우 효과 */ -.pop-glow-cyan { - box-shadow: - 0 0 20px rgba(0, 212, 255, 0.5), - 0 0 40px rgba(0, 212, 255, 0.3); -} - -.pop-glow-cyan-strong { - box-shadow: - 0 0 10px rgba(0, 212, 255, 0.8), - 0 0 30px rgba(0, 212, 255, 0.5), - 0 0 50px rgba(0, 212, 255, 0.3); -} - -.pop-glow-success { - box-shadow: 0 0 15px rgba(0, 255, 136, 0.5); -} - -.pop-glow-warning { - box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); -} - -.pop-glow-danger { - box-shadow: 0 0 15px rgba(255, 51, 51, 0.5); -} - -/* POP 펄스 글로우 애니메이션 */ -@keyframes pop-pulse-glow { - 0%, - 100% { - box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); - } - 50% { - box-shadow: - 0 0 20px rgba(0, 212, 255, 0.8), - 0 0 30px rgba(0, 212, 255, 0.4); - } -} - -.pop-animate-pulse-glow { - animation: pop-pulse-glow 2s ease-in-out infinite; -} - -/* POP 프로그레스 바 샤인 애니메이션 */ -@keyframes pop-progress-shine { - 0% { - opacity: 0; - transform: translateX(-20px); - } - 50% { - opacity: 1; - } - 100% { - opacity: 0; - transform: translateX(20px); - } -} - -.pop-progress-shine::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 20px; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); - animation: pop-progress-shine 1.5s ease-in-out infinite; -} - -/* POP 스크롤바 스타일 */ -.pop-scrollbar::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.pop-scrollbar::-webkit-scrollbar-track { - background: rgb(var(--pop-bg-secondary)); -} - -.pop-scrollbar::-webkit-scrollbar-thumb { - background: rgb(var(--pop-border-light)); - border-radius: 9999px; -} - -.pop-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgb(var(--pop-neon-cyan-dim)); -} - -/* POP 스크롤바 숨기기 */ -.pop-hide-scrollbar::-webkit-scrollbar { - display: none; -} - -.pop-hide-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; -} - -/* ===== Marching Ants Animation (Excel Copy Border) ===== */ -@keyframes marching-ants-h { - 0% { - background-position: 0 0; - } - 100% { - background-position: 16px 0; - } -} - -@keyframes marching-ants-v { - 0% { - background-position: 0 0; - } - 100% { - background-position: 0 16px; - } -} - -.animate-marching-ants-h { - background: repeating-linear-gradient( - 90deg, - hsl(var(--primary)) 0, - hsl(var(--primary)) 4px, - transparent 4px, - transparent 8px - ); - background-size: 16px 2px; - animation: marching-ants-h 0.4s linear infinite; -} - -.animate-marching-ants-v { - background: repeating-linear-gradient( - 180deg, - hsl(var(--primary)) 0, - hsl(var(--primary)) 4px, - transparent 4px, - transparent 8px - ); - background-size: 2px 16px; - animation: marching-ants-v 0.4s linear infinite; -} - /* ===== 저장 테이블 막대기 애니메이션 ===== */ @keyframes saveBarDrop { 0% { diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index f5e71c4c..a5207b96 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -20,13 +20,29 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; +import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; interface LinkedModalScreen { screenId: number; @@ -45,7 +61,13 @@ interface CopyScreenModalProps { isOpen: boolean; onClose: () => void; sourceScreen: ScreenDefinition | null; - onCopySuccess: () => void; + onCopySuccess: () => void | Promise; + // 트리 구조 지원용 추가 props + mode?: "screen" | "group"; // 단일 화면 복제 또는 그룹 복제 + sourceGroup?: ScreenGroup | null; // 그룹 복제 시 원본 그룹 + groups?: ScreenGroup[]; // 대상 그룹 목록 + targetGroupId?: number | null; // 초기 선택된 대상 그룹 + allScreens?: ScreenDefinition[]; // 그룹 복제 시 사용할 전체 화면 목록 } export default function CopyScreenModal({ @@ -53,6 +75,11 @@ export default function CopyScreenModal({ onClose, sourceScreen, onCopySuccess, + mode = "screen", + sourceGroup, + groups = [], + targetGroupId: initialTargetGroupId, + allScreens = [], }: CopyScreenModalProps) { const { user } = useAuth(); // 최고 관리자 판별: userType이 "SUPER_ADMIN" 또는 companyCode가 "*" @@ -76,6 +103,21 @@ export default function CopyScreenModal({ const [screenCode, setScreenCode] = useState(""); const [description, setDescription] = useState(""); + // 대상 그룹 선택 (트리 구조용) + const [selectedTargetGroupId, setSelectedTargetGroupId] = useState(null); + const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); + + // 그룹 복제용 상태 + const [newGroupName, setNewGroupName] = useState(""); + const [groupParentId, setGroupParentId] = useState(null); + const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false); + const [groupDisplayOrder, setGroupDisplayOrder] = useState(0); + + // 그룹 일괄 이름 변경 (고급 옵션) - 찾기/대체 방식 + const [useGroupBulkRename, setUseGroupBulkRename] = useState(false); + const [groupFindText, setGroupFindText] = useState(""); // 찾을 텍스트 + const [groupReplaceText, setGroupReplaceText] = useState(""); // 대체할 텍스트 + // 대상 회사 선택 (최고 관리자 전용) const [targetCompanyCode, setTargetCompanyCode] = useState(""); const [companies, setCompanies] = useState([]); @@ -90,8 +132,12 @@ export default function CopyScreenModal({ const [removeText, setRemoveText] = useState(""); const [addPrefix, setAddPrefix] = useState(""); + // 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만) + const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all"); + // 복사 중 상태 const [isCopying, setIsCopying] = useState(false); + const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" }); // 최고 관리자인 경우 회사 목록 조회 useEffect(() => { @@ -104,24 +150,66 @@ export default function CopyScreenModal({ // 모달이 열릴 때 초기값 설정 및 연결된 화면 감지 useEffect(() => { - console.log("🔍 모달 초기화:", { isOpen, sourceScreen, isSuperAdmin }); - if (isOpen && sourceScreen) { - // 메인 화면 정보 설정 + console.log("🔍 모달 초기화:", { isOpen, sourceScreen, sourceGroup, mode, isSuperAdmin }); + + if (isOpen && mode === "screen" && sourceScreen) { + // 단일 화면 복제 모드 setScreenName(`${sourceScreen.screenName} (복사본)`); setDescription(sourceScreen.description || ""); // 대상 회사 코드 설정 if (isSuperAdmin) { - setTargetCompanyCode(sourceScreen.companyCode); // 기본값: 원본과 같은 회사 + setTargetCompanyCode(sourceScreen.companyCode); } else { setTargetCompanyCode(sourceScreen.companyCode); } + // 대상 그룹 초기화 (전달받은 값 또는 null) + setSelectedTargetGroupId(initialTargetGroupId ?? null); + // 연결된 모달 화면 감지 console.log("✅ 연결된 모달 화면 감지 시작"); detectLinkedModals(); + } else if (isOpen && mode === "group" && sourceGroup) { + // 그룹 복제 모드 + + // 1. 그룹명 중복 체크 - 같은 부모 그룹 내에 동일한 이름이 있는지 확인 + const parentId = sourceGroup.parent_group_id; + const siblingGroups = groups.filter(g => g.parent_group_id === parentId); + const existingNames = siblingGroups.map(g => g.group_name); + + let newName = sourceGroup.group_name; + if (existingNames.includes(newName)) { + // 겹치는 이름이 있으면 "(복제)" 추가 + newName = `${sourceGroup.group_name} (복제)`; + // "(복제)"도 겹치면 숫자 추가 + let copyNum = 2; + while (existingNames.includes(newName)) { + newName = `${sourceGroup.group_name} (복제 ${copyNum})`; + copyNum++; + } + } + setNewGroupName(newName); + + setGroupParentId(sourceGroup.parent_group_id ?? null); + + // 2. 상위 그룹의 회사 코드로 대상 회사 자동 설정 + let autoCompanyCode = sourceGroup.company_code || ""; + if (sourceGroup.parent_group_id) { + const parentGroup = groups.find(g => g.id === sourceGroup.parent_group_id); + if (parentGroup?.company_code) { + autoCompanyCode = parentGroup.company_code; + } + } + setTargetCompanyCode(autoCompanyCode); + + setGroupDisplayOrder(sourceGroup.display_order ?? 0); + setUseGroupBulkRename(false); + setGroupFindText(""); + setGroupReplaceText(""); + setGroupCopyMode("all"); } - }, [isOpen, sourceScreen, isSuperAdmin]); + }, [isOpen, sourceScreen, sourceGroup, mode, isSuperAdmin, initialTargetGroupId]); // 일괄 변경 설정이 변경될 때 화면명 자동 업데이트 useEffect(() => { @@ -194,11 +282,15 @@ export default function CopyScreenModal({ try { setLoadingCompanies(true); const response = await apiClient.get("/admin/companies"); + console.log("📋 회사 목록 API 응답:", response.data); const data = response.data.data || response.data || []; - setCompanies(data.map((c: any) => ({ + console.log("📋 회사 목록 데이터:", data); + const mappedCompanies = data.map((c: any) => ({ companyCode: c.company_code || c.companyCode, companyName: c.company_name || c.companyName, - }))); + })); + console.log("📋 매핑된 회사 목록:", mappedCompanies); + setCompanies(mappedCompanies); } catch (error) { console.error("회사 목록 조회 실패:", error); toast.error("회사 목록을 불러오는데 실패했습니다."); @@ -331,6 +423,82 @@ export default function CopyScreenModal({ }; }; + // 그룹 경로 가져오기 + const getGroupPath = (groupId: number | null): string => { + if (groupId === null) return ""; + const group = groups.find((g) => g.id === groupId); + if (!group) return ""; + + const path: string[] = [group.group_name]; + let currentParentId = group.parent_group_id; + + while (currentParentId) { + const parent = groups.find((g) => g.id === currentParentId); + if (parent) { + path.unshift(parent.group_name); + currentParentId = parent.parent_group_id; + } else { + break; + } + } + + return path.join(" > "); + }; + + // 그룹 레벨 가져오기 (들여쓰기용) + const getGroupLevel = (groupId: number | null): number => { + if (groupId === null) return 0; + const group = groups.find((g) => g.id === groupId); + if (!group) return 0; + + let level = 0; + let currentParentId = group.parent_group_id; + + while (currentParentId) { + level++; + const parent = groups.find((g) => g.id === currentParentId); + if (parent) { + currentParentId = parent.parent_group_id; + } else { + break; + } + } + + return level; + }; + + // 그룹 정렬 (계층 구조 유지) + const getSortedGroups = (): ScreenGroup[] => { + if (!groups || groups.length === 0) return []; + + const result: ScreenGroup[] = []; + + const addChildren = (parentId: number | null | undefined) => { + const children = groups.filter((g) => + (g.parent_group_id === parentId) || + (parentId === null && !g.parent_group_id) + ); + for (const child of children) { + result.push(child); + addChildren(child.id); + } + }; + + addChildren(null); + + // 정렬 결과가 비어있으면 원본 그룹 반환 (parent_group_id가 없는 경우) + return result.length > 0 ? result : groups; + }; + + // 고유한 화면코드 생성 (중복 시 _COPY 추가) + const generateUniqueScreenCode = (baseCode: string, existingCodes: Set): string => { + let newCode = `${baseCode}_COPY`; + while (existingCodes.has(newCode)) { + newCode = `${newCode}_COPY`; + } + return newCode; + }; + // 화면 복사 실행 const handleCopy = async () => { if (!sourceScreen) return; @@ -369,6 +537,7 @@ export default function CopyScreenModal({ companyCode, screenName.trim() ); + if (isMainDuplicate) { toast.error(`"${screenName}" 화면명이 이미 존재합니다. 다른 이름을 입력해주세요.`); setIsCopying(false); @@ -407,15 +576,330 @@ export default function CopyScreenModal({ console.log("✅ 복사 완료:", result); + // 대상 그룹이 선택된 경우 복제된 메인 화면을 그룹에 추가 + if (selectedTargetGroupId && result.mainScreen?.screenId) { + try { + await addScreenToGroup({ + group_id: selectedTargetGroupId, + screen_id: result.mainScreen.screenId, + screen_role: "MAIN", + display_order: 1, + }); + console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`); + } catch (groupError) { + console.error("그룹에 화면 추가 실패:", groupError); + // 그룹 추가 실패해도 복제는 성공했으므로 계속 진행 + } + } + toast.success( `화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)` ); - onCopySuccess(); + // 새로고침 완료 후 모달 닫기 + await onCopySuccess(); handleClose(); } catch (error: any) { console.error("화면 복사 실패:", error); - const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다."; + const errorMessage = error.response?.data?.message || error.message || "화면 복사에 실패했습니다."; + toast.error(errorMessage); + } finally { + setIsCopying(false); + } + }; + + // 이름 변환 헬퍼 함수 (일괄 이름 변경 적용) + const transformName = (originalName: string, isRootGroup: boolean = false): string => { + // 루트 그룹은 사용자가 직접 입력한 이름 사용 + if (isRootGroup) { + return newGroupName.trim(); + } + + // 일괄 이름 변경이 활성화된 경우 (찾기/대체 방식) + if (useGroupBulkRename && groupFindText) { + // 찾을 텍스트를 대체할 텍스트로 변경 + return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText); + } + + // 기본: "(복제)" 붙이기 + return `${originalName} (복제)`; + }; + + // 재귀적 그룹 복제 함수 (하위 그룹 + 화면 전부 복제) + const copyGroupRecursively = async ( + sourceGroupData: ScreenGroup, + parentGroupId: number | null, + targetCompany: string, + screenCodes: string[], // 미리 생성된 화면 코드 배열 + codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달) + stats: { groups: number; screens: number }, + totalScreenCount: number // 전체 화면 수 (진행률 표시용) + ): Promise => { + // 1. 현재 그룹 생성 (원본 display_order 유지) + const timestamp = Date.now(); + const randomSuffix = Math.floor(Math.random() * 1000); + const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`; + + console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`); + + const newGroupResponse = await createScreenGroup({ + group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용 + group_code: newGroupCode, + parent_group_id: parentGroupId, + target_company_code: targetCompany, + display_order: sourceGroupData.display_order, // 원본 정렬순서 유지 + }); + + if (!newGroupResponse.success || !newGroupResponse.data) { + throw new Error(newGroupResponse.error || `그룹 생성 실패: ${sourceGroupData.group_name}`); + } + + const newGroup = newGroupResponse.data; + stats.groups++; + console.log(`✅ 그룹 생성 완료: ${newGroup.group_name} (id: ${newGroup.id})`); + + // 2. 현재 그룹의 화면들 복제 (원본 display_order 유지) - folder_only 모드가 아닌 경우만 + if (groupCopyMode !== "folder_only") { + const sourceScreensInfo = sourceGroupData.screens || []; + + // 화면 정보와 display_order를 함께 매핑 + const screensWithOrder = sourceScreensInfo.map((s: any) => { + const screenId = typeof s === 'object' ? s.screen_id : s; + const displayOrder = typeof s === 'object' ? s.display_order : 0; + const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; + const screenData = allScreens.find((sc) => sc.screenId === screenId); + return { screenId, displayOrder, screenRole, screenData }; + }).filter(item => item.screenData); // 화면 데이터가 있는 것만 + + // display_order 순으로 정렬 + screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + + for (const { screenData: screen, displayOrder, screenRole } of screensWithOrder) { + try { + // 미리 생성된 화면 코드 사용 + const newScreenCode = screenCodes[codeIndex.current]; + codeIndex.current++; + + // 진행률 업데이트 + setCopyProgress({ + current: stats.screens + 1, + total: totalScreenCount, + message: `화면 복제 중: ${screen.screenName}` + }); + + console.log(` 📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + + const result = await screenApi.copyScreenWithModals(screen.screenId, { + targetCompanyCode: targetCompany, + mainScreen: { + screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenCode: newScreenCode, + description: screen.description || "", + }, + modalScreens: [], + }); + + if (result.mainScreen?.screenId) { + await addScreenToGroup({ + group_id: newGroup.id, + screen_id: result.mainScreen.screenId, + screen_role: screenRole || "MAIN", + display_order: displayOrder, // 원본 정렬순서 유지 + }); + stats.screens++; + console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + } + } catch (screenError) { + console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError); + } + } + } + + // 3. 하위 그룹들 재귀 복제 (display_order 순으로 정렬) - screen_only 모드가 아닌 경우만 + if (groupCopyMode !== "screen_only") { + const childGroups = groups + .filter(g => g.parent_group_id === sourceGroupData.id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + console.log(`📂 하위 그룹 ${childGroups.length}개 발견:`, childGroups.map(g => g.group_name)); + + for (const childGroup of childGroups) { + await copyGroupRecursively( + childGroup, + newGroup.id, + targetCompany, + screenCodes, + codeIndex, + stats, + totalScreenCount + ); + } + } + }; + + // 그룹 내 모든 화면 수 계산 (재귀적) + const countAllScreensInGroup = (groupId: number): number => { + const group = groups.find(g => g.id === groupId); + if (!group) return 0; + + const directScreens = group.screens?.length || 0; + const childGroups = groups.filter(g => g.parent_group_id === groupId); + const childScreens = childGroups.reduce((sum, child) => sum + countAllScreensInGroup(child.id), 0); + + return directScreens + childScreens; + }; + + // 그룹 복제 실행 + const handleCopyGroup = async () => { + if (!sourceGroup) return; + + if (!newGroupName.trim()) { + toast.error("그룹명을 입력해주세요."); + return; + } + + // 최고 관리자인 경우 대상 회사 필수 + if (isSuperAdmin && !targetCompanyCode) { + toast.error("대상 회사를 선택해주세요."); + return; + } + + try { + setIsCopying(true); + setCopyProgress({ current: 0, total: 0, message: "복제 준비 중..." }); + + const finalCompanyCode = targetCompanyCode || sourceGroup.company_code; + const stats = { groups: 0, screens: 0 }; + + console.log("🔄 그룹 복제 시작 (재귀적):", { + sourceGroup: sourceGroup.group_name, + targetCompany: finalCompanyCode, + }); + + // 1. 복제할 전체 화면 수 계산 (folder_only 모드가 아닌 경우만) + const totalScreenCount = groupCopyMode === "folder_only" ? 0 : countAllScreensInGroup(sourceGroup.id); + setCopyProgress({ current: 0, total: totalScreenCount, message: "화면 코드 생성 중..." }); + console.log(`📊 복제할 총 화면 수: ${totalScreenCount}개 (모드: ${groupCopyMode})`); + + // 2. 필요한 화면 코드들을 API로 미리 생성 (DB에서 고유 코드 보장) + let screenCodes: string[] = []; + if (totalScreenCount > 0) { + console.log(`🔧 화면 코드 ${totalScreenCount}개 생성 중...`); + screenCodes = await screenApi.generateMultipleScreenCodes(finalCompanyCode, totalScreenCount); + console.log(`✅ 화면 코드 생성 완료:`, screenCodes); + } + const codeIndex = { current: 0 }; // 참조로 전달해서 재귀 호출간 공유 + + // 3. 루트 그룹 생성 (일괄 변경 활성화 시 transformName 적용) + const timestamp = Date.now(); + const newGroupCode = `${finalCompanyCode}_GROUP_${timestamp}`; + + // 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용 + const rootGroupName = useGroupBulkRename && groupFindText + ? transformName(sourceGroup.group_name) + : newGroupName.trim(); + + const newGroupResponse = await createScreenGroup({ + group_name: rootGroupName, + group_code: newGroupCode, + parent_group_id: groupParentId, + target_company_code: finalCompanyCode, + display_order: groupDisplayOrder, // 사용자가 입력한 정렬 순서 + }); + + if (!newGroupResponse.success || !newGroupResponse.data) { + throw new Error(newGroupResponse.error || "그룹 생성 실패"); + } + + const newRootGroup = newGroupResponse.data; + stats.groups++; + console.log("✅ 루트 그룹 생성 완료:", newRootGroup.group_name); + + // 4. 원본 그룹의 화면들 복제 (루트 레벨, 원본 display_order 유지) - folder_only 모드가 아닌 경우만 + if (groupCopyMode !== "folder_only") { + const sourceScreensInfo = sourceGroup.screens || []; + + // 화면 정보와 display_order를 함께 매핑 + const screensWithOrder = sourceScreensInfo.map((s: any) => { + const screenId = typeof s === 'object' ? s.screen_id : s; + const displayOrder = typeof s === 'object' ? s.display_order : 0; + const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; + const screenData = allScreens.find((sc) => sc.screenId === screenId); + return { screenId, displayOrder, screenRole, screenData }; + }).filter(item => item.screenData); + + // display_order 순으로 정렬 + screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + + for (const { screenData: screen, displayOrder, screenRole } of screensWithOrder) { + try { + // 미리 생성된 화면 코드 사용 + const newScreenCode = screenCodes[codeIndex.current]; + codeIndex.current++; + + // 진행률 업데이트 + setCopyProgress({ + current: stats.screens + 1, + total: totalScreenCount, + message: `화면 복제 중: ${screen.screenName}` + }); + + console.log(`📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + + const result = await screenApi.copyScreenWithModals(screen.screenId, { + targetCompanyCode: finalCompanyCode, + mainScreen: { + screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenCode: newScreenCode, + description: screen.description || "", + }, + modalScreens: [], + }); + + if (result.mainScreen?.screenId) { + await addScreenToGroup({ + group_id: newRootGroup.id, + screen_id: result.mainScreen.screenId, + screen_role: screenRole || "MAIN", + display_order: displayOrder, // 원본 정렬순서 유지 + }); + stats.screens++; + console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + } + } catch (screenError) { + console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError); + } + } + } + + // 5. 하위 그룹들 재귀 복제 (display_order 순으로 정렬) - screen_only 모드가 아닌 경우만 + if (groupCopyMode !== "screen_only") { + const childGroups = groups + .filter(g => g.parent_group_id === sourceGroup.id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + console.log(`📂 하위 그룹 ${childGroups.length}개 발견:`, childGroups.map(g => g.group_name)); + + for (const childGroup of childGroups) { + await copyGroupRecursively( + childGroup, + newRootGroup.id, + finalCompanyCode, + screenCodes, + codeIndex, + stats, + totalScreenCount + ); + } + } + + toast.success( + `그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)` + ); + + await onCopySuccess(); + handleClose(); + } catch (error: any) { + console.error("그룹 복제 실패:", error); + const errorMessage = error.response?.data?.message || "그룹 복제에 실패했습니다."; toast.error(errorMessage); } finally { setIsCopying(false); @@ -429,272 +913,571 @@ export default function CopyScreenModal({ setDescription(""); setTargetCompanyCode(""); setLinkedScreens([]); + setSelectedTargetGroupId(null); + setNewGroupName(""); + setGroupParentId(null); + setGroupDisplayOrder(0); + setUseGroupBulkRename(false); + setGroupFindText(""); + setGroupReplaceText(""); onClose(); }; + // 그룹 복제 모드 렌더링 + if (mode === "group") { + return ( + + + {/* 로딩 오버레이 */} + {isCopying && ( +
+ +

{copyProgress.message}

+ {copyProgress.total > 0 && ( + <> +
+
+
+

+ {copyProgress.current} / {copyProgress.total} 화면 +

+ + )} +
+ )} + + + + + 그룹 복제 + + + "{sourceGroup?.group_name}" 그룹을 복제합니다. 그룹 내 모든 화면도 함께 복제됩니다. + + + +
+ {/* 대분류 경고 (최상위 그룹인 경우) */} + {sourceGroup && !sourceGroup.parent_group_id && ( +
+
+ +
+

대분류 폴더 복제

+

+ 이 폴더는 최상위(대분류) 폴더입니다. 복제 시 모든 하위 폴더와 화면이 함께 복제됩니다. + 데이터 양이 많을 경우 복제에 시간이 소요될 수 있습니다. +

+
+
+
+ )} + + {/* 원본 그룹 정보 */} +
+

원본 그룹 정보

+
+
+ 그룹명: {sourceGroup?.group_name} +
+
+ 정렬 순서: {sourceGroup?.display_order ?? 0} +
+
+ 직접 포함 화면: {sourceGroup?.screens?.length || 0}개 +
+ {(() => { + // 하위 그룹 수 계산 + const childGroupCount = groups.filter(g => g.parent_group_id === sourceGroup?.id).length; + // 총 화면 수 계산 (현재 그룹 + 모든 하위 그룹) + const totalScreenCount = sourceGroup ? countAllScreensInGroup(sourceGroup.id) : 0; + return ( + <> +
+ 하위 그룹: {childGroupCount}개 +
+ {childGroupCount > 0 && ( +
+ 복제될 총 화면:{" "} + {totalScreenCount}개 +
+ )} + + ); + })()} +
+
+ + {/* 복제 모드 선택 */} +
+ + setGroupCopyMode(value as "all" | "folder_only" | "screen_only")} + className="flex flex-wrap gap-4" + > +
+ + +
+
+ + +
+
+ + +
+
+

+ {groupCopyMode === "all" && "하위 그룹과 모든 화면이 함께 복제됩니다"} + {groupCopyMode === "folder_only" && "하위 그룹 구조만 복제하고 화면은 복제하지 않습니다"} + {groupCopyMode === "screen_only" && "현재 그룹의 화면만 복제하고 하위 그룹은 복제하지 않습니다"} +

+
+ + {/* 새 그룹명 + 정렬 순서 */} +
+
+ + setNewGroupName(e.target.value)} + placeholder="복제될 그룹의 이름을 입력하세요" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + { + const val = e.target.value; + setGroupDisplayOrder(val === "" ? 0 : parseInt(val) || 0); + }} + onBlur={(e) => { + // 빈 값이면 0으로 설정 + if (e.target.value === "") { + setGroupDisplayOrder(0); + } + }} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 상위 그룹 선택 */} + {groups.length > 0 && ( +
+ + + + + + + + + + + 그룹을 찾을 수 없습니다. + + + { + setGroupParentId(null); + setIsParentGroupSelectOpen(false); + // 최상위 선택 시 원본 그룹의 회사 코드 유지 + if (sourceGroup?.company_code) { + setTargetCompanyCode(sourceGroup.company_code); + } + }} + className="text-xs sm:text-sm" + > + + 최상위 그룹 (상위 없음) + + {getSortedGroups() + .filter((g) => g.id !== sourceGroup?.id) + .map((group) => ( + { + setGroupParentId(group.id); + setIsParentGroupSelectOpen(false); + // 선택한 상위 그룹의 회사 코드로 자동 설정 + if (group.company_code) { + setTargetCompanyCode(group.company_code); + } + }} + className="text-xs sm:text-sm" + > + + + {group.group_name} + + + ))} + + + + + +
+ )} + + {/* 대상 회사 선택 (최고 관리자 전용) */} + {isSuperAdmin && companies.length > 0 && ( +
+ + +

+ 복제된 그룹과 화면이 이 회사에 생성됩니다 +

+
+ )} + + {/* 고급 옵션: 일괄 이름 변경 */} +
+ + 고급 옵션: 이름 일괄 변경 + +
+
+ setUseGroupBulkRename(e.target.checked)} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ + {useGroupBulkRename && ( + <> +
+ + setGroupFindText(e.target.value)} + placeholder="예: 테스트" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 모든 폴더/화면 이름에서 이 텍스트를 찾습니다 +

+
+ +
+ + setGroupReplaceText(e.target.value)} + placeholder="예: TEST" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 찾은 텍스트를 이 텍스트로 대체합니다 +

+
+ +
+ 미리보기: +
+ "{sourceGroup?.group_name}" → " + {groupFindText + ? (sourceGroup?.group_name || "").replace(new RegExp(groupFindText, "g"), groupReplaceText) + : `${sourceGroup?.group_name} (복제)`} + " +
+
+ + )} +
+
+
+ + + + + + +
+ ); + } + + // 화면 복제 모드 렌더링 return ( - + - - - 화면 복사 - {linkedScreens.length > 0 && ( - - ({linkedScreens.length}개의 모달 화면 포함) - - )} - - - {sourceScreen?.screenName} 화면을 복사합니다. 화면 구성과 연결된 모달 화면도 함께 복사됩니다. + 화면 복제 + + "{sourceScreen?.screenName}" 화면을 복제합니다. + {linkedScreens.length > 0 && ` (모달 ${linkedScreens.length}개 포함)`}
- {/* 원본 화면 정보 */} -
-

원본 화면 정보

-
-
- 화면명: {sourceScreen?.screenName} -
-
- 화면코드: {sourceScreen?.screenCode} -
-
- 회사코드: {sourceScreen?.companyCode} -
-
+ {/* 새 화면명 */} +
+ + setScreenName(e.target.value)} + placeholder="복제될 화면 이름" + className="mt-1" + />
+ {/* 새 화면코드 (자동생성) */} +
+ + +
+ + {/* 대상 그룹 선택 */} + {groups.length > 0 && ( +
+ + + + + + + + + + 그룹 없음 + + { + setSelectedTargetGroupId(null); + setIsGroupSelectOpen(false); + }} + > + + 미분류 + + {getSortedGroups().map((group) => ( + { + setSelectedTargetGroupId(group.id); + setIsGroupSelectOpen(false); + }} + > + + + {group.group_name} + + + ))} + + + + + +
+ )} + {/* 최고 관리자: 대상 회사 선택 */} {isSuperAdmin && (
- + -

- 선택한 회사로 화면이 복사됩니다. 원본과 다른 회사를 선택하면 회사 간 화면 복사가 가능합니다. -

)} - {/* 화면명 일괄 수정 */} -
-
- setUseBulkRename(e.target.checked)} - className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500" - /> - -
- - {useBulkRename && ( -
-
-
- - setRemoveText(e.target.value)} - placeholder="예: 탑씰" - className="mt-1 bg-white" - /> -
-
- - setAddPrefix(e.target.value)} - placeholder="예: 대진산업" - className="mt-1 bg-white" - /> -
-
- - {/* 미리보기 */} - {(removeText || addPrefix) && getPreviewNames() && ( -
-

미리보기

-
- {/* 메인 화면 */} -
-

- 메인: {getPreviewNames()?.main.original} -

-

- → {getPreviewNames()?.main.preview} -

-
- {/* 모달 화면들 */} - {getPreviewNames()?.modals.map((modal, idx) => ( -
-

- 모달: {modal.original} -

-

→ {modal.preview}

-
- ))} -
-
- )} - -

- 💡 모든 화면명에서 "제거할 텍스트"를 삭제하고 "추가할 접두사"를 앞에 붙입니다. -

-
- )} -
- - {/* 메인 화면 정보 입력 */} -
-

메인 화면 정보

- -
- - setScreenName(e.target.value)} - placeholder="복사될 화면의 이름을 입력하세요" - className="mt-1" - /> -
- -
- - -
- -
- -