kcg-ai-monitoring/frontend/src/features/parent-inference/ParentReview.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

271 lines
11 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } 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 { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchReviewList,
reviewParent,
type ParentResolution,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* 모선 확정/거부/리셋 페이지.
* - 운영자가 prediction이 추론한 모선 후보를 확정/거부.
* - 권한: parent-inference-workflow:parent-review (READ + UPDATE)
* - 모든 액션은 백엔드에서 audit_log + review_log에 기록
*/
export function ParentReview() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { hasPermission } = useAuth();
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
const [items, setItems] = useState<ParentResolution[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<number | null>(null);
const [filter, setFilter] = useState<string>('');
// 새 그룹 입력 폼 (테스트용)
const [newGroupKey, setNewGroupKey] = useState('');
const [newSubCluster, setNewSubCluster] = useState('1');
const [newMmsi, setNewMmsi] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetchReviewList(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
setError(msg);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
load();
}, [load]);
const handleAction = async (
item: ParentResolution,
action: 'CONFIRM' | 'REJECT' | 'RESET',
selectedMmsi?: string,
) => {
if (!canUpdate) return;
setActionLoading(item.id);
try {
await reviewParent(item.groupKey, item.subClusterId, {
action,
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
comment: `${action} via UI`,
});
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg);
} finally {
setActionLoading(null);
}
};
const handleCreate = async () => {
if (!canUpdate || !newGroupKey || !newMmsi) return;
setActionLoading(-1);
try {
await reviewParent(newGroupKey, parseInt(newSubCluster, 10), {
action: 'CONFIRM',
selectedParentMmsi: newMmsi,
comment: '운영자 직접 등록',
});
setNewGroupKey('');
setNewMmsi('');
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg);
} finally {
setActionLoading(null);
}
};
return (
<PageContainer size="lg">
<PageHeader
icon={GitMerge}
iconColor="text-purple-400"
title="모선 확정/거부"
description="추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)"
actions={
<>
<Select size="sm" title="상태 필터" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
{/* 신규 등록 폼 (테스트용) */}
{canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
type="text"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
placeholder="group_key (예: 渔船A)"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="sub_cluster_id"
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
placeholder="sub_cluster_id"
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="parent MMSI"
type="text"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}
placeholder="parent MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<button
type="button"
onClick={handleCreate}
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
>
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
)}
{!canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-yellow-400">
(UPDATE ). // .
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-red-400">: {error}</div>
</CardContent>
</Card>
)}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{!loading && items.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-hint text-sm">
. , prediction .
</CardContent>
</Card>
)}
{!loading && items.length > 0 && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">ID</th>
<th className="px-3 py-2 text-left">Group Key</th>
<th className="px-3 py-2 text-center">Sub</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> MMSI</th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2">
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
{getParentResolutionLabel(it.status, tc, lang)}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{formatDateTime(it.updatedAt)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'CONFIRM')}
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
title="확정"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'REJECT')}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
title="거부"
>
<XCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'RESET')}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
title="리셋"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</PageContainer>
);
}