ERP-node/frontend/components/screen/CopyScreenModal.tsx

1489 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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;
screenName: string;
screenCode: string;
newScreenName?: string;
newScreenCode?: string;
}
interface CompanyInfo {
companyCode: string;
companyName: string;
}
interface CopyScreenModalProps {
isOpen: boolean;
onClose: () => void;
sourceScreen: ScreenDefinition | null;
onCopySuccess: () => void | Promise<void>;
// 트리 구조 지원용 추가 props
mode?: "screen" | "group"; // 단일 화면 복제 또는 그룹 복제
sourceGroup?: ScreenGroup | null; // 그룹 복제 시 원본 그룹
groups?: ScreenGroup[]; // 대상 그룹 목록
targetGroupId?: number | null; // 초기 선택된 대상 그룹
allScreens?: ScreenDefinition[]; // 그룹 복제 시 사용할 전체 화면 목록
}
export default function CopyScreenModal({
isOpen,
onClose,
sourceScreen,
onCopySuccess,
mode = "screen",
sourceGroup,
groups = [],
targetGroupId: initialTargetGroupId,
allScreens = [],
}: CopyScreenModalProps) {
const { user } = useAuth();
// 최고 관리자 판별: userType이 "SUPER_ADMIN" 또는 companyCode가 "*"
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
// 디버깅: 사용자 정보 확인
useEffect(() => {
console.log("🔍 CopyScreenModal - User Info:", {
user,
isSuperAdmin,
userType: user?.userType,
companyCode: user?.companyCode,
조건1: user?.userType === "SUPER_ADMIN",
조건2: user?.companyCode === "*",
최종판별: user?.userType === "SUPER_ADMIN" || user?.companyCode === "*",
});
}, [user, isSuperAdmin]);
// 메인 화면 복사 정보
const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState("");
const [description, setDescription] = useState("");
// 대상 그룹 선택 (트리 구조용)
const [selectedTargetGroupId, setSelectedTargetGroupId] = useState<number | null>(null);
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
// 그룹 복제용 상태
const [newGroupName, setNewGroupName] = useState("");
const [groupParentId, setGroupParentId] = useState<number | null>(null);
const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false);
const [groupDisplayOrder, setGroupDisplayOrder] = useState<number>(0);
// 그룹 일괄 이름 변경 (고급 옵션) - 찾기/대체 방식
const [useGroupBulkRename, setUseGroupBulkRename] = useState(false);
const [groupFindText, setGroupFindText] = useState(""); // 찾을 텍스트
const [groupReplaceText, setGroupReplaceText] = useState(""); // 대체할 텍스트
// 대상 회사 선택 (최고 관리자 전용)
const [targetCompanyCode, setTargetCompanyCode] = useState<string>("");
const [companies, setCompanies] = useState<CompanyInfo[]>([]);
const [loadingCompanies, setLoadingCompanies] = useState(false);
// 연결된 모달 화면들
const [linkedScreens, setLinkedScreens] = useState<LinkedModalScreen[]>([]);
const [loadingLinkedScreens, setLoadingLinkedScreens] = useState(false);
// 화면명 일괄 수정 기능
const [useBulkRename, setUseBulkRename] = useState(false);
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(() => {
console.log("🔍 회사 목록 조회 체크:", { isSuperAdmin, isOpen });
if (isSuperAdmin && isOpen) {
console.log("✅ 회사 목록 조회 시작");
loadCompanies();
}
}, [isSuperAdmin, isOpen]);
// 모달이 열릴 때 초기값 설정 및 연결된 화면 감지
useEffect(() => {
console.log("🔍 모달 초기화:", { isOpen, sourceScreen, sourceGroup, mode, isSuperAdmin });
if (isOpen && mode === "screen" && sourceScreen) {
// 단일 화면 복제 모드
setScreenName(`${sourceScreen.screenName} (복사본)`);
setDescription(sourceScreen.description || "");
// 대상 회사 코드 설정
if (isSuperAdmin) {
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, sourceGroup, mode, isSuperAdmin, initialTargetGroupId]);
// 일괄 변경 설정이 변경될 때 화면명 자동 업데이트
useEffect(() => {
if (!sourceScreen) return;
if (useBulkRename) {
// 일괄 수정 사용 시: (복사본) 텍스트 제거
const newMainName = applyBulkRename(sourceScreen.screenName);
setScreenName(newMainName);
// 모달 화면명 업데이트
setLinkedScreens((prev) =>
prev.map((screen) => ({
...screen,
newScreenName: applyBulkRename(screen.screenName),
}))
);
} else {
// 일괄 수정 미사용 시: (복사본) 텍스트 추가
setScreenName(`${sourceScreen.screenName} (복사본)`);
setLinkedScreens((prev) =>
prev.map((screen) => ({
...screen,
newScreenName: screen.screenName,
}))
);
}
}, [useBulkRename, removeText, addPrefix]);
// 대상 회사 변경 시 기존 코드 초기화
useEffect(() => {
if (targetCompanyCode) {
console.log("🔄 회사 변경 → 기존 코드 초기화:", targetCompanyCode);
setScreenCode("");
// 모달 화면들의 코드도 초기화
setLinkedScreens((prev) =>
prev.map((screen) => ({ ...screen, newScreenCode: undefined }))
);
}
}, [targetCompanyCode]);
// linkedScreens 로딩이 완료되면 화면 코드 생성
useEffect(() => {
// 모달 화면들의 코드가 모두 설정되었는지 확인
const allModalCodesSet = linkedScreens.length === 0 ||
linkedScreens.every(screen => screen.newScreenCode);
console.log("🔍 코드 생성 조건 체크:", {
targetCompanyCode,
loadingLinkedScreens,
screenCode,
linkedScreensCount: linkedScreens.length,
allModalCodesSet,
});
// 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때)
const needsCodeGeneration = targetCompanyCode &&
!loadingLinkedScreens &&
(!screenCode || (linkedScreens.length > 0 && !allModalCodesSet));
if (needsCodeGeneration) {
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
generateScreenCodes();
}
}, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]);
// 회사 목록 조회
const loadCompanies = async () => {
try {
setLoadingCompanies(true);
const response = await apiClient.get("/admin/companies");
console.log("📋 회사 목록 API 응답:", response.data);
const data = response.data.data || response.data || [];
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("회사 목록을 불러오는데 실패했습니다.");
} finally {
setLoadingCompanies(false);
}
};
// 연결된 모달 화면 감지
const detectLinkedModals = async () => {
if (!sourceScreen) return;
try {
setLoadingLinkedScreens(true);
console.log("📡 API 호출: detectLinkedModals", sourceScreen.screenId);
const linked = await screenApi.detectLinkedModals(sourceScreen.screenId);
console.log("✅ 연결된 모달 화면 감지 결과:", linked);
// 초기 newScreenName 설정
setLinkedScreens(
linked.map((screen) => ({
...screen,
newScreenName: `${screen.screenName} (복사본)`,
}))
);
if (linked.length > 0) {
toast.info(`${linked.length}개의 연결된 모달 화면을 감지했습니다.`);
console.log("🎉 감지된 화면들:", linked);
} else {
console.log(" 연결된 모달 화면이 없습니다.");
}
} catch (error) {
console.error("❌ 연결된 화면 감지 실패:", error);
// 에러가 나도 진행 가능하도록 무시
} finally {
setLoadingLinkedScreens(false);
}
};
// 화면 코드 자동 생성 (메인 + 모달 화면들) - 일괄 생성으로 중복 방지
const generateScreenCodes = async () => {
if (!targetCompanyCode) {
console.log("❌ targetCompanyCode가 없어서 화면 코드 생성 중단");
return;
}
try {
// 메인 화면 1개 + 연결된 모달 화면들 = 총 개수
const totalCount = 1 + linkedScreens.length;
console.log(`📡 화면 코드 일괄 생성 API 호출: ${targetCompanyCode}, 개수: ${totalCount}`);
// 한 번에 모든 코드 생성 (중복 방지)
const generatedCodes = await screenApi.generateMultipleScreenCodes(
targetCompanyCode,
totalCount
);
console.log("✅ 생성된 화면 코드들:", generatedCodes);
// 첫 번째 코드는 메인 화면용
setScreenCode(generatedCodes[0]);
console.log("✅ 메인 화면 코드:", generatedCodes[0]);
// 나머지 코드들은 모달 화면들에 순서대로 할당
if (linkedScreens.length > 0) {
const updatedLinkedScreens = linkedScreens.map((screen, index) => ({
...screen,
newScreenCode: generatedCodes[index + 1], // 1번째부터 시작
}));
setLinkedScreens(updatedLinkedScreens);
console.log("✅ 모달 화면 코드 할당 완료:", updatedLinkedScreens.map(s => ({
name: s.screenName,
code: s.newScreenCode
})));
}
} catch (error) {
console.error("❌ 화면 코드 일괄 생성 실패:", error);
toast.error("화면 코드 생성에 실패했습니다.");
}
};
// 연결된 화면 이름 변경
const updateLinkedScreenName = (screenId: number, newName: string) => {
setLinkedScreens((prev) =>
prev.map((screen) =>
screen.screenId === screenId ? { ...screen, newScreenName: newName } : screen
)
);
};
// 연결된 화면 제거 (복사하지 않음)
const removeLinkedScreen = (screenId: number) => {
setLinkedScreens((prev) => prev.filter((screen) => screen.screenId !== screenId));
};
// 화면명 일괄 변경 적용
const applyBulkRename = (originalName: string): string => {
if (!useBulkRename) return originalName;
let newName = originalName;
// 1. 제거할 텍스트 제거
if (removeText.trim()) {
newName = newName.replace(new RegExp(removeText.trim(), "g"), "");
newName = newName.trim(); // 앞뒤 공백 제거
}
// 2. 접두사 추가
if (addPrefix.trim()) {
newName = addPrefix.trim() + " " + newName;
}
return newName;
};
// 미리보기: 변경될 화면명들
const getPreviewNames = () => {
if (!sourceScreen || !useBulkRename) return null;
return {
main: {
original: sourceScreen.screenName,
preview: applyBulkRename(sourceScreen.screenName), // (복사본) 없음
},
modals: linkedScreens.map((screen) => ({
original: screen.screenName,
preview: applyBulkRename(screen.screenName),
})),
};
};
// 그룹 경로 가져오기
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>): string => {
let newCode = `${baseCode}_COPY`;
while (existingCodes.has(newCode)) {
newCode = `${newCode}_COPY`;
}
return newCode;
};
// 화면 복사 실행
const handleCopy = async () => {
if (!sourceScreen) return;
// 입력값 검증
if (!screenName.trim()) {
toast.error("화면명을 입력해주세요.");
return;
}
if (!screenCode.trim()) {
toast.error("화면 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.");
return;
}
// 연결된 화면들의 이름 검증
for (const linked of linkedScreens) {
if (!linked.newScreenName?.trim()) {
toast.error(`"${linked.screenName}" 모달 화면의 새 이름을 입력해주세요.`);
return;
}
if (!linked.newScreenCode?.trim()) {
toast.error(`"${linked.screenName}" 모달 화면의 코드가 생성되지 않았습니다.`);
return;
}
}
try {
setIsCopying(true);
// 화면명 중복 체크
const companyCode = targetCompanyCode || sourceScreen.companyCode;
// 메인 화면명 중복 체크
const isMainDuplicate = await screenApi.checkDuplicateScreenName(
companyCode,
screenName.trim()
);
if (isMainDuplicate) {
toast.error(`"${screenName}" 화면명이 이미 존재합니다. 다른 이름을 입력해주세요.`);
setIsCopying(false);
return;
}
// 모달 화면명 중복 체크
for (const linked of linkedScreens) {
const isModalDuplicate = await screenApi.checkDuplicateScreenName(
companyCode,
linked.newScreenName!.trim()
);
if (isModalDuplicate) {
toast.error(
`"${linked.newScreenName}" 화면명이 이미 존재합니다. 모달 화면의 이름을 변경해주세요.`
);
setIsCopying(false);
return;
}
}
// 메인 화면 + 모달 화면들 일괄 복사
const result = await screenApi.copyScreenWithModals(sourceScreen.screenId, {
targetCompanyCode: targetCompanyCode || undefined, // 최고 관리자: 대상 회사 전달
mainScreen: {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
description: description.trim(),
},
modalScreens: linkedScreens.map((screen) => ({
sourceScreenId: screen.screenId,
screenName: screen.newScreenName!.trim(),
screenCode: screen.newScreenCode!.trim(),
})),
});
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}개)`
);
// 새로고침 완료 후 모달 닫기
await onCopySuccess();
handleClose();
} catch (error: any) {
console.error("화면 복사 실패:", error);
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<void> => {
// 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);
}
};
// 모달 닫기
const handleClose = () => {
setScreenName("");
setScreenCode("");
setDescription("");
setTargetCompanyCode("");
setLinkedScreens([]);
setSelectedTargetGroupId(null);
setNewGroupName("");
setGroupParentId(null);
setGroupDisplayOrder(0);
setUseGroupBulkRename(false);
setGroupFindText("");
setGroupReplaceText("");
onClose();
};
// 그룹 복제 모드 렌더링
if (mode === "group") {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
{/* 로딩 오버레이 */}
{isCopying && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="mt-4 text-sm font-medium">{copyProgress.message}</p>
{copyProgress.total > 0 && (
<>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.round((copyProgress.current / copyProgress.total) * 100)}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{copyProgress.current} / {copyProgress.total}
</p>
</>
)}
</div>
)}
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FolderTree className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{sourceGroup?.group_name}" . .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 대분류 경고 (최상위 그룹인 경우) */}
{sourceGroup && !sourceGroup.parent_group_id && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<div>
<h4 className="text-sm font-medium text-amber-800"> </h4>
<p className="mt-1 text-xs text-amber-700">
() . .
.
</p>
</div>
</div>
</div>
)}
{/* 원본 그룹 정보 */}
<div className="rounded-md bg-gray-50 p-3">
<h4 className="mb-2 text-sm font-medium text-gray-700"> </h4>
<div className="space-y-1 text-sm text-muted-foreground">
<div>
<span className="font-medium">:</span> {sourceGroup?.group_name}
</div>
<div>
<span className="font-medium"> :</span> {sourceGroup?.display_order ?? 0}
</div>
<div>
<span className="font-medium"> :</span> {sourceGroup?.screens?.length || 0}
</div>
{(() => {
// 하위 그룹 수 계산
const childGroupCount = groups.filter(g => g.parent_group_id === sourceGroup?.id).length;
// 총 화면 수 계산 (현재 그룹 + 모든 하위 그룹)
const totalScreenCount = sourceGroup ? countAllScreensInGroup(sourceGroup.id) : 0;
return (
<>
<div>
<span className="font-medium"> :</span> {childGroupCount}
</div>
{childGroupCount > 0 && (
<div className="mt-1 border-t pt-1">
<span className="font-medium text-primary"> :</span>{" "}
<span className="font-semibold text-primary">{totalScreenCount}</span>
</div>
)}
</>
);
})()}
</div>
</div>
{/* 복제 모드 선택 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<RadioGroup
value={groupCopyMode}
onValueChange={(value) => setGroupCopyMode(value as "all" | "folder_only" | "screen_only")}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="copy-all" />
<Label htmlFor="copy-all" className="text-xs sm:text-sm font-normal cursor-pointer">
(+)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="folder_only" id="copy-folder-only" />
<Label htmlFor="copy-folder-only" className="text-xs sm:text-sm font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen_only" id="copy-screen-only" />
<Label htmlFor="copy-screen-only" className="text-xs sm:text-sm font-normal cursor-pointer">
( )
</Label>
</div>
</RadioGroup>
<p className="text-[10px] sm:text-xs text-muted-foreground">
{groupCopyMode === "all" && "하위 그룹과 모든 화면이 함께 복제됩니다"}
{groupCopyMode === "folder_only" && "하위 그룹 구조만 복제하고 화면은 복제하지 않습니다"}
{groupCopyMode === "screen_only" && "현재 그룹의 화면만 복제하고 하위 그룹은 복제하지 않습니다"}
</p>
</div>
{/* 새 그룹명 + 정렬 순서 */}
<div className="flex gap-3">
<div className="flex-1">
<Label htmlFor="newGroupName" className="text-xs sm:text-sm">
*
</Label>
<Input
id="newGroupName"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="복제될 그룹의 이름을 입력하세요"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="w-24">
<Label htmlFor="groupDisplayOrder" className="text-xs sm:text-sm">
</Label>
<Input
id="groupDisplayOrder"
type="number"
min={0}
value={groupDisplayOrder === 0 ? "" : groupDisplayOrder}
onChange={(e) => {
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"
/>
</div>
</div>
{/* 상위 그룹 선택 */}
{groups.length > 0 && (
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Popover open={isParentGroupSelectOpen} onOpenChange={setIsParentGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isParentGroupSelectOpen}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{groupParentId
? getGroupPath(groupParentId) || "그룹 선택"
: "상위 그룹 없음 (최상위)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="그룹 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="최상위 그룹"
onSelect={() => {
setGroupParentId(null);
setIsParentGroupSelectOpen(false);
// 최상위 선택 시 원본 그룹의 회사 코드 유지
if (sourceGroup?.company_code) {
setTargetCompanyCode(sourceGroup.company_code);
}
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
groupParentId === null ? "opacity-100" : "opacity-0"
)}
/>
( )
</CommandItem>
{getSortedGroups()
.filter((g) => g.id !== sourceGroup?.id)
.map((group) => (
<CommandItem
key={group.id}
value={`${group.group_name} ${getGroupPath(group.id)}`}
onSelect={() => {
setGroupParentId(group.id);
setIsParentGroupSelectOpen(false);
// 선택한 상위 그룹의 회사 코드로 자동 설정
if (group.company_code) {
setTargetCompanyCode(group.company_code);
}
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
groupParentId === group.id ? "opacity-100" : "opacity-0"
)}
/>
<span style={{ paddingLeft: `${getGroupLevel(group.id) * 12}px` }}>
{group.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 대상 회사 선택 (최고 관리자 전용) */}
{isSuperAdmin && companies.length > 0 && (
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<select
value={targetCompanyCode}
onChange={(e) => setTargetCompanyCode(e.target.value)}
className="mt-1 flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
>
{companies.map((company) => (
<option key={company.companyCode} value={company.companyCode}>
{company.companyName} ({company.companyCode})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
)}
{/* 고급 옵션: 일괄 이름 변경 */}
<details className="rounded-md border p-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground sm:text-sm">
옵션: 이름
</summary>
<div className="mt-3 space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="useGroupBulkRename"
checked={useGroupBulkRename}
onChange={(e) => setUseGroupBulkRename(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="useGroupBulkRename" className="text-xs sm:text-sm">
/
</label>
</div>
{useGroupBulkRename && (
<>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={groupFindText}
onChange={(e) => setGroupFindText(e.target.value)}
placeholder="예: 테스트"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
/
</p>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={groupReplaceText}
onChange={(e) => setGroupReplaceText(e.target.value)}
placeholder="예: TEST"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
<div className="rounded-md bg-gray-50 p-2 text-xs text-muted-foreground">
<strong>:</strong>
<div className="mt-1">
"{sourceGroup?.group_name}" "
{groupFindText
? (sourceGroup?.group_name || "").replace(new RegExp(groupFindText, "g"), groupReplaceText)
: `${sourceGroup?.group_name} (복제)`}
"
</div>
</div>
</>
)}
</div>
</details>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleClose}
disabled={isCopying}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleCopyGroup}
disabled={isCopying || !newGroupName.trim() || (isSuperAdmin && !targetCompanyCode)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isCopying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<FolderTree className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 화면 복제 모드 렌더링
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{sourceScreen?.screenName}" .
{linkedScreens.length > 0 && ` (모달 ${linkedScreens.length}개 포함)`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 새 화면명 */}
<div>
<Label htmlFor="screenName" className="text-sm"> *</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="복제될 화면 이름"
className="mt-1"
/>
</div>
{/* 새 화면코드 (자동생성) */}
<div>
<Label htmlFor="screenCode" className="text-sm"> ()</Label>
<Input
id="screenCode"
value={screenCode}
readOnly
className="mt-1 bg-muted"
/>
</div>
{/* 대상 그룹 선택 */}
{groups.length > 0 && (
<div>
<Label className="text-sm"> </Label>
<Popover open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="mt-1 h-10 w-full justify-between text-sm"
>
{selectedTargetGroupId
? getGroupPath(selectedTargetGroupId)
: "미분류"}
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="그룹 검색..." />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
<CommandItem
value="미분류"
onSelect={() => {
setSelectedTargetGroupId(null);
setIsGroupSelectOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", selectedTargetGroupId === null ? "opacity-100" : "opacity-0")} />
</CommandItem>
{getSortedGroups().map((group) => (
<CommandItem
key={group.id}
value={group.group_name}
onSelect={() => {
setSelectedTargetGroupId(group.id);
setIsGroupSelectOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", selectedTargetGroupId === group.id ? "opacity-100" : "opacity-0")} />
<span style={{ paddingLeft: `${getGroupLevel(group.id) * 12}px` }}>
{group.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 최고 관리자: 대상 회사 선택 */}
{isSuperAdmin && (
<div>
<Label className="text-sm"> </Label>
<Select value={targetCompanyCode} onValueChange={setTargetCompanyCode}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 연결된 모달 화면 (있을 경우만) */}
{linkedScreens.length > 0 && (
<div className="rounded-md border p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium flex items-center gap-1">
<LinkIcon className="h-4 w-4" />
({linkedScreens.length})
</span>
</div>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{linkedScreens.map((linked) => (
<div key={linked.screenId} className="flex items-center justify-between text-xs bg-muted rounded p-2">
<div className="flex-1 min-w-0">
<Input
value={linked.newScreenName || ""}
onChange={(e) => updateLinkedScreenName(linked.screenId, e.target.value)}
className="h-7 text-xs"
placeholder="모달 화면명"
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 ml-1 text-destructive"
onClick={() => removeLinkedScreen(linked.screenId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 화면명 일괄 수정 (접히는 옵션) */}
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
</summary>
<div className="mt-3 space-y-3 rounded-md border p-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
value={removeText}
onChange={(e) => {
setRemoveText(e.target.value);
setUseBulkRename(true);
}}
placeholder="예: 탑씰"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={addPrefix}
onChange={(e) => {
setAddPrefix(e.target.value);
setUseBulkRename(true);
}}
placeholder="예: 대진"
className="mt-1 h-8 text-xs"
/>
</div>
</div>
{(removeText || addPrefix) && getPreviewNames() && (
<div className="text-xs text-muted-foreground">
: {getPreviewNames()?.main.preview}
</div>
)}
</div>
</details>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button>
<Button onClick={handleCopy} disabled={isCopying || !screenName.trim() || !screenCode}>
{isCopying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복제"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}