ERP-node/frontend/components/admin/MenuPermissionsTable.tsx

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>
);
}