kcg-ai-monitoring/frontend/src/features/admin/PermissionsPanel.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00

548 lines
22 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { Input } from '@shared/components/ui/input';
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';
import { useTranslation } from 'react-i18next';
/**
* 트리 기반 권한 관리 패널 (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 { t: tc } = useTranslation('common');
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(`${tc('success.permissionUpdated')} (${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(tc('error.createFailed', { msg: 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(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert(tc('message.builtinRoleCannotDelete'));
return;
}
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} catch (e: unknown) {
alert(tc('error.deleteFailed', { msg: 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
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: 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
variant="ghost"
size="sm"
onClick={() => setShowCreate(!showCreate)}
aria-label="신규 역할"
title="신규 역할"
icon={<Plus className="w-3.5 h-3.5" />}
/>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteRole}
aria-label="역할 삭제"
title="역할 삭제"
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<Input
aria-label={tc('aria.roleCode')}
size="sm"
value={newRoleCd}
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
/>
<Input
aria-label={tc('aria.roleName')}
size="sm"
value={newRoleNm}
onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
/>
<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>
);
}