wing-ops/frontend/src/components/admin/components/contents/RolePermTab.tsx
leedano 38d931db65 refactor(mpa): 탭 디렉토리를 MPA 컴포넌트 구조로 재편
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:38:49 +09:00

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