313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import type { PermTreeNode, RoleWithPermissions } from '@common/services/authApi';
|
|
import type { PermState, OperCode } from '../PermissionsPanel';
|
|
import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, buildEffectiveStates } from '../PermissionsPanel';
|
|
import { TreeRow } from './TreeRow';
|
|
import { PermLegend } from './PermLegend';
|
|
|
|
interface RolePermTabProps {
|
|
roles: RoleWithPermissions[];
|
|
permTree: PermTreeNode[];
|
|
rolePerms: Map<number, Map<string, boolean>>;
|
|
setRolePerms: React.Dispatch<React.SetStateAction<Map<number, Map<string, boolean>>>>;
|
|
selectedRoleSn: number | null;
|
|
setSelectedRoleSn: (sn: number | null) => void;
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
saveError: string | null;
|
|
handleSave: () => Promise<void>;
|
|
handleToggleExpand: (code: string) => void;
|
|
handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void;
|
|
expanded: Set<string>;
|
|
flatNodes: PermTreeNode[];
|
|
editingRoleSn: number | null;
|
|
editRoleName: string;
|
|
setEditRoleName: (name: string) => void;
|
|
handleStartEditName: (role: RoleWithPermissions) => void;
|
|
handleSaveRoleName: (roleSn: number) => Promise<void>;
|
|
setEditingRoleSn: (sn: number | null) => void;
|
|
toggleDefault: (roleSn: number) => Promise<void>;
|
|
handleDeleteRole: (roleSn: number, roleName: string) => Promise<void>;
|
|
showCreateForm: boolean;
|
|
setShowCreateForm: (show: boolean) => void;
|
|
setCreateError: (err: string) => void;
|
|
newRoleCode: string;
|
|
setNewRoleCode: (code: string) => void;
|
|
newRoleName: string;
|
|
setNewRoleName: (name: string) => void;
|
|
newRoleDesc: string;
|
|
setNewRoleDesc: (desc: string) => void;
|
|
creating: boolean;
|
|
createError: string;
|
|
handleCreateRole: () => Promise<void>;
|
|
}
|
|
|
|
export function RolePermTab({
|
|
roles,
|
|
permTree,
|
|
selectedRoleSn,
|
|
setSelectedRoleSn,
|
|
dirty,
|
|
saving,
|
|
saveError,
|
|
handleSave,
|
|
handleToggleExpand,
|
|
handleTogglePerm,
|
|
expanded,
|
|
flatNodes,
|
|
rolePerms,
|
|
editingRoleSn,
|
|
editRoleName,
|
|
setEditRoleName,
|
|
handleStartEditName,
|
|
handleSaveRoleName,
|
|
setEditingRoleSn,
|
|
toggleDefault,
|
|
handleDeleteRole,
|
|
showCreateForm,
|
|
setShowCreateForm,
|
|
setCreateError,
|
|
newRoleCode,
|
|
setNewRoleCode,
|
|
newRoleName,
|
|
setNewRoleName,
|
|
newRoleDesc,
|
|
setNewRoleDesc,
|
|
creating,
|
|
createError,
|
|
handleCreateRole,
|
|
}: RolePermTabProps) {
|
|
const currentStateMap = selectedRoleSn
|
|
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
|
|
: new Map<string, PermState>();
|
|
|
|
return (
|
|
<>
|
|
{/* 헤더 액션 버튼 */}
|
|
<div
|
|
className="flex items-center gap-2 px-4 py-2 border-b border-stroke"
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setShowCreateForm(true);
|
|
setCreateError('');
|
|
}}
|
|
className="px-3 py-1.5 text-label-2 font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
|
>
|
|
+ 역할 추가
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!dirty || saving}
|
|
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
|
|
dirty
|
|
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{saving ? '저장 중...' : '변경사항 저장'}
|
|
</button>
|
|
{saveError && (
|
|
<span className="text-label-2 text-color-danger font-korean">{saveError}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 역할 탭 바 */}
|
|
<div
|
|
className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface overflow-x-auto"
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
{roles.map((role) => {
|
|
const isSelected = selectedRoleSn === role.sn;
|
|
return (
|
|
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
|
|
<button
|
|
onClick={() => setSelectedRoleSn(role.sn)}
|
|
className={`px-2.5 py-1 text-label-2 font-semibold rounded-md transition-all font-korean ${
|
|
isSelected
|
|
? 'border-2 border-color-accent text-color-accent shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
|
: 'border border-stroke text-fg-disabled hover:border-stroke-light hover:text-fg-sub'
|
|
}`}
|
|
>
|
|
{editingRoleSn === role.sn ? (
|
|
<input
|
|
type="text"
|
|
value={editRoleName}
|
|
onChange={(e) => setEditRoleName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSaveRoleName(role.sn);
|
|
if (e.key === 'Escape') setEditingRoleSn(null);
|
|
}}
|
|
onBlur={() => handleSaveRoleName(role.sn)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
autoFocus
|
|
className="w-20 px-1 py-0 text-label-2 font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
|
|
/>
|
|
) : (
|
|
<span onDoubleClick={() => handleStartEditName(role)}>{role.name}</span>
|
|
)}
|
|
<span className="ml-1 text-caption font-mono opacity-50">{role.code}</span>
|
|
{role.isDefault && (
|
|
<span className="ml-1 text-caption text-color-accent">기본</span>
|
|
)}
|
|
</button>
|
|
{isSelected && (
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={() => toggleDefault(role.sn)}
|
|
className={`px-1.5 py-0.5 text-caption rounded transition-all font-korean ${
|
|
role.isDefault
|
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
|
|
: 'text-fg-disabled hover:text-fg-sub'
|
|
}`}
|
|
title="신규 사용자 기본 역할 설정"
|
|
>
|
|
{role.isDefault ? '기본역할' : '기본설정'}
|
|
</button>
|
|
{role.code !== 'ADMIN' && (
|
|
<button
|
|
onClick={() => handleDeleteRole(role.sn, role.name)}
|
|
className="w-5 h-5 flex items-center justify-center text-fg-disabled hover:text-color-danger transition-colors"
|
|
title="역할 삭제"
|
|
>
|
|
<svg
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 범례 */}
|
|
<PermLegend />
|
|
|
|
{/* CRUD 매트릭스 테이블 */}
|
|
{selectedRoleSn ? (
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
|
<th className="px-3 py-1.5 text-left text-caption font-semibold text-fg-disabled font-korean min-w-[200px]">
|
|
기능
|
|
</th>
|
|
{OPER_CODES.map((oper) => (
|
|
<th key={oper} className="px-1 py-1.5 text-center w-12">
|
|
<div className="text-caption font-semibold text-fg-sub">
|
|
{OPER_LABELS[oper]}
|
|
</div>
|
|
<div className="text-caption text-fg-disabled font-korean">
|
|
{OPER_FULL_LABELS[oper]}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{permTree.map((rootNode) => (
|
|
<TreeRow
|
|
key={rootNode.code}
|
|
node={rootNode}
|
|
stateMap={currentStateMap}
|
|
expanded={expanded}
|
|
onToggleExpand={handleToggleExpand}
|
|
onTogglePerm={handleTogglePerm}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
|
|
역할을 선택하세요
|
|
</div>
|
|
)}
|
|
|
|
{/* 역할 생성 모달 */}
|
|
{showCreateForm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
|
|
<div className="px-5 py-4 border-b border-stroke">
|
|
<h3 className="text-body-2 font-bold text-fg font-korean">새 역할 추가</h3>
|
|
</div>
|
|
<div className="px-5 py-4 flex flex-col gap-3">
|
|
<div>
|
|
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
|
역할 코드
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newRoleCode}
|
|
onChange={(e) =>
|
|
setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))
|
|
}
|
|
placeholder="CUSTOM_ROLE"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
|
/>
|
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
|
영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
|
역할 이름
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newRoleName}
|
|
onChange={(e) => setNewRoleName(e.target.value)}
|
|
placeholder="사용자 정의 역할"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
|
설명 (선택)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newRoleDesc}
|
|
onChange={(e) => setNewRoleDesc(e.target.value)}
|
|
placeholder="역할에 대한 설명"
|
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
|
/>
|
|
</div>
|
|
{createError && (
|
|
<div className="px-3 py-2 text-label-2 text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
|
{createError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowCreateForm(false)}
|
|
className="px-4 py-2 text-caption text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleCreateRole}
|
|
disabled={!newRoleCode || !newRoleName || creating}
|
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
|
>
|
|
{creating ? '생성 중...' : '생성'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|