feat: 메뉴 복사 기능 - 2단계 복사 방식으로 화면 참조 매핑 문제 해결

- 문제: 화면 복사 시 참조되는 화면이 아직 복사되지 않아 screenIdMap에 매핑 정보가 없었음
- 해결: 2단계 복사 방식 도입
  1단계: 모든 screen_definitions 먼저 복사하여 screenIdMap 완성
  2단계: screen_layouts 복사하면서 완성된 screenIdMap으로 참조 업데이트
- 결과: targetScreenId가 올바르게 새 회사의 화면 ID로 매핑됨 (예: 149 → 517)
- 추가: 화면 수집 시 문자열 타입 ID도 올바르게 파싱하도록 개선
- 추가: 참조 화면 발견 및 업데이트 로그 추가

관련 파일:
- backend-node/src/services/menuCopyService.ts
- db/migrations/1003_add_source_menu_objid_to_menu_info.sql
- db/scripts/cleanup_company_11_*.sql
This commit is contained in:
kjs
2025-11-21 14:37:09 +09:00
parent bb49073bf7
commit c70998fa4f
11 changed files with 4021 additions and 16 deletions

View File

@@ -0,0 +1,262 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { menuApi, MenuCopyResult } from "@/lib/api/menu";
import { apiClient } from "@/lib/api/client";
interface MenuCopyDialogProps {
menuObjid: number | null;
menuName: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCopyComplete?: () => void;
}
interface Company {
company_code: string;
company_name: string;
}
export function MenuCopyDialog({
menuObjid,
menuName,
open,
onOpenChange,
onCopyComplete,
}: MenuCopyDialogProps) {
const [targetCompanyCode, setTargetCompanyCode] = useState("");
const [companies, setCompanies] = useState<Company[]>([]);
const [copying, setCopying] = useState(false);
const [result, setResult] = useState<MenuCopyResult | null>(null);
const [loadingCompanies, setLoadingCompanies] = useState(false);
// 회사 목록 로드
useEffect(() => {
if (open) {
loadCompanies();
// 다이얼로그가 열릴 때마다 초기화
setTargetCompanyCode("");
setResult(null);
}
}, [open]);
const loadCompanies = async () => {
try {
setLoadingCompanies(true);
const response = await apiClient.get("/admin/companies/db");
if (response.data.success && response.data.data) {
// 최고 관리자(*) 회사 제외
const filteredCompanies = response.data.data.filter(
(company: Company) => company.company_code !== "*"
);
setCompanies(filteredCompanies);
}
} catch (error) {
console.error("회사 목록 조회 실패:", error);
toast.error("회사 목록을 불러올 수 없습니다");
} finally {
setLoadingCompanies(false);
}
};
const handleCopy = async () => {
if (!menuObjid) {
toast.error("메뉴를 선택해주세요");
return;
}
if (!targetCompanyCode) {
toast.error("대상 회사를 선택해주세요");
return;
}
setCopying(true);
setResult(null);
try {
const response = await menuApi.copyMenu(menuObjid, targetCompanyCode);
if (response.success && response.data) {
setResult(response.data);
toast.success("메뉴 복사 완료!");
// 경고 메시지 표시
if (response.data.warnings && response.data.warnings.length > 0) {
response.data.warnings.forEach((warning) => {
toast.warning(warning);
});
}
// 복사 완료 콜백
if (onCopyComplete) {
onCopyComplete();
}
} else {
toast.error(response.message || "메뉴 복사 실패");
}
} catch (error: any) {
console.error("메뉴 복사 오류:", error);
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다");
} finally {
setCopying(false);
}
};
const handleClose = () => {
if (!copying) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{menuName}" .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 회사 선택 */}
{!result && (
<div>
<Label htmlFor="company" className="text-xs sm:text-sm">
*
</Label>
<Select
value={targetCompanyCode}
onValueChange={setTargetCompanyCode}
disabled={copying || loadingCompanies}
>
<SelectTrigger
id="company"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{loadingCompanies ? (
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
) : companies.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
</div>
) : (
companies.map((company) => (
<SelectItem
key={company.company_code}
value={company.company_code}
className="text-xs sm:text-sm"
>
{company.company_name} ({company.company_code})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 복사 항목 안내 */}
{!result && (
<div className="rounded-md border p-3 text-xs">
<p className="font-medium mb-2"> :</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> ( )</li>
<li> + (, )</li>
<li> (, )</li>
<li> + </li>
</ul>
<p className="mt-2 text-warning">
.
</p>
</div>
)}
{/* 복사 결과 */}
{result && (
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2">
<p className="font-medium text-success"> !</p>
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedMenus}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedScreens}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedFlows}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCategories}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCodes}</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleClose}
disabled={copying}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{result ? "닫기" : "취소"}
</Button>
{!result && (
<Button
onClick={handleCopy}
disabled={copying || !targetCompanyCode || loadingCompanies}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{copying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복사 시작"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable";
import { MenuFormModal } from "./MenuFormModal";
import { MenuCopyDialog } from "./MenuCopyDialog";
import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
@@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
type MenuType = "admin" | "user";
export const MenuManagement: React.FC = () => {
const { adminMenus, userMenus, refreshMenus } = useMenu();
const { user } = useAuth(); // 현재 사용자 정보 가져오기
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [formModalOpen, setFormModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
@@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => {
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
const { userLang } = useMultiLang({ companyCode: "*" });
// SUPER_ADMIN 여부 확인
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
// 다국어 텍스트 상태
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
const [uiTextsLoading, setUiTextsLoading] = useState(false);
@@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => {
}
};
const handleCopyMenu = (menuId: string, menuName: string) => {
setSelectedMenuId(menuId);
setSelectedMenuName(menuName);
setCopyDialogOpen(true);
};
const handleCopyComplete = async () => {
// 복사 완료 후 메뉴 목록 새로고침
await loadMenus(false);
toast.success("메뉴 복사가 완료되었습니다");
};
const handleToggleStatus = async (menuId: string) => {
try {
const response = await menuApi.toggleMenuStatus(menuId);
@@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => {
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onCopyMenu={handleCopyMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
@@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => {
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
/>
</div>
</div>
@@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<MenuCopyDialog
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
menuName={selectedMenuName}
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
onCopyComplete={handleCopyComplete}
/>
</LoadingOverlay>
);
};

View File

@@ -14,6 +14,7 @@ interface MenuTableProps {
title: string;
onAddMenu: (parentId: string, menuType: string, level: number) => void;
onEditMenu: (menuId: string) => void;
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
onToggleStatus: (menuId: string) => void;
selectedMenus: Set<string>;
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
@@ -22,6 +23,7 @@ interface MenuTableProps {
onToggleExpand: (menuId: string) => void;
// 다국어 텍스트 props 추가
uiTexts: Record<string, string>;
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
}
export const MenuTable: React.FC<MenuTableProps> = ({
@@ -29,6 +31,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
title,
onAddMenu,
onEditMenu,
onCopyMenu,
onToggleStatus,
selectedMenus,
onMenuSelectionChange,
@@ -36,6 +39,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
expandedMenus,
onToggleExpand,
uiTexts,
isSuperAdmin = false, // 기본값 false
}) => {
// 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => {
@@ -281,14 +285,26 @@ export const MenuTable: React.FC<MenuTableProps> = ({
<TableCell className="h-16">
<div className="flex flex-nowrap gap-1">
{lev === 1 && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onAddMenu(objid, menuType, lev)}
>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
</Button>
<>
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onAddMenu(objid, menuType, lev)}
>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</>
)}
{lev === 2 && (
<>
@@ -308,17 +324,39 @@ export const MenuTable: React.FC<MenuTableProps> = ({
>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</>
)}
{lev > 2 && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onEditMenu(objid)}
>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button>
<>
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onEditMenu(objid)}
>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="outline"
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
>
</Button>
)}
</>
)}
</div>
</TableCell>