129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환: - text-cyan-400 (45건) → text-label - text-green-400/500 (51건) → text-label + Badge intent="success" - text-red-400/500 (31건) → text-heading + Badge intent="critical" - text-blue-400 (33건) → text-label + Badge intent="info" - text-purple-400 (20건) → text-heading - text-yellow/orange/amber (32건) → text-heading + Badge intent="warning" raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등) 미사용 import 정리 (SaveButton/DataTable/lucide 아이콘) 대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs, AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement, PermissionsPanel, SystemConfig 검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
526 lines
22 KiB
TypeScript
526 lines
22 KiB
TypeScript
import { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
|
||
import {
|
||
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
|
||
ExternalLink, Layers,
|
||
} from 'lucide-react';
|
||
import { Card, CardContent } from '@shared/components/ui/card';
|
||
import { Badge } from '@shared/components/ui/badge';
|
||
import { Button } from '@shared/components/ui/button';
|
||
import {
|
||
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
|
||
type RoleWithPermissions, type PermTreeNode, type PermEntry,
|
||
} from '@/services/adminApi';
|
||
import {
|
||
resolveSingleRoleEffective, OPERATIONS,
|
||
type Operation, type TreeNode, type PermRow,
|
||
} from '@/lib/permission/permResolver';
|
||
import { useAuth } from '@/app/auth/AuthContext';
|
||
import { useSettingsStore } from '@stores/settingsStore';
|
||
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
||
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
||
|
||
/**
|
||
* 트리 기반 권한 관리 패널 (wing 패턴).
|
||
*
|
||
* - 좌측: 역할 목록
|
||
* - 우측: 권한 트리 + R/C/U/D/E 체크박스 매트릭스
|
||
*
|
||
* 셀 상태 (4가지):
|
||
* • explicit-granted (✓ 파랑) - 명시적 Y
|
||
* • explicit-denied (— 빨강) - 명시적 N
|
||
* • inherited-granted (✓ 연파랑) - 부모로부터 상속
|
||
* • forced-denied (회색) - 부모 READcandid가 N → 강제 거부
|
||
*
|
||
* 클릭 사이클: explicit-granted → explicit-denied → 미지정(상속) → ...
|
||
*
|
||
* 권한:
|
||
* - admin:role-management (READ): 역할 목록 조회
|
||
* - admin:role-management (CREATE/DELETE): 역할 생성/삭제
|
||
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
|
||
*/
|
||
|
||
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
|
||
|
||
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
||
|
||
export function PermissionsPanel() {
|
||
const { hasPermission } = useAuth();
|
||
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
|
||
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
|
||
const canUpdatePerm = hasPermission('admin:permission-management', 'UPDATE');
|
||
|
||
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||
const [tree, setTree] = useState<PermTreeNode[]>([]);
|
||
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
const [draftPerms, setDraftPerms] = useState<DraftPerms>(new Map());
|
||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [newRoleCd, setNewRoleCd] = useState('');
|
||
const [newRoleNm, setNewRoleNm] = useState('');
|
||
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
|
||
const [editingColor, setEditingColor] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true); setError('');
|
||
try {
|
||
const [r, t] = await Promise.all([fetchRoles(), fetchPermTree()]);
|
||
setRoles(r);
|
||
setTree(t);
|
||
if (r.length > 0 && selectedRoleSn === null) {
|
||
setSelectedRoleSn(r[0].roleSn);
|
||
}
|
||
// Level 0 노드 자동 펼침
|
||
setExpanded(new Set(t.filter((n) => n.rsrcLevel === 0).map((n) => n.rsrcCd)));
|
||
} catch (e: unknown) {
|
||
setError(e instanceof Error ? e.message : 'unknown');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedRoleSn]);
|
||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
useEffect(() => { load(); /* 최초 1회만 로드 */ }, []);
|
||
|
||
// 역할 선택 시 draft 초기화
|
||
const selectedRole = useMemo(
|
||
() => roles.find((r) => r.roleSn === selectedRoleSn) ?? null,
|
||
[roles, selectedRoleSn],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!selectedRole) return;
|
||
const m: DraftPerms = new Map();
|
||
for (const p of selectedRole.permissions) {
|
||
m.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||
}
|
||
setDraftPerms(m);
|
||
}, [selectedRole]);
|
||
|
||
// 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
|
||
const childrenMap = useMemo(() => {
|
||
const m = new Map<string | null, PermTreeNode[]>();
|
||
for (const n of tree) {
|
||
if (n.useYn !== 'Y') continue;
|
||
const arr = m.get(n.parentCd) ?? [];
|
||
arr.push(n);
|
||
m.set(n.parentCd, arr);
|
||
}
|
||
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
|
||
for (const [, arr] of m.entries()) {
|
||
arr.sort((a, b) => {
|
||
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
|
||
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
|
||
return aSort - bSort;
|
||
});
|
||
}
|
||
return m;
|
||
}, [tree]);
|
||
|
||
// draft 기반 effective 권한 해석 (PermResolver TS 미러)
|
||
const effective = useMemo(() => {
|
||
const treeNodes: TreeNode[] = tree.map((n) => ({
|
||
rsrcCd: n.rsrcCd, parentCd: n.parentCd, rsrcNm: n.rsrcNm,
|
||
rsrcLevel: n.rsrcLevel, sortOrd: n.sortOrd, useYn: n.useYn,
|
||
}));
|
||
const perms: PermRow[] = [];
|
||
draftPerms.forEach((v, k) => {
|
||
if (v === 'Y' || v === 'N') {
|
||
const [rsrcCd, operCd] = k.split('::');
|
||
perms.push({ rsrcCd, operCd, grantYn: v });
|
||
}
|
||
});
|
||
return resolveSingleRoleEffective(treeNodes, perms);
|
||
}, [tree, draftPerms]);
|
||
|
||
const cellState = useCallback((rsrcCd: string, operCd: Operation, parentCd: string | null) => {
|
||
const key = makeKey(rsrcCd, operCd);
|
||
const explicit = draftPerms.get(key);
|
||
|
||
// 1) 부모 노드의 effective READ가 거부되면 자식의 모든 작업 강제 거부
|
||
let parentReadDenied = false;
|
||
if (parentCd) {
|
||
const parentEff = effective.get(parentCd);
|
||
parentReadDenied = !parentEff || !parentEff.has('READ');
|
||
}
|
||
if (parentReadDenied) return 'forced-denied';
|
||
|
||
// 2) 같은 노드의 READ가 effective로 거부되면 C/U/D/E도 강제 거부
|
||
// (READ가 안 되면 그 페이지/리소스 자체에 접근 못 하므로 다른 작업 권한도 의미 없음)
|
||
if (operCd !== 'READ') {
|
||
const ownEff = effective.get(rsrcCd);
|
||
const ownReadGranted = ownEff?.has('READ') ?? false;
|
||
if (!ownReadGranted) return 'forced-denied';
|
||
}
|
||
|
||
if (explicit === 'Y') return 'explicit-granted';
|
||
if (explicit === 'N') return 'explicit-denied';
|
||
|
||
// 상속 체크
|
||
const eff = effective.get(rsrcCd);
|
||
if (eff?.has(operCd)) return 'inherited-granted';
|
||
return 'inherited-denied';
|
||
}, [draftPerms, effective]);
|
||
|
||
const isDirty = useMemo(() => {
|
||
if (!selectedRole) return false;
|
||
const original = new Map<string, 'Y' | 'N'>();
|
||
for (const p of selectedRole.permissions) {
|
||
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||
}
|
||
if (original.size !== Array.from(draftPerms.values()).filter((v) => v !== null).length) {
|
||
return true;
|
||
}
|
||
for (const [k, v] of draftPerms) {
|
||
if (v === null) {
|
||
if (original.has(k)) return true;
|
||
} else if (original.get(k) !== v) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}, [selectedRole, draftPerms]);
|
||
|
||
// 셀 클릭: explicit Y → explicit N → 미지정(상속) → ...
|
||
const handleCellClick = (rsrcCd: string, operCd: Operation) => {
|
||
if (!canUpdatePerm) return;
|
||
const key = makeKey(rsrcCd, operCd);
|
||
setDraftPerms((prev) => {
|
||
const next = new Map(prev);
|
||
const cur = next.get(key);
|
||
if (cur === 'Y') next.set(key, 'N');
|
||
else if (cur === 'N') next.set(key, null); // 명시 권한 제거
|
||
else next.set(key, 'Y');
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!selectedRole || !canUpdatePerm) return;
|
||
setSaving(true); setError('');
|
||
try {
|
||
const original = new Map<string, 'Y' | 'N'>();
|
||
for (const p of selectedRole.permissions) {
|
||
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
|
||
}
|
||
|
||
// 변경된 셀만 수집
|
||
const changes: PermEntry[] = [];
|
||
const allKeys = new Set([...original.keys(), ...draftPerms.keys()]);
|
||
for (const k of allKeys) {
|
||
const [rsrcCd, operCd] = k.split('::');
|
||
const oldVal = original.get(k);
|
||
const newVal = draftPerms.get(k);
|
||
if (newVal === null && oldVal !== undefined) {
|
||
changes.push({ rsrcCd, operCd, grantYn: null });
|
||
} else if ((newVal === 'Y' || newVal === 'N') && newVal !== oldVal) {
|
||
changes.push({ rsrcCd, operCd, grantYn: newVal });
|
||
}
|
||
}
|
||
|
||
if (changes.length === 0) {
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
|
||
await updateRolePermissions(selectedRole.roleSn, changes);
|
||
await load(); // 새로 가져와서 동기화
|
||
alert(`권한 ${changes.length}건 갱신되었습니다.`);
|
||
} catch (e: unknown) {
|
||
setError(e instanceof Error ? e.message : 'unknown');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleCreateRole = async () => {
|
||
if (!newRoleCd || !newRoleNm) return;
|
||
try {
|
||
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
|
||
setShowCreate(false);
|
||
setNewRoleCd(''); setNewRoleNm('');
|
||
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
|
||
await load();
|
||
} catch (e: unknown) {
|
||
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||
}
|
||
};
|
||
|
||
const handleUpdateColor = async (roleSn: number, hex: string) => {
|
||
try {
|
||
await apiUpdateRole(roleSn, { colorHex: hex });
|
||
await load();
|
||
setEditingColor(null);
|
||
} catch (e: unknown) {
|
||
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||
}
|
||
};
|
||
|
||
const handleDeleteRole = async () => {
|
||
if (!selectedRole) return;
|
||
if (selectedRole.builtinYn === 'Y') {
|
||
alert('내장 역할은 삭제할 수 없습니다.');
|
||
return;
|
||
}
|
||
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
|
||
try {
|
||
await deleteRole(selectedRole.roleSn);
|
||
setSelectedRoleSn(null);
|
||
await load();
|
||
} catch (e: unknown) {
|
||
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||
}
|
||
};
|
||
|
||
const toggleExpand = (rsrcCd: string) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(rsrcCd)) next.delete(rsrcCd); else next.add(rsrcCd);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const renderTreeRow = (node: PermTreeNode, depth: number): React.ReactNode => {
|
||
const children = childrenMap.get(node.rsrcCd) ?? [];
|
||
const hasChildren = children.length > 0;
|
||
const isExpanded = expanded.has(node.rsrcCd);
|
||
|
||
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
|
||
const lang = useSettingsStore.getState().language;
|
||
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
|
||
|
||
return (
|
||
<Fragment key={node.rsrcCd}>
|
||
<tr className="border-t border-border hover:bg-surface-overlay/30">
|
||
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
|
||
<div className="flex items-center gap-1">
|
||
{hasChildren ? (
|
||
<button type="button" onClick={() => toggleExpand(node.rsrcCd)}
|
||
className="p-0.5 text-hint hover:text-heading">
|
||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||
</button>
|
||
) : <span className="w-4" />}
|
||
{/* 페이지/패널 구분 아이콘 */}
|
||
{node.urlPath
|
||
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
|
||
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
|
||
}
|
||
<span className="text-[11px] text-heading font-medium">{displayName}</span>
|
||
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
|
||
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
|
||
</div>
|
||
</td>
|
||
{OPERATIONS.map((op) => {
|
||
const state = cellState(node.rsrcCd, op as Operation, node.parentCd);
|
||
const cls =
|
||
state === 'explicit-granted' ? 'bg-blue-500 text-white border-blue-400 font-bold'
|
||
: state === 'inherited-granted' ? 'bg-blue-500/30 text-blue-300 border-blue-500/40'
|
||
: state === 'explicit-denied' ? 'bg-red-500/40 text-red-300 border-red-500/50 font-bold'
|
||
: state === 'forced-denied' ? 'bg-gray-700/40 text-gray-600 border-gray-700/40 cursor-not-allowed'
|
||
: 'bg-surface-overlay text-hint border-border';
|
||
const icon =
|
||
state === 'explicit-granted' || state === 'inherited-granted' ? '✓'
|
||
: state === 'explicit-denied' ? '—'
|
||
: state === 'forced-denied' ? '×'
|
||
: '·';
|
||
return (
|
||
<td key={op} className="text-center py-1.5">
|
||
<button
|
||
type="button"
|
||
disabled={!canUpdatePerm || state === 'forced-denied'}
|
||
onClick={() => handleCellClick(node.rsrcCd, op as Operation)}
|
||
className={`w-7 h-6 rounded border text-[11px] transition-colors ${cls} ${canUpdatePerm && state !== 'forced-denied' ? 'hover:opacity-80 cursor-pointer' : ''}`}
|
||
title={`${op} - ${state}`}
|
||
>
|
||
{icon}
|
||
</button>
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
|
||
</Fragment>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-base font-bold text-heading">권한 관리 (트리 RBAC)</h2>
|
||
<p className="text-[10px] text-hint mt-0.5">
|
||
좌측 역할 선택 → 우측 트리 매트릭스에서 셀 클릭 (Y → N → 상속) → 저장
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button type="button" onClick={load}
|
||
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||
|
||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||
|
||
{!loading && (
|
||
<div className="grid grid-cols-12 gap-3">
|
||
{/* 좌측: 역할 목록 */}
|
||
<Card className="col-span-3">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs text-label font-bold">역할</div>
|
||
<div className="flex items-center gap-1">
|
||
{canCreateRole && (
|
||
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
||
className="p-1 text-hint hover:text-label" title="신규 역할">
|
||
<Plus className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||
<button type="button" onClick={handleDeleteRole}
|
||
className="p-1 text-hint hover:text-heading" title="역할 삭제">
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showCreate && (
|
||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||
placeholder="ROLE_CD (대문자)"
|
||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||
placeholder="역할 이름"
|
||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||
<div className="flex gap-1 pt-1">
|
||
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
|
||
생성
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
|
||
취소
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
{roles.map((r) => {
|
||
const selected = r.roleSn === selectedRoleSn;
|
||
const isEditingColor = editingColor === String(r.roleSn);
|
||
return (
|
||
<div
|
||
key={r.roleSn}
|
||
className={`px-2 py-1.5 rounded border transition-colors ${
|
||
selected
|
||
? 'bg-blue-600/20 border-blue-500/40 text-heading'
|
||
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||
className="flex items-center gap-1.5 cursor-pointer"
|
||
title="역할 선택"
|
||
>
|
||
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
|
||
{r.roleCd}
|
||
</Badge>
|
||
</button>
|
||
<div className="flex items-center gap-1">
|
||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||
{canUpdatePerm && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||
className="text-[8px] text-hint hover:text-label"
|
||
title="색상 변경"
|
||
>
|
||
●
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
|
||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||
</button>
|
||
{isEditingColor && (
|
||
<div className="mt-2 p-2 bg-background rounded border border-border">
|
||
<ColorPicker
|
||
label="배지 색상"
|
||
value={r.colorHex}
|
||
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 우측: 권한 매트릭스 */}
|
||
<Card className="col-span-9">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div className="text-xs text-label font-bold">
|
||
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
||
</div>
|
||
<div className="text-[10px] text-hint mt-0.5">
|
||
셀 의미: <span className="text-label">✓ 명시 허용</span> /
|
||
<span className="text-blue-300/80 ml-1">✓ 상속 허용</span> /
|
||
<span className="text-heading ml-1">— 명시 거부</span> /
|
||
<span className="text-gray-500 ml-1">× 강제 거부</span> /
|
||
<span className="text-hint ml-1">· 미지정</span>
|
||
</div>
|
||
</div>
|
||
{canUpdatePerm && selectedRole && (
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleSave}
|
||
disabled={!isDirty || saving}
|
||
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||
>
|
||
저장 {isDirty && <span className="text-yellow-300">●</span>}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{selectedRole && (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead className="sticky top-0 bg-surface-overlay">
|
||
<tr className="border-b border-border">
|
||
<th className="text-left py-2 pl-2 text-hint font-medium">리소스</th>
|
||
{OPERATIONS.map((op) => (
|
||
<th key={op} className="w-16 text-center py-2 text-hint font-medium">{op[0]}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|