873 lines
32 KiB
TypeScript
873 lines
32 KiB
TypeScript
"use client";
|
|
|
|
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 { 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;
|
|
deleteYn: string;
|
|
children?: MenuPermission[];
|
|
}
|
|
|
|
interface MenuPermissionsTableProps {
|
|
permissions: any[];
|
|
onPermissionsChange: (permissions: any[]) => void;
|
|
roleGroup: RoleGroup;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 권한 설정 테이블
|
|
*
|
|
* 기능:
|
|
* - 메뉴 트리 구조 표시
|
|
* - CRUD 권한 체크박스
|
|
* - 전체 선택/해제
|
|
* - 검색 기능
|
|
*/
|
|
export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGroup }: MenuPermissionsTableProps) {
|
|
const { user: currentUser } = useAuth();
|
|
const [searchText, setSearchText] = useState("");
|
|
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가 로드될 때까지 대기
|
|
if (!currentUser) {
|
|
console.log("⏳ [MenuPermissionsTable] currentUser 로드 대기 중...");
|
|
return;
|
|
}
|
|
|
|
const loadAllMenus = async () => {
|
|
let targetCompanyCode: string | undefined;
|
|
|
|
if (isSuperAdmin) {
|
|
// 최고 관리자: 권한그룹의 회사 코드로 조회 (해당 회사 + 공통 메뉴 모두 반환)
|
|
targetCompanyCode = roleGroup.companyCode;
|
|
} else {
|
|
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
|
targetCompanyCode = roleGroup.companyCode;
|
|
}
|
|
|
|
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
|
|
currentUser: {
|
|
userId: currentUser.userId,
|
|
companyCode: currentUser.companyCode,
|
|
userType: currentUser.userType,
|
|
},
|
|
isSuperAdmin,
|
|
roleGroupCompanyCode: roleGroup.companyCode,
|
|
companyFilter,
|
|
targetCompanyCode,
|
|
});
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await roleAPI.getAllMenus(targetCompanyCode);
|
|
|
|
console.log("✅ [MenuPermissionsTable] 전체 메뉴 로드 응답", {
|
|
success: response.success,
|
|
count: response.data?.length,
|
|
data: response.data,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
setAllMenus(response.data);
|
|
|
|
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
|
|
count: response.data.length,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ [MenuPermissionsTable] 메뉴 로드 실패", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadAllMenus();
|
|
}, [currentUser, isSuperAdmin, roleGroup.companyCode, companyFilter]);
|
|
|
|
// 메뉴 권한 상태 (로컬 상태 관리)
|
|
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
|
|
// allMenus가 로드되면 초기 권한 상태 설정 (한 번만)
|
|
useEffect(() => {
|
|
if (allMenus.length > 0 && !isInitialized) {
|
|
const permissionsMap = new Map<number, MenuPermission>();
|
|
|
|
allMenus.forEach((menu) => {
|
|
// 기존 권한이 있으면 사용, 없으면 기본값
|
|
const existingPermission = permissions.find((p) => p.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,
|
|
companyCode: menu.companyCode,
|
|
createYn: existingPermission?.createYn || "N",
|
|
readYn: existingPermission?.readYn || "N",
|
|
updateYn: existingPermission?.updateYn || "N",
|
|
deleteYn: existingPermission?.deleteYn || "N",
|
|
});
|
|
});
|
|
|
|
setMenuPermissions(permissionsMap);
|
|
setIsInitialized(true);
|
|
console.log("✅ [MenuPermissionsTable] 권한 상태 초기화", {
|
|
count: permissionsMap.size,
|
|
});
|
|
}
|
|
}, [allMenus, permissions, isInitialized]);
|
|
|
|
// menuPermissions가 변경되면 부모에 전달 (초기화 이후에만)
|
|
useEffect(() => {
|
|
if (isInitialized && menuPermissions.size > 0) {
|
|
const updatedPermissions = Array.from(menuPermissions.values());
|
|
onPermissionsChange(updatedPermissions);
|
|
}
|
|
}, [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 = 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) => {
|
|
setExpandedMenus((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(menuObjid)) {
|
|
newSet.delete(menuObjid);
|
|
} else {
|
|
newSet.add(menuObjid);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 하위 메뉴 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, applyToChildren = true) => {
|
|
setMenuPermissions((prev) => {
|
|
const newMap = new Map(prev);
|
|
const menuPerm = newMap.get(menuObjid);
|
|
|
|
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, applyToChildren });
|
|
},
|
|
[collectChildIds],
|
|
);
|
|
|
|
// 전체 선택/해제
|
|
const handleSelectAll = useCallback(
|
|
(permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => {
|
|
setMenuPermissions((prev) => {
|
|
const newMap = new Map(prev);
|
|
|
|
newMap.forEach((menuPerm, menuObjid) => {
|
|
newMap.set(menuObjid, {
|
|
...menuPerm,
|
|
[permission]: checked ? "Y" : "N",
|
|
});
|
|
});
|
|
|
|
return newMap;
|
|
});
|
|
|
|
console.log("✅ 전체 선택:", { permission, checked });
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 필터링된 메뉴 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={cn(
|
|
"hover:bg-muted/50 transition-colors",
|
|
hasAnyPermission && "bg-primary/5 border-l-2 border-l-primary",
|
|
)}
|
|
>
|
|
{/* 메뉴명 */}
|
|
<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="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>
|
|
)}
|
|
{!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-12 text-center text-sm py-2">
|
|
<div className="flex justify-center">
|
|
<Checkbox
|
|
checked={menu.createYn === "Y"}
|
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
|
|
{/* 조회(Read) */}
|
|
<TableCell className="h-12 text-center text-sm py-2">
|
|
<div className="flex justify-center">
|
|
<Checkbox
|
|
checked={menu.readYn === "Y"}
|
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
|
|
{/* 수정(Update) */}
|
|
<TableCell className="h-12 text-center text-sm py-2">
|
|
<div className="flex justify-center">
|
|
<Checkbox
|
|
checked={menu.updateYn === "Y"}
|
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
|
|
{/* 삭제(Delete) */}
|
|
<TableCell className="h-12 text-center text-sm py-2">
|
|
<div className="flex justify-center">
|
|
<Checkbox
|
|
checked={menu.deleteYn === "Y"}
|
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
|
|
{/* 자식 메뉴 렌더링 */}
|
|
{hasChildren && isExpanded && menu.children!.map((child) => renderMenuRow(child, level + 1))}
|
|
</React.Fragment>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-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-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>
|
|
)}
|
|
|
|
{/* 데스크톱 테이블 */}
|
|
{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>
|
|
)}
|
|
|
|
{/* 모바일 카드 뷰 */}
|
|
{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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|