kcg-ai-monitoring/frontend/src/features/admin/UserRoleAssignDialog.tsx
htlee c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
2026-04-08 13:04:23 +09:00

116 lines
4.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react';
import { Badge } from '@shared/components/ui/badge';
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 [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('실패: ' + (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="닫기" 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 type="button" onClick={onClose}
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
</button>
<button type="button" onClick={handleSave} disabled={saving}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
);
}