diff --git a/PLAN.MD b/PLAN.MD index 507695c6..0ca6521d 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,4 +1,65 @@ -# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) +# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리) + +## 개요 +화면 관리 시스템의 복제 및 삭제 기능을 전면 개선하여, 단일 화면 복제, 그룹(폴더) 전체 복제, 정렬 순서 유지, 일괄 이름 변경 등 다양한 고급 기능을 지원합니다. + +## 핵심 기능 + +### 1. 단일 화면 복제 +- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택 +- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가) +- [x] 연결된 모달 화면 함께 복제 +- [x] 대상 그룹 선택 가능 +- [x] 복제 후 목록 자동 새로고침 + +### 2. 그룹(폴더) 전체 복제 +- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제 +- [x] 정렬 순서(display_order) 유지 + - 그룹 생성 시 원본 display_order 전달 + - 화면 추가 시 원본 display_order 유지 + - 하위 그룹들 display_order 순으로 정렬 후 복제 +- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시 +- [x] 정렬 순서 입력 필드 추가 (사용자가 직접 수정 가능) +- [x] 원본 그룹 정보 표시 개선 + - 직접 포함 화면 수 + - 하위 그룹 수 + - 복제될 총 화면 수 (하위 그룹 포함) + +### 3. 고급 옵션: 이름 일괄 변경 +- [x] 삭제할 텍스트 지정 (모든 폴더/화면 이름에서 제거) +- [x] 추가할 접미사 지정 (기본값: " (복제)") +- [x] 미리보기 기능 + +### 4. 삭제 기능 +- [x] 단일 화면 삭제 (휴지통으로 이동) +- [x] 그룹 삭제 시 옵션 선택 + - "화면도 함께 삭제" 체크박스 + - 체크 시: 그룹 + 포함된 화면 모두 삭제 + - 미체크 시: 화면은 "미분류"로 이동 + +### 5. 회사 코드 지원 (최고 관리자) +- [x] 대상 회사 선택 가능 +- [x] 복제된 그룹/화면에 선택한 회사 코드 적용 + +## 관련 파일 +- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 (화면/그룹 통합) +- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 +- `frontend/lib/api/screen.ts` - 화면 API (복제, 삭제) +- `frontend/lib/api/screenGroup.ts` - 그룹 API + +## 진행 상태 +- [완료] 단일 화면 복제 + 새로고침 +- [완료] 그룹 전체 복제 (재귀적) +- [완료] 정렬 순서(display_order) 유지 +- [완료] 대분류 경고 문구 +- [완료] 정렬 순서 입력 필드 +- [완료] 고급 옵션: 이름 일괄 변경 +- [완료] 단일 화면 삭제 +- [완료] 그룹 삭제 (화면 함께 삭제 옵션) + +--- + +# 이전 프로젝트: 외부 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 d42029d1..f4f89d25 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); @@ -593,98 +558,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..569fe793 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,23 +1,18 @@ 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"; // 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 +84,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 +130,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 +191,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 +204,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,10 +293,10 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const deleteScreenGroup = 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_groups WHERE id = $1`; const params: any[] = [id]; @@ -375,10 +329,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 +369,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 +400,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 +439,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 +480,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 +521,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 +566,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 +600,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 +650,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 +689,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 +732,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 +766,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 +815,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 +848,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 +883,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]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index e57e8295..db301ec8 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -809,12 +809,6 @@ export async function getTableData( } } - // 🆕 최종 검색 조건 로그 - logger.info( - `🔍 최종 검색 조건 (enhancedSearch):`, - JSON.stringify(enhancedSearch) - ); - // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), @@ -898,10 +892,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}`); @@ -911,10 +902,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}`); @@ -922,25 +910,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); @@ -1668,10 +1644,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1684,10 +1660,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({ @@ -1713,11 +1686,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 ( @@ -1739,21 +1709,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 @@ -1783,31 +1749,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 @@ -1817,17 +1772,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({ @@ -1837,7 +1786,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1862,15 +1811,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({ @@ -1895,9 +1842,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1967,29 +1914,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}" @@ -1998,43 +1939,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 = ` @@ -2045,10 +1972,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); } @@ -2067,15 +1991,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; } @@ -2088,20 +2009,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); } @@ -2114,12 +2030,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, }; @@ -2133,8 +2044,7 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = - options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; } // company_code 추가 @@ -2157,30 +2067,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}" @@ -2199,26 +2099,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 = ` @@ -2228,11 +2116,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] }); } } @@ -2248,12 +2132,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 = ` @@ -2262,16 +2142,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} 저장 완료`); @@ -2312,11 +2185,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, @@ -2325,55 +2195,94 @@ 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/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/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 40628f12..4891e353 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2603,10 +2603,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 37c17d14..eb8b1c8e 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -54,6 +54,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"); @@ -247,5 +260,3 @@ export default function ScreenManagementPage() { ); } - - diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 133a7256..dba52052 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -34,7 +34,7 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - + // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -115,7 +115,7 @@ function ScreenViewPage() { // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); setEditModalConfig({ screenId: event.detail.screenId, @@ -327,8 +327,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; } @@ -408,10 +408,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..5590cef4 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" - /> -
- -
- - -
- -
- -