3280be8bd4
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>
990 lines
39 KiB
TypeScript
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>--></span>
|
|
<span>삭제</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddMembers}
|
|
disabled={!selectedRole || checkedNonMembers.size === 0}
|
|
className="gap-1 text-xs"
|
|
>
|
|
<span><--</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>
|
|
);
|
|
}
|