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

202 lines
8.4 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Tag, X, Loader2 } 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 { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchLabelSessions,
createLabelSession,
cancelLabelSession,
type LabelSession as LabelSessionType,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getLabelSessionIntent, getLabelSessionLabel } from '@shared/constants/parentResolutionStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* 모선 추론 학습 세션 페이지.
* 운영자가 정답 라벨링 → prediction 모델 학습 데이터로 활용.
*
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
*/
export function LabelSession() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { hasPermission } = useAuth();
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
const [items, setItems] = useState<LabelSessionType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
const [busy, setBusy] = useState<number | null>(null);
// 신규 세션
const [groupKey, setGroupKey] = useState('');
const [subCluster, setSubCluster] = useState('1');
const [labelMmsi, setLabelMmsi] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchLabelSessions(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleCreate = async () => {
if (!canCreate || !groupKey || !labelMmsi) return;
setBusy(-1);
try {
await createLabelSession(groupKey, parseInt(subCluster, 10), {
labelParentMmsi: labelMmsi,
anchorSnapshot: { source: 'manual', timestamp: new Date().toISOString() },
});
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
};
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm(tc('dialog.cancelSession'))) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
};
return (
<PageContainer size="lg">
<PageHeader
icon={Tag}
iconColor="text-cyan-400"
title="학습 세션"
description="정답 라벨링 → prediction 모델 학습 데이터로 활용"
actions={
<>
<Select
size="sm"
aria-label={tc('aria.statusFilter')}
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" />
{!canCreate && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<Input aria-label={tc('aria.groupKey')} size="sm" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreate} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreate} />
<Input aria-label={tc('aria.correctParentMmsi')} size="sm" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48" disabled={!canCreate} />
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
>
</Button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-600 dark:text-red-400">{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 && (
<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"> MMSI</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={8} className="px-3 py-8 text-center text-hint"> .</td></tr>
)}
{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 text-cyan-600 dark:text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-600 dark:text-red-400" title="취소" aria-label="취소">
<X className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</PageContainer>
);
}