feat: 화면 복사 기능 개선 및 버튼 모달 설정 수정
## 주요 변경사항 ### 1. 화면 복사 기능 강화 - 최고 관리자가 다른 회사로 화면 복사 가능하도록 개선 - 메인 화면과 연결된 모달 화면 자동 감지 및 일괄 복사 - 복사 시 버튼의 targetScreenId 자동 업데이트 - 일괄 이름 변경 기능 추가 (복사본 텍스트 제거) - 중복 화면명 체크 기능 추가 #### 백엔드 (screenManagementService.ts) - generateMultipleScreenCodes: 여러 화면 코드 일괄 생성 (Advisory Lock 사용) - detectLinkedModalScreens: edit 액션도 모달로 감지하도록 개선 - checkDuplicateScreenName: 중복 화면명 체크 API 추가 - copyScreenWithModals: 메인+모달 일괄 복사 및 버튼 업데이트 - updateButtonTargetScreenIds: 복사된 모달로 버튼 targetScreenId 업데이트 - updated_date 컬럼 제거 (screen_layouts 테이블에 존재하지 않음) #### 프론트엔드 (CopyScreenModal.tsx) - 회사 선택 UI 추가 (최고 관리자 전용) - 연결된 모달 화면 자동 감지 및 표시 - 일괄 이름 변경 기능 (텍스트 제거/추가) - 실시간 미리보기 - 중복 화면명 체크 ### 2. 버튼 설정 모달 화면 선택 개선 - 편집 중인 화면의 company_code 기준으로 화면 목록 조회 - 최고 관리자가 다른 회사 화면 편집 시 해당 회사의 모달 화면만 표시 - targetScreenId 문자열/숫자 타입 불일치 수정 #### 백엔드 (screenManagementController.ts) - getScreens API에 companyCode 쿼리 파라미터 추가 - 최고 관리자는 다른 회사의 화면 목록 조회 가능 #### 프론트엔드 - ButtonConfigPanel: currentScreenCompanyCode props 추가 - DetailSettingsPanel: currentScreenCompanyCode 전달 - UnifiedPropertiesPanel: currentScreenCompanyCode 전달 - ScreenDesigner: selectedScreen.companyCode 전달 - targetScreenId 비교 시 parseInt 처리 (문자열→숫자) ### 3. 카테고리 메뉴별 컬럼 분리 기능 - 메뉴별로 카테고리 컬럼을 독립적으로 관리 - 카테고리 컬럼 추가/삭제 시 메뉴 스코프 적용 ## 수정된 파일 - backend-node/src/services/screenManagementService.ts - backend-node/src/controllers/screenManagementController.ts - backend-node/src/routes/screenManagementRoutes.ts - frontend/components/screen/CopyScreenModal.tsx - frontend/components/screen/config-panels/ButtonConfigPanel.tsx - frontend/components/screen/panels/DetailSettingsPanel.tsx - frontend/components/screen/panels/UnifiedPropertiesPanel.tsx - frontend/components/screen/ScreenDesigner.tsx - frontend/lib/api/screen.ts
This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search, ChevronRight, ChevronDown, ChevronsDown, ChevronsUp, BookOpen, Shield, Eye, CheckSquare, Building2 } from "lucide-react";
|
||||
import { RoleGroup, roleAPI } from "@/lib/api/role";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MenuPermission {
|
||||
menuObjid: number;
|
||||
menuName: string;
|
||||
menuPath?: string;
|
||||
parentObjid?: number;
|
||||
companyCode?: string;
|
||||
createYn: string;
|
||||
readYn: string;
|
||||
updateYn: string;
|
||||
@@ -41,10 +45,37 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
|
||||
const [allMenus, setAllMenus] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 최고 관리자 전용: 회사별 필터
|
||||
const [companyFilter, setCompanyFilter] = useState<string>("all"); // 초기값: 모든 메뉴
|
||||
const [companyInfo, setCompanyInfo] = useState<{ code: string; name: string } | null>(null);
|
||||
|
||||
// 메뉴 타입 필터 (관리자/사용자)
|
||||
const [menuTypeFilter, setMenuTypeFilter] = useState<string>("all");
|
||||
|
||||
// 최고 관리자 여부 확인
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 회사 정보 가져오기
|
||||
useEffect(() => {
|
||||
const fetchCompanyInfo = async () => {
|
||||
if (roleGroup.companyCode && roleGroup.companyCode !== "*") {
|
||||
try {
|
||||
const { companyAPI } = await import("@/lib/api/company");
|
||||
const company = await companyAPI.getInfo(roleGroup.companyCode);
|
||||
setCompanyInfo({
|
||||
code: company.companyCode,
|
||||
name: company.companyName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("회사 정보 로드 실패", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCompanyInfo();
|
||||
}, [roleGroup.companyCode]);
|
||||
|
||||
// 전체 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
// currentUser가 로드될 때까지 대기
|
||||
@@ -54,9 +85,15 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
}
|
||||
|
||||
const loadAllMenus = async () => {
|
||||
// 최고 관리자: companyCode 없이 모든 메뉴 조회
|
||||
// 회사 관리자: 자기 회사 메뉴만 조회
|
||||
const targetCompanyCode = isSuperAdmin ? undefined : roleGroup.companyCode;
|
||||
let targetCompanyCode: string | undefined;
|
||||
|
||||
if (isSuperAdmin) {
|
||||
// 최고 관리자: 권한그룹의 회사 코드로 조회 (해당 회사 + 공통 메뉴 모두 반환)
|
||||
targetCompanyCode = roleGroup.companyCode;
|
||||
} else {
|
||||
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||
targetCompanyCode = roleGroup.companyCode;
|
||||
}
|
||||
|
||||
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
|
||||
currentUser: {
|
||||
@@ -66,7 +103,8 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
},
|
||||
isSuperAdmin,
|
||||
roleGroupCompanyCode: roleGroup.companyCode,
|
||||
targetCompanyCode: targetCompanyCode || "전체",
|
||||
companyFilter,
|
||||
targetCompanyCode,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -81,6 +119,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
|
||||
if (response.success && response.data) {
|
||||
setAllMenus(response.data);
|
||||
|
||||
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
|
||||
count: response.data.length,
|
||||
});
|
||||
@@ -93,7 +132,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
};
|
||||
|
||||
loadAllMenus();
|
||||
}, [currentUser, isSuperAdmin, roleGroup.companyCode]);
|
||||
}, [currentUser, isSuperAdmin, roleGroup.companyCode, companyFilter]);
|
||||
|
||||
// 메뉴 권한 상태 (로컬 상태 관리)
|
||||
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
|
||||
@@ -108,11 +147,16 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
// 기존 권한이 있으면 사용, 없으면 기본값
|
||||
const existingPermission = permissions.find((p) => p.menuObjid === menu.objid);
|
||||
|
||||
permissionsMap.set(menu.objid, {
|
||||
menuObjid: menu.objid,
|
||||
// objid를 숫자로 변환하여 저장
|
||||
const menuObjid = Number(menu.objid);
|
||||
const parentObjid = menu.parentObjid ? Number(menu.parentObjid) : 0;
|
||||
|
||||
permissionsMap.set(menuObjid, {
|
||||
menuObjid,
|
||||
menuName: menu.menuName,
|
||||
menuPath: menu.menuUrl,
|
||||
parentObjid: menu.parentObjid,
|
||||
parentObjid,
|
||||
companyCode: menu.companyCode,
|
||||
createYn: existingPermission?.createYn || "N",
|
||||
readYn: existingPermission?.readYn || "N",
|
||||
updateYn: existingPermission?.updateYn || "N",
|
||||
@@ -136,8 +180,159 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
}
|
||||
}, [menuPermissions, isInitialized, onPermissionsChange]);
|
||||
|
||||
// 메뉴 트리 구조 생성 및 필터링
|
||||
const buildMenuTree = useCallback((menus: MenuPermission[]): MenuPermission[] => {
|
||||
const menuMap = new Map<number, MenuPermission>();
|
||||
const rootMenus: MenuPermission[] = [];
|
||||
|
||||
// 먼저 모든 메뉴를 Map에 저장
|
||||
menus.forEach((menu) => {
|
||||
menuMap.set(menu.menuObjid, { ...menu, children: [] });
|
||||
});
|
||||
|
||||
// 부모-자식 관계 구성
|
||||
menuMap.forEach((menu) => {
|
||||
// parentObjid를 숫자로 변환하여 비교
|
||||
const parentId = Number(menu.parentObjid);
|
||||
|
||||
if (!menu.parentObjid || parentId === 0 || isNaN(parentId)) {
|
||||
rootMenus.push(menu);
|
||||
} else {
|
||||
const parent = menuMap.get(parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(menu);
|
||||
} else {
|
||||
// 부모를 찾을 수 없으면 최상위로 처리
|
||||
console.warn("⚠️ 부모 메뉴를 찾을 수 없음", {
|
||||
menuObjid: menu.menuObjid,
|
||||
menuName: menu.menuName,
|
||||
parentObjid: menu.parentObjid,
|
||||
parentId,
|
||||
});
|
||||
rootMenus.push(menu);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rootMenus;
|
||||
}, []);
|
||||
|
||||
// 검색 필터링
|
||||
const filterMenus = useCallback(
|
||||
(menus: MenuPermission[], searchText: string): MenuPermission[] => {
|
||||
if (!searchText.trim()) {
|
||||
return menus;
|
||||
}
|
||||
|
||||
const search = searchText.toLowerCase();
|
||||
const filtered: MenuPermission[] = [];
|
||||
|
||||
const matchesSearch = (menu: MenuPermission): boolean => {
|
||||
return menu.menuName.toLowerCase().includes(search);
|
||||
};
|
||||
|
||||
const filterRecursive = (menu: MenuPermission): MenuPermission | null => {
|
||||
const matches = matchesSearch(menu);
|
||||
const filteredChildren = (menu.children || [])
|
||||
.map((child) => filterRecursive(child))
|
||||
.filter((child): child is MenuPermission => child !== null);
|
||||
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
return {
|
||||
...menu,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
menus.forEach((menu) => {
|
||||
const result = filterRecursive(menu);
|
||||
if (result) {
|
||||
filtered.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 메뉴 트리 구조 생성 (menuPermissions에서)
|
||||
const menuTree: MenuPermission[] = Array.from(menuPermissions.values());
|
||||
const menuTree = useMemo(() => {
|
||||
let allMenusArray = Array.from(menuPermissions.values());
|
||||
|
||||
// 회사 필터링 (최고 관리자 전용)
|
||||
if (isSuperAdmin && companyFilter !== "all") {
|
||||
// 특정 회사 또는 공통 메뉴만 선택한 경우
|
||||
allMenusArray = allMenusArray.filter(menu => menu.companyCode === companyFilter);
|
||||
}
|
||||
|
||||
// 메뉴 타입 필터링 (관리자/사용자)
|
||||
if (menuTypeFilter !== "all") {
|
||||
const targetMenuType = menuTypeFilter; // "0" (관리자) 또는 "1" (사용자)
|
||||
allMenusArray = allMenusArray.filter(menu => {
|
||||
// 백엔드에서 받은 menuType을 비교 (allMenus에서 가져와야 함)
|
||||
const originalMenu = allMenus.find(m => Number(m.objid) === menu.menuObjid);
|
||||
return originalMenu && String(originalMenu.menuType) === targetMenuType;
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🌲 [MenuTree] 트리 생성 시작", {
|
||||
allMenusCount: allMenusArray.length,
|
||||
searchText,
|
||||
menuTypeFilter,
|
||||
sampleMenu: allMenusArray[0],
|
||||
});
|
||||
|
||||
const tree = buildMenuTree(allMenusArray);
|
||||
console.log("🌲 [MenuTree] 빌드 완료", {
|
||||
rootMenusCount: tree.length,
|
||||
rootMenus: tree.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName, parentObjid: m.parentObjid })),
|
||||
});
|
||||
|
||||
const filtered = filterMenus(tree, searchText);
|
||||
console.log("🌲 [MenuTree] 필터링 완료", {
|
||||
filteredCount: filtered.length,
|
||||
filtered: filtered.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName })),
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [menuPermissions, searchText, menuTypeFilter, allMenus, isSuperAdmin, companyFilter, buildMenuTree, filterMenus]);
|
||||
|
||||
// 통계 계산
|
||||
const statistics = useMemo(() => {
|
||||
let totalMenus = 0;
|
||||
let menusWithPermissions = 0;
|
||||
|
||||
menuPermissions.forEach((menu) => {
|
||||
totalMenus++;
|
||||
if (menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y") {
|
||||
menusWithPermissions++;
|
||||
}
|
||||
});
|
||||
|
||||
return { totalMenus, menusWithPermissions };
|
||||
}, [menuPermissions]);
|
||||
|
||||
// 전체 펼치기/접기
|
||||
const expandAll = useCallback(() => {
|
||||
const allIds = new Set<number>();
|
||||
const collectIds = (menu: MenuPermission) => {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
allIds.add(menu.menuObjid);
|
||||
menu.children.forEach(collectIds);
|
||||
}
|
||||
};
|
||||
menuTree.forEach(collectIds);
|
||||
setExpandedMenus(allIds);
|
||||
}, [menuTree]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedMenus(new Set());
|
||||
}, []);
|
||||
|
||||
// 메뉴 펼치기/접기 토글
|
||||
const toggleExpand = useCallback((menuObjid: number) => {
|
||||
@@ -152,26 +347,52 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 권한 변경 핸들러
|
||||
// 하위 메뉴 ID 수집
|
||||
const collectChildIds = useCallback((menu: MenuPermission): number[] => {
|
||||
const ids = [menu.menuObjid];
|
||||
if (menu.children) {
|
||||
menu.children.forEach((child) => {
|
||||
ids.push(...collectChildIds(child));
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
}, []);
|
||||
|
||||
// 권한 변경 핸들러 (하위 메뉴 포함)
|
||||
const handlePermissionChange = useCallback(
|
||||
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => {
|
||||
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean, applyToChildren = true) => {
|
||||
setMenuPermissions((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const menuPerm = newMap.get(menuObjid);
|
||||
|
||||
if (menuPerm) {
|
||||
newMap.set(menuObjid, {
|
||||
...menuPerm,
|
||||
[permission]: checked ? "Y" : "N",
|
||||
if (!menuPerm) return newMap;
|
||||
|
||||
// 현재 메뉴 권한 변경
|
||||
newMap.set(menuObjid, {
|
||||
...menuPerm,
|
||||
[permission]: checked ? "Y" : "N",
|
||||
});
|
||||
|
||||
// 하위 메뉴에도 적용 (옵션)
|
||||
if (applyToChildren && menuPerm.children && menuPerm.children.length > 0) {
|
||||
const childIds = collectChildIds(menuPerm);
|
||||
childIds.forEach((childId) => {
|
||||
const childPerm = newMap.get(childId);
|
||||
if (childPerm) {
|
||||
newMap.set(childId, {
|
||||
...childPerm,
|
||||
[permission]: checked ? "Y" : "N",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
|
||||
console.log("✅ 권한 변경:", { menuObjid, permission, checked });
|
||||
console.log("✅ 권한 변경:", { menuObjid, permission, checked, applyToChildren });
|
||||
},
|
||||
[],
|
||||
[collectChildIds],
|
||||
);
|
||||
|
||||
// 전체 선택/해제
|
||||
@@ -195,29 +416,142 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
[],
|
||||
);
|
||||
|
||||
// 필터링된 메뉴 ID 수집 (재귀)
|
||||
const collectFilteredMenuIds = useCallback((menus: MenuPermission[]): Set<number> => {
|
||||
const ids = new Set<number>();
|
||||
|
||||
const traverse = (menu: MenuPermission) => {
|
||||
ids.add(menu.menuObjid);
|
||||
if (menu.children) {
|
||||
menu.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
menus.forEach(traverse);
|
||||
return ids;
|
||||
}, []);
|
||||
|
||||
// 빠른 권한 설정 프리셋 (필터링된 메뉴만)
|
||||
const applyPreset = useCallback((preset: "read-only" | "full" | "none") => {
|
||||
// 현재 필터링된 메뉴 ID 수집
|
||||
const filteredIds = collectFilteredMenuIds(menuTree);
|
||||
|
||||
console.log("🎯 프리셋 적용 대상:", {
|
||||
preset,
|
||||
filteredCount: filteredIds.size,
|
||||
filteredIds: Array.from(filteredIds),
|
||||
});
|
||||
|
||||
setMenuPermissions((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
// 필터링된 메뉴만 권한 변경
|
||||
filteredIds.forEach((menuObjid) => {
|
||||
const menuPerm = newMap.get(menuObjid);
|
||||
if (!menuPerm) return;
|
||||
|
||||
switch (preset) {
|
||||
case "read-only":
|
||||
newMap.set(menuObjid, {
|
||||
...menuPerm,
|
||||
createYn: "N",
|
||||
readYn: "Y",
|
||||
updateYn: "N",
|
||||
deleteYn: "N",
|
||||
});
|
||||
break;
|
||||
case "full":
|
||||
newMap.set(menuObjid, {
|
||||
...menuPerm,
|
||||
createYn: "Y",
|
||||
readYn: "Y",
|
||||
updateYn: "Y",
|
||||
deleteYn: "Y",
|
||||
});
|
||||
break;
|
||||
case "none":
|
||||
newMap.set(menuObjid, {
|
||||
...menuPerm,
|
||||
createYn: "N",
|
||||
readYn: "N",
|
||||
updateYn: "N",
|
||||
deleteYn: "N",
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return newMap;
|
||||
});
|
||||
|
||||
console.log("✅ 프리셋 적용 완료:", { preset, count: filteredIds.size });
|
||||
}, [menuTree, collectFilteredMenuIds]);
|
||||
|
||||
// 회사 코드에서 회사명 가져오기
|
||||
const getCompanyLabel = useCallback((code: string) => {
|
||||
if (code === "*") {
|
||||
return "공통";
|
||||
}
|
||||
// 현재 권한그룹의 회사 코드와 일치하면 회사명 표시
|
||||
if (companyInfo && code === companyInfo.code) {
|
||||
return companyInfo.name;
|
||||
}
|
||||
// 그 외에는 회사 코드 표시
|
||||
return code;
|
||||
}, [companyInfo]);
|
||||
|
||||
// 메뉴 행 렌더링
|
||||
const renderMenuRow = (menu: MenuPermission, level: number = 0) => {
|
||||
const hasChildren = menu.children && menu.children.length > 0;
|
||||
const isExpanded = expandedMenus.has(menu.menuObjid);
|
||||
const paddingLeft = level * 24;
|
||||
const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
|
||||
|
||||
return (
|
||||
<React.Fragment key={menu.menuObjid}>
|
||||
<TableRow className="hover:bg-muted/50 transition-colors">
|
||||
<TableRow
|
||||
className={cn(
|
||||
"hover:bg-muted/50 transition-colors",
|
||||
hasAnyPermission && "bg-primary/5 border-l-2 border-l-primary",
|
||||
)}
|
||||
>
|
||||
{/* 메뉴명 */}
|
||||
<TableCell className="h-16 text-sm" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
|
||||
<TableCell className="h-12 text-sm py-2" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChildren && (
|
||||
<button onClick={() => toggleExpand(menu.menuObjid)} className="transition-transform">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<button
|
||||
onClick={() => toggleExpand(menu.menuObjid)}
|
||||
className="p-1 hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className={`text-sm ${hasChildren ? "font-semibold" : "font-medium"}`}>{menu.menuName}</span>
|
||||
{!hasChildren && <div className="w-6" />}
|
||||
<span className={cn("text-sm", hasChildren ? "font-semibold" : "font-medium", hasAnyPermission && "text-primary")}>
|
||||
{menu.menuName}
|
||||
</span>
|
||||
{menu.companyCode && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 text-[10px] font-medium rounded",
|
||||
menu.companyCode === "*"
|
||||
? "bg-primary/10 text-primary border border-primary/20"
|
||||
: "bg-muted text-muted-foreground border border-border"
|
||||
)}
|
||||
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
|
||||
>
|
||||
{getCompanyLabel(menu.companyCode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 생성(Create) */}
|
||||
<TableCell className="h-16 text-center text-sm">
|
||||
<TableCell className="h-12 text-center text-sm py-2">
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={menu.createYn === "Y"}
|
||||
@@ -227,7 +561,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
</TableCell>
|
||||
|
||||
{/* 조회(Read) */}
|
||||
<TableCell className="h-16 text-center text-sm">
|
||||
<TableCell className="h-12 text-center text-sm py-2">
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={menu.readYn === "Y"}
|
||||
@@ -237,7 +571,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
</TableCell>
|
||||
|
||||
{/* 수정(Update) */}
|
||||
<TableCell className="h-16 text-center text-sm">
|
||||
<TableCell className="h-12 text-center text-sm py-2">
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={menu.updateYn === "Y"}
|
||||
@@ -247,7 +581,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
</TableCell>
|
||||
|
||||
{/* 삭제(Delete) */}
|
||||
<TableCell className="h-16 text-center text-sm">
|
||||
<TableCell className="h-12 text-center text-sm py-2">
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={menu.deleteYn === "Y"}
|
||||
@@ -265,105 +599,274 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 필터 영역 (통합) */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* 왼쪽: 필터들 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">회사:</span>
|
||||
<Select value={companyFilter} onValueChange={setCompanyFilter}>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 전체 메뉴 */}
|
||||
<SelectItem value="all">전체 메뉴</SelectItem>
|
||||
|
||||
{/* 권한그룹의 회사 */}
|
||||
{roleGroup.companyCode && roleGroup.companyCode !== "*" && companyInfo && (
|
||||
<SelectItem value={roleGroup.companyCode}>
|
||||
{companyInfo.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
{/* 공통 메뉴 */}
|
||||
<SelectItem value="*">공통 메뉴</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="hidden h-6 w-px bg-border sm:block"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 메뉴 타입 필터 (모든 사용자) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">타입:</span>
|
||||
<Select value={menuTypeFilter} onValueChange={setMenuTypeFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px] text-xs">
|
||||
<SelectValue placeholder="메뉴 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 메뉴</SelectItem>
|
||||
<SelectItem value="0">관리자</SelectItem>
|
||||
<SelectItem value="1">사용자</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 메뉴 개수 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
({statistics.totalMenus}개 메뉴)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 및 빠른 액션 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
전체: <span className="font-semibold text-foreground">{statistics.totalMenus}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span className="text-muted-foreground">
|
||||
권한 있음: <span className="font-semibold text-primary">{statistics.menusWithPermissions}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("read-only")} className="gap-1.5">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
조회만
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("full")} className="gap-1.5">
|
||||
<CheckSquare className="h-3.5 w-3.5" />
|
||||
전체 권한
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("none")} className="gap-1.5">
|
||||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 트리 제어 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-[400px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="메뉴 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-9 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={expandAll} className="gap-1.5">
|
||||
<ChevronsDown className="h-3.5 w-3.5" />
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={collapseAll} className="gap-1.5">
|
||||
<ChevronsUp className="h-3.5 w-3.5" />
|
||||
전체 접기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
{searchText && menuTree.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
"{searchText}"에 대한 검색 결과가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searchText && menuTree.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BookOpen className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
메뉴 데이터가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-[40%] text-sm font-semibold">메뉴</TableHead>
|
||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span>생성 (C)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span>조회 (R)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span>수정 (U)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span>삭제 (D)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{menuTree.map((menu) => renderMenuRow(menu))}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{menuTree.length > 0 && (
|
||||
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="h-11 w-[40%] text-xs font-semibold">메뉴</TableHead>
|
||||
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span>생성 (C)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span>조회 (R)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span>수정 (U)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-amber-600"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span>삭제 (D)</span>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-red-600"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">메뉴 권한 로딩 중...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
menuTree.map((menu) => renderMenuRow(menu))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모바일 카드 뷰 */}
|
||||
<div className="grid gap-4 lg:hidden">
|
||||
{menuTree.map((menu) => (
|
||||
<div key={menu.menuObjid} className="bg-card p-4 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold">{menu.menuName}</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성 (C)</span>
|
||||
<Checkbox
|
||||
checked={menu.createYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
||||
/>
|
||||
{menuTree.length > 0 && (
|
||||
<div className="grid gap-3 lg:hidden">
|
||||
{menuTree.map((menu) => {
|
||||
const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={menu.menuObjid}
|
||||
className={cn(
|
||||
"bg-card rounded-lg border p-4 shadow-sm",
|
||||
hasAnyPermission && "border-l-4 border-l-primary",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className={cn("text-sm font-semibold", hasAnyPermission && "text-primary")}>
|
||||
{menu.menuName}
|
||||
</h3>
|
||||
{menu.companyCode && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 text-[10px] font-medium rounded",
|
||||
menu.companyCode === "*"
|
||||
? "bg-primary/10 text-primary border border-primary/20"
|
||||
: "bg-muted text-muted-foreground border border-border"
|
||||
)}
|
||||
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
|
||||
>
|
||||
{getCompanyLabel(menu.companyCode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성 (C)</span>
|
||||
<Checkbox
|
||||
checked={menu.createYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">조회 (R)</span>
|
||||
<Checkbox
|
||||
checked={menu.readYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">수정 (U)</span>
|
||||
<Checkbox
|
||||
checked={menu.updateYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-amber-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">삭제 (D)</span>
|
||||
<Checkbox
|
||||
checked={menu.deleteYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
||||
className="data-[state=checked]:bg-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">조회 (R)</span>
|
||||
<Checkbox
|
||||
checked={menu.readYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">수정 (U)</span>
|
||||
<Checkbox
|
||||
checked={menu.updateYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">삭제 (D)</span>
|
||||
<Checkbox
|
||||
checked={menu.deleteYn === "Y"}
|
||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user