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 기존)
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { X, Check, Loader2 } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { Button } from '@shared/components/ui/button';
|
|
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
|
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
|
|
|
interface Props {
|
|
user: AdminUser;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
|
const { t: tc } = useTranslation('common');
|
|
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchRoles()
|
|
.then((r) => {
|
|
setRoles(r);
|
|
const cur = new Set<number>();
|
|
for (const role of r) {
|
|
if (user.roles.includes(role.roleCd)) cur.add(role.roleSn);
|
|
}
|
|
setSelected(cur);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [user]);
|
|
|
|
const toggle = (sn: number) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(sn)) next.delete(sn); else next.add(sn);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await assignUserRoles(user.userId, Array.from(selected));
|
|
onSaved();
|
|
onClose();
|
|
} catch (e: unknown) {
|
|
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
|
<div className="bg-card border border-border rounded-lg shadow-2xl w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div>
|
|
<div className="text-sm font-bold text-heading">역할 배정</div>
|
|
<div className="text-[10px] text-hint mt-0.5">
|
|
{user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합)
|
|
</div>
|
|
</div>
|
|
<button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-2 max-h-96 overflow-y-auto">
|
|
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
|
{!loading && roles.map((r) => {
|
|
const isSelected = selected.has(r.roleSn);
|
|
return (
|
|
<button
|
|
key={r.roleSn}
|
|
type="button"
|
|
onClick={() => toggle(r.roleSn)}
|
|
className={`w-full flex items-center justify-between p-3 rounded border transition-colors ${
|
|
isSelected ? 'bg-blue-600/10 border-blue-500/40' : 'bg-surface-overlay border-border hover:bg-surface-overlay/80'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-5 h-5 rounded border flex items-center justify-center ${
|
|
isSelected ? 'bg-blue-600 border-blue-500' : 'border-border'
|
|
}`}>
|
|
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
|
|
</div>
|
|
<Badge size="md" style={getRoleBadgeStyle(r.roleCd)}>
|
|
{r.roleCd}
|
|
</Badge>
|
|
<div className="text-left">
|
|
<div className="text-xs text-heading font-medium">{r.roleNm}</div>
|
|
<div className="text-[10px] text-hint">{r.roleDc || '-'}</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-[10px] text-hint">권한 {r.permissions.length}건</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
|
<Button variant="secondary" size="sm" onClick={onClose}>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
|
>
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|