Files
invyone/frontend/app/(main)/admin/userMng/rolesList/page.tsx
T
hjjeong 3280be8bd4 fix(rolesList): cross-tenant row 식별 + 메뉴 트리 스크롤 보강
cross-tenant fan-out 결과에서 회사 A·B 의 동일 objid 가 합본에 들어와
React key 중복 경고 발생 + isSelected 가 회사 구분 못 하던 문제.

- li key: role.objid → \`\${company_code}-\${objid}\` 조합으로 unique
- isSelected 비교: objid + company_code 둘 다 매칭
- selectedRole 유효성 체크(useEffect)에도 company_code 매칭 추가

추가:
- 메뉴 전체 트리구조에 자체 스크롤 (maxHeight: calc(100vh - 32rem))
- thead sticky top-0 + bg-muted (투명도 제거) → 스크롤 시 헤더 가려짐 해소

SUPER_ADMIN cross-tenant 정책 변경 없음 (모든 회사 합본 표시 유지),
React 식별만 정확해지는 fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:16 +09:00

990 lines
39 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Plus,
Edit,
Trash2,
Users,
Filter,
X,
Search,
AlertCircle,
Shield,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { RoleFormModal } from "@/components/admin/RoleFormModal";
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { companyAPI } from "@/lib/api/company";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
import { useMenu } from "@/contexts/MenuContext";
import { cn } from "@/lib/utils";
type UserItem = {
user_id: string;
user_name?: string;
dept_name?: string;
};
type MenuItem = {
objid: string;
menu_name: string;
parent_objid?: string;
sort_order?: string | number;
company_code?: string;
menu_type?: string;
};
type MenuTreeNode = MenuItem & {
children: MenuTreeNode[];
level: number;
};
type PermMap = {
create_yn: "Y" | "N";
read_yn: "Y" | "N";
update_yn: "Y" | "N";
delete_yn: "Y" | "N";
};
const EMPTY_PERM: PermMap = { create_yn: "N", read_yn: "N", update_yn: "N", delete_yn: "N" };
/**
* 권한 관리 (이미지 기반 통합 페이지)
*
* 레이아웃:
* [권한목록] | [권한있는 직원] | [추가/삭제] | [권한없는 직원]
* ──────────────────────────────────────────────────────────
* [메뉴 트리구조] | 등록/수정 | 삭제 | 조회
*
* 규칙:
* - 권한 그룹 선택 시 권한있는/없는 직원, 메뉴 권한 트리 자동 로드
* - 체크박스를 체크하면 바로 서버에 반영 (auto-save, 저장 버튼 없음)
* - 등록/수정 컬럼은 C+U 동시 토글
*/
export default function RolesPage() {
const { user: currentUser } = useAuth();
const { refreshMenus } = useMenu();
const isAdmin =
(currentUser?.company_code === "*" && currentUser?.user_type === "SUPER_ADMIN") ||
currentUser?.user_type === "COMPANY_ADMIN" ||
currentUser?.user_type === "ADMIN";
const isSuperAdmin =
currentUser?.company_code === "*" && currentUser?.user_type === "SUPER_ADMIN";
// 권한 그룹 목록
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
const [selectedRole, setSelectedRole] = useState<RoleGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [error, setError] = useState<string | null>(null);
// 필터
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>(
[],
);
const [selectedCompany, setSelectedCompany] = useState<string>("all");
const [searchText, setSearchText] = useState("");
// 모달
const [formModal, setFormModal] = useState({
isOpen: false,
editingRole: null as RoleGroup | null,
});
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
role: null as RoleGroup | null,
});
// 워크스페이스 데이터
const [members, setMembers] = useState<UserItem[]>([]);
const [nonMembers, setNonMembers] = useState<UserItem[]>([]);
const [menus, setMenus] = useState<MenuItem[]>([]);
const [permissions, setPermissions] = useState<Record<string, PermMap>>({});
// 멤버 이동 선택
const [checkedMembers, setCheckedMembers] = useState<Set<string>>(new Set());
const [checkedNonMembers, setCheckedNonMembers] = useState<Set<string>>(new Set());
// 멤버 영역 검색
const [memberSearch, setMemberSearch] = useState("");
const [nonMemberSearch, setNonMemberSearch] = useState("");
// 메뉴 트리 펼침
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
// ─────────── 회사 목록 (최고 관리자) ───────────
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
try {
const list = await companyAPI.getList();
setCompanies(list);
} catch (err) {
console.error("회사 목록 로드 오류:", err);
}
}, [isSuperAdmin]);
// ─────────── 권한 그룹 목록 ───────────
const loadRoleGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const companyFilter =
isSuperAdmin && selectedCompany !== "all"
? selectedCompany
: isSuperAdmin
? undefined
: currentUser?.company_code;
const response = await roleAPI.getList({ companyCode: companyFilter });
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((response as any)?.cross_tenant_meta ?? null);
if (response.success && response.data) {
setRoleGroups(response.data);
} else {
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error(err);
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [isSuperAdmin, selectedCompany, currentUser?.company_code]);
useEffect(() => {
if (!isAdmin) {
setIsLoading(false);
return;
}
if (isSuperAdmin) loadCompanies();
loadRoleGroups();
}, [isAdmin, isSuperAdmin, loadCompanies, loadRoleGroups]);
// 선택 유효성
useEffect(() => {
if (!selectedRole) return;
if (
!roleGroups.find(
(r) => r.objid === selectedRole.objid && r.company_code === selectedRole.company_code,
)
) {
setSelectedRole(null);
}
}, [roleGroups, selectedRole]);
// ─────────── 워크스페이스 로드 ───────────
const loadWorkspace = useCallback(async (roleId: number | string, companyCode?: string) => {
setIsLoadingWorkspace(true);
setCheckedMembers(new Set());
setCheckedNonMembers(new Set());
try {
const res = await roleAPI.getWorkspace(roleId, companyCode);
if (res.success && res.data) {
setMembers(res.data.members || []);
setNonMembers(res.data.nonMembers || []);
setMenus(res.data.menus || []);
const permMap: Record<string, PermMap> = {};
(res.data.permissions || []).forEach((p: any) => {
permMap[String(p.menu_objid)] = {
create_yn: p.create_yn === "Y" ? "Y" : "N",
read_yn: p.read_yn === "Y" ? "Y" : "N",
update_yn: p.update_yn === "Y" ? "Y" : "N",
delete_yn: p.delete_yn === "Y" ? "Y" : "N",
};
});
setPermissions(permMap);
}
} catch (err) {
console.error("워크스페이스 로드 오류:", err);
} finally {
setIsLoadingWorkspace(false);
}
}, []);
useEffect(() => {
if (selectedRole) {
loadWorkspace(selectedRole.objid, selectedRole.company_code);
} else {
setMembers([]);
setNonMembers([]);
setMenus([]);
setPermissions({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRole?.objid]);
// ─────────── 권한 그룹 목록 필터 ───────────
const filteredRoleGroups = useMemo(() => {
let list = roleGroups;
// SUPER_ADMIN cross-tenant 모드: fan-out 결과는 모든 회사 그룹 합본이라
// 회사 dropdown 선택을 client-side 에서 한 번 더 적용해야 그 회사 그룹만 보임.
if (isSuperAdmin && selectedCompany !== "all") {
list = list.filter((r) => r.company_code === selectedCompany);
}
if (!searchText.trim()) return list;
const q = searchText.toLowerCase();
return list.filter(
(r) =>
(r.auth_name || "").toLowerCase().includes(q) ||
(r.auth_code || "").toLowerCase().includes(q),
);
}, [roleGroups, searchText, isSuperAdmin, selectedCompany]);
// ─────────── 멤버 이동 (이미지: --> 삭제 / <-- 추가) ───────────
const handleAddMembers = useCallback(async () => {
if (!selectedRole || checkedNonMembers.size === 0) return;
const ids = Array.from(checkedNonMembers);
// 낙관적 UI 업데이트
const moving = nonMembers.filter((u) => checkedNonMembers.has(u.user_id));
setMembers((prev) => [...prev, ...moving]);
setNonMembers((prev) => prev.filter((u) => !checkedNonMembers.has(u.user_id)));
setCheckedNonMembers(new Set());
try {
for (const userId of ids) {
const res = await roleAPI.addSingleMember(selectedRole.objid, userId, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
loadRoleGroups();
} catch (err) {
console.error("멤버 추가 오류:", err);
alert("멤버 추가에 실패했습니다. 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
}, [selectedRole, checkedNonMembers, nonMembers, refreshMenus, loadRoleGroups, loadWorkspace]);
const handleRemoveMembers = useCallback(async () => {
if (!selectedRole || checkedMembers.size === 0) return;
const ids = Array.from(checkedMembers);
const moving = members.filter((u) => checkedMembers.has(u.user_id));
setNonMembers((prev) => [...prev, ...moving]);
setMembers((prev) => prev.filter((u) => !checkedMembers.has(u.user_id)));
setCheckedMembers(new Set());
try {
for (const userId of ids) {
const res = await roleAPI.removeSingleMember(selectedRole.objid, userId, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
loadRoleGroups();
} catch (err) {
console.error("멤버 제거 오류:", err);
alert("멤버 제거에 실패했습니다. 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
}, [selectedRole, checkedMembers, members, refreshMenus, loadRoleGroups, loadWorkspace]);
// 리스트 필터링 (멤버/비멤버 검색)
const filteredMembers = useMemo(() => {
if (!memberSearch.trim()) return members;
const q = memberSearch.toLowerCase();
return members.filter(
(u) =>
(u.user_name || "").toLowerCase().includes(q) ||
u.user_id.toLowerCase().includes(q) ||
(u.dept_name || "").toLowerCase().includes(q),
);
}, [members, memberSearch]);
const filteredNonMembers = useMemo(() => {
if (!nonMemberSearch.trim()) return nonMembers;
const q = nonMemberSearch.toLowerCase();
return nonMembers.filter(
(u) =>
(u.user_name || "").toLowerCase().includes(q) ||
u.user_id.toLowerCase().includes(q) ||
(u.dept_name || "").toLowerCase().includes(q),
);
}, [nonMembers, nonMemberSearch]);
const toggleMemberCheck = useCallback((id: string) => {
setCheckedMembers((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleNonMemberCheck = useCallback((id: string) => {
setCheckedNonMembers((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleMembersSelectAll = useCallback(
(checked: boolean) => {
setCheckedMembers(checked ? new Set(filteredMembers.map((u) => u.user_id)) : new Set());
},
[filteredMembers],
);
const handleNonMembersSelectAll = useCallback(
(checked: boolean) => {
setCheckedNonMembers(
checked ? new Set(filteredNonMembers.map((u) => u.user_id)) : new Set(),
);
},
[filteredNonMembers],
);
// ─────────── 메뉴 트리 빌드 ───────────
const menuTree = useMemo<MenuTreeNode[]>(() => {
const map = new Map<string, MenuTreeNode>();
menus.forEach((m) => {
map.set(String(m.objid), { ...m, children: [], level: 0 });
});
const roots: MenuTreeNode[] = [];
map.forEach((node) => {
const parentId =
node.parent_objid && String(node.parent_objid) !== "0"
? String(node.parent_objid)
: null;
if (parentId && map.has(parentId)) {
const parent = map.get(parentId)!;
node.level = parent.level + 1;
parent.children.push(node);
} else {
roots.push(node);
}
});
const sortTree = (nodes: MenuTreeNode[]) => {
nodes.sort((a, b) => {
const sa = Number(a.sort_order ?? 0);
const sb = Number(b.sort_order ?? 0);
if (!isNaN(sa) && !isNaN(sb) && sa !== sb) return sa - sb;
return (a.menu_name || "").localeCompare(b.menu_name || "");
});
nodes.forEach((n) => sortTree(n.children));
};
sortTree(roots);
return roots;
}, [menus]);
const toggleExpand = useCallback((id: string) => {
setExpandedMenus((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
// ─────────── 메뉴 권한 즉시 반영 (PATCH) ───────────
const applyMenuPermission = useCallback(
async (menuId: string, changes: Partial<PermMap>) => {
if (!selectedRole) return;
const prevPerm = permissions[menuId] || EMPTY_PERM;
const nextPerm: PermMap = { ...prevPerm, ...changes };
// 낙관적 UI 업데이트
setPermissions((prev) => ({ ...prev, [menuId]: nextPerm }));
try {
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, menuId, changes, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
if (res.data) {
setPermissions((prev) => ({
...prev,
[menuId]: {
create_yn: res.data.create_yn === "Y" ? "Y" : "N",
read_yn: res.data.read_yn === "Y" ? "Y" : "N",
update_yn: res.data.update_yn === "Y" ? "Y" : "N",
delete_yn: res.data.delete_yn === "Y" ? "Y" : "N",
},
}));
}
await refreshMenus();
} catch (err) {
console.error("메뉴 권한 저장 오류:", err);
setPermissions((prev) => ({ ...prev, [menuId]: prevPerm }));
alert("권한 변경에 실패했습니다.");
}
},
[selectedRole, permissions, refreshMenus],
);
// 이미지 3컬럼: 등록/수정(C+U 동시), 삭제(D), 조회(R)
const handleEditCol = useCallback(
(menuId: string, checked: boolean) => {
const v: "Y" | "N" = checked ? "Y" : "N";
applyMenuPermission(menuId, { create_yn: v, update_yn: v });
},
[applyMenuPermission],
);
const handleDeleteCol = useCallback(
(menuId: string, checked: boolean) => {
applyMenuPermission(menuId, { delete_yn: checked ? "Y" : "N" });
},
[applyMenuPermission],
);
const handleReadCol = useCallback(
(menuId: string, checked: boolean) => {
applyMenuPermission(menuId, { read_yn: checked ? "Y" : "N" });
},
[applyMenuPermission],
);
const flatMenuIds = useMemo(() => {
const ids: string[] = [];
const walk = (nodes: MenuTreeNode[]) => {
nodes.forEach((n) => {
ids.push(String(n.objid));
walk(n.children);
});
};
walk(menuTree);
return ids;
}, [menuTree]);
const handleBulkColumn = useCallback(
async (column: "edit" | "delete" | "read", checked: boolean) => {
if (!selectedRole) return;
const v: "Y" | "N" = checked ? "Y" : "N";
const change: Partial<PermMap> =
column === "edit"
? { create_yn: v, update_yn: v }
: column === "delete"
? { delete_yn: v }
: { read_yn: v };
setPermissions((prev) => {
const next = { ...prev };
flatMenuIds.forEach((id) => {
next[id] = { ...(next[id] || EMPTY_PERM), ...change };
});
return next;
});
try {
for (const id of flatMenuIds) {
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, id, change, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
} catch (err) {
console.error("일괄 변경 오류:", err);
alert("일괄 변경 실패 — 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
},
[selectedRole, flatMenuIds, refreshMenus, loadWorkspace],
);
const isColumnAllChecked = useCallback(
(column: "edit" | "delete" | "read"): boolean => {
if (flatMenuIds.length === 0) return false;
return flatMenuIds.every((id) => {
const p = permissions[id] || EMPTY_PERM;
if (column === "edit") return p.create_yn === "Y" && p.update_yn === "Y";
if (column === "delete") return p.delete_yn === "Y";
return p.read_yn === "Y";
});
},
[flatMenuIds, permissions],
);
const renderMenuRow = (node: MenuTreeNode): React.ReactNode => {
const perm = permissions[String(node.objid)] || EMPTY_PERM;
const hasChildren = node.children.length > 0;
const isExpanded = expandedMenus.has(String(node.objid));
const editChecked = perm.create_yn === "Y" && perm.update_yn === "Y";
return (
<React.Fragment key={String(node.objid)}>
<tr className="border-t hover:bg-muted/30 transition-colors">
<td className="py-2 pr-2" style={{ paddingLeft: `${node.level * 20 + 12}px` }}>
<div className="flex items-center gap-1.5">
{hasChildren ? (
<button
onClick={() => toggleExpand(String(node.objid))}
className="hover:bg-muted rounded p-0.5"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
) : (
<span className="inline-block w-4" />
)}
<span className={cn("text-sm", hasChildren && "font-semibold")}>
{node.menu_name}
</span>
</div>
</td>
<td className="py-2 text-center">
<Checkbox
checked={editChecked}
onCheckedChange={(c) => handleEditCol(String(node.objid), c === true)}
/>
</td>
<td className="py-2 text-center">
<Checkbox
checked={perm.delete_yn === "Y"}
onCheckedChange={(c) => handleDeleteCol(String(node.objid), c === true)}
/>
</td>
<td className="py-2 text-center">
<Checkbox
checked={perm.read_yn === "Y"}
onCheckedChange={(c) => handleReadCol(String(node.objid), c === true)}
/>
</td>
</tr>
{hasChildren && isExpanded && node.children.map((c) => renderMenuRow(c))}
</React.Fragment>
);
};
const handleCreateRole = () => setFormModal({ isOpen: true, editingRole: null });
const handleEditRole = (r: RoleGroup) => setFormModal({ isOpen: true, editingRole: r });
const handleDeleteRole = (r: RoleGroup) => setDeleteModal({ isOpen: true, role: r });
const handleFormClose = () => setFormModal({ isOpen: false, editingRole: null });
const handleDeleteClose = () => setDeleteModal({ isOpen: false, role: null });
const handleModalSuccess = () => loadRoleGroups();
if (!isAdmin) {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="w-full space-y-4 p-6">
<div className="space-y-1 border-b pb-3">
<h1 className="text-2xl font-bold tracking-tight"> </h1>
</div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
</Button>
</div>
</div>
<ScrollToTop />
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="w-full space-y-3 p-6">
<div className="space-y-1 border-b pb-3">
<h1 className="text-xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-xs">
/ , .
</p>
</div>
<CrossTenantBanner meta={crossTenantMeta} />
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold">{error}</p>
<button onClick={() => setError(null)} className="text-destructive">
<X className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* 상단 4분할: 권한목록 | 권한있는직원 | 이동버튼 | 권한없는직원 */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr_auto_1fr]">
{/* 권한 목록 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="flex items-center justify-between border-b p-3">
<div className="flex items-center gap-2">
<Shield className="text-primary h-4 w-4" />
<h2 className="text-sm font-semibold"> </h2>
</div>
<Button size="sm" onClick={handleCreateRole} className="h-7 gap-1 px-2 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
{isSuperAdmin && (
<div className="flex items-center gap-1">
<Filter className="text-muted-foreground h-3.5 w-3.5 flex-shrink-0" />
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((c) => (
<SelectItem key={c.company_code} value={c.company_code}>
{c.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto" style={{ maxHeight: "440px" }}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : filteredRoleGroups.length === 0 ? (
<p className="text-muted-foreground p-6 text-center text-xs">
</p>
) : (
<ul className="divide-y">
{filteredRoleGroups.map((role) => {
const isSelected =
selectedRole?.objid === role.objid &&
selectedRole?.company_code === role.company_code;
return (
<li
key={`${role.company_code ?? "_"}-${role.objid}`}
onClick={() => setSelectedRole(role)}
className={cn(
"group cursor-pointer p-2.5 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-muted/50",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm font-semibold",
isSelected && "text-primary",
)}
>
{role.auth_name}
</div>
<div className="text-muted-foreground mt-0.5 truncate font-mono text-[10px]">
{role.auth_code}
</div>
</div>
<div
className={cn(
"flex flex-shrink-0 gap-0.5 transition-opacity",
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
<button
onClick={(e) => {
e.stopPropagation();
handleEditRole(role);
}}
className="hover:bg-background rounded p-1"
title="수정"
>
<Edit className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRole(role);
}}
className="hover:bg-destructive hover:text-destructive-foreground rounded p-1"
title="삭제"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
{/* 권한있는 직원 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="border-b p-3">
<div className="mb-2 flex items-center gap-2">
<Users className="text-primary h-4 w-4" />
<h2 className="text-sm font-semibold"> ({members.length})</h2>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={
filteredMembers.length > 0 &&
checkedMembers.size === filteredMembers.length
}
onCheckedChange={(c) => handleMembersSelectAll(c === true)}
disabled={!selectedRole}
/>
<span className="text-muted-foreground text-xs"></span>
<div className="relative ml-auto flex-1 max-w-[180px]">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<Input
placeholder="검색"
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="h-7 pl-7 text-xs"
disabled={!selectedRole}
/>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto" style={{ height: "380px" }}>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-xs"> </p>
</div>
) : isLoadingWorkspace ? (
<div className="flex h-full items-center justify-center">
<div className="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : filteredMembers.length === 0 ? (
<p className="text-muted-foreground p-6 text-center text-xs">
{memberSearch ? "검색 결과 없음" : "권한있는 직원이 없습니다"}
</p>
) : (
<ul className="divide-y">
{filteredMembers.map((u) => (
<li
key={u.user_id}
onClick={() => toggleMemberCheck(u.user_id)}
className={cn(
"flex cursor-pointer items-center gap-2 p-2 transition-colors",
checkedMembers.has(u.user_id) ? "bg-muted" : "hover:bg-muted/50",
)}
>
<Checkbox
checked={checkedMembers.has(u.user_id)}
onCheckedChange={() => toggleMemberCheck(u.user_id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
{u.dept_name && (
<div className="text-muted-foreground truncate text-[10px]">
{u.dept_name}
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
{/* 이동 버튼: --> 삭제 / <-- 추가 */}
<div className="flex flex-row items-center justify-center gap-2 xl:flex-col">
<Button
variant="outline"
size="sm"
onClick={handleRemoveMembers}
disabled={!selectedRole || checkedMembers.size === 0}
className="gap-1 text-xs"
>
<span>--&gt;</span>
<span></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleAddMembers}
disabled={!selectedRole || checkedNonMembers.size === 0}
className="gap-1 text-xs"
>
<span>&lt;--</span>
<span></span>
</Button>
</div>
{/* 권한없는 직원 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="border-b p-3">
<div className="mb-2 flex items-center gap-2">
<Users className="text-muted-foreground h-4 w-4" />
<h2 className="text-sm font-semibold"> ({nonMembers.length})</h2>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={
filteredNonMembers.length > 0 &&
checkedNonMembers.size === filteredNonMembers.length
}
onCheckedChange={(c) => handleNonMembersSelectAll(c === true)}
disabled={!selectedRole}
/>
<span className="text-muted-foreground text-xs"></span>
<div className="relative ml-auto flex-1 max-w-[180px]">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<Input
placeholder="검색"
value={nonMemberSearch}
onChange={(e) => setNonMemberSearch(e.target.value)}
className="h-7 pl-7 text-xs"
disabled={!selectedRole}
/>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto" style={{ height: "380px" }}>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-xs"> </p>
</div>
) : isLoadingWorkspace ? (
<div className="flex h-full items-center justify-center">
<div className="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : filteredNonMembers.length === 0 ? (
<p className="text-muted-foreground p-6 text-center text-xs">
{nonMemberSearch ? "검색 결과 없음" : "권한없는 직원이 없습니다"}
</p>
) : (
<ul className="divide-y">
{filteredNonMembers.map((u) => (
<li
key={u.user_id}
onClick={() => toggleNonMemberCheck(u.user_id)}
className={cn(
"flex cursor-pointer items-center gap-2 p-2 transition-colors",
checkedNonMembers.has(u.user_id) ? "bg-muted" : "hover:bg-muted/50",
)}
>
<Checkbox
checked={checkedNonMembers.has(u.user_id)}
onCheckedChange={() => toggleNonMemberCheck(u.user_id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
{u.dept_name && (
<div className="text-muted-foreground truncate text-[10px]">
{u.dept_name}
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* 하단: 메뉴 권한 트리 (등록/수정, 삭제, 조회 3컬럼) */}
<div className="bg-card rounded-lg border shadow-sm">
<div className="border-b p-3">
<h2 className="text-sm font-semibold">
{" "}
{selectedRole && (
<span className="text-muted-foreground text-xs">({selectedRole.auth_name})</span>
)}
</h2>
<p className="text-muted-foreground mt-0.5 text-[11px]">
·
</p>
</div>
{!selectedRole ? (
<div className="flex h-40 items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
) : isLoadingWorkspace ? (
<div className="flex h-40 items-center justify-center">
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : menuTree.length === 0 ? (
<p className="text-muted-foreground p-12 text-center text-sm">
</p>
) : (
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 32rem)" }}>
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
<th className="py-2.5 pl-3 text-left text-xs font-semibold w-[40%]">
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<span>/</span>
<Checkbox
checked={isColumnAllChecked("edit")}
onCheckedChange={(c) => handleBulkColumn("edit", c === true)}
/>
</div>
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<span></span>
<Checkbox
checked={isColumnAllChecked("delete")}
onCheckedChange={(c) => handleBulkColumn("delete", c === true)}
/>
</div>
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<span></span>
<Checkbox
checked={isColumnAllChecked("read")}
onCheckedChange={(c) => handleBulkColumn("read", c === true)}
/>
</div>
</th>
</tr>
</thead>
<tbody>{menuTree.map((n) => renderMenuRow(n))}</tbody>
</table>
</div>
)}
</div>
<RoleFormModal
isOpen={formModal.isOpen}
onClose={handleFormClose}
onSuccess={handleModalSuccess}
editingRole={formModal.editingRole}
/>
<RoleDeleteModal
isOpen={deleteModal.isOpen}
onClose={handleDeleteClose}
onSuccess={handleModalSuccess}
role={deleteModal.role}
/>
</div>
<ScrollToTop />
</div>
);
}