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/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..4e2878eb 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"); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 29288163..4923ded7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -33,7 +33,7 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - + // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -265,8 +265,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; } 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" - /> -
- -
- - -
- -
- -