1489 lines
58 KiB
TypeScript
1489 lines
58 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|