kcg-ai-monitoring/frontend/src/features/detection/components/DarkDetailPanel.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

194 lines
9.1 KiB
TypeScript

/**
* DarkDetailPanel — Dark Vessel 판정 상세 사이드 패널
*
* 테이블 행 클릭 시 우측에 슬라이드 표시.
* 점수 산출 내역, 선박 정보, GAP 상세, 과거 이력을 종합 표시.
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
import { buildScoreBreakdown } from '@shared/constants/darkVesselPatterns';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
import { X, Ship, MapPin, Clock, AlertTriangle, TrendingUp, ExternalLink, ShieldAlert } from 'lucide-react';
import { BarChart as EcBarChart } from '@lib/charts';
interface DarkDetailPanelProps {
vessel: VesselAnalysis | null;
onClose: () => void;
}
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
const darkTier = (features.dark_tier as string) ?? 'NONE';
const darkScore = (features.dark_suspicion_score as number) ?? 0;
const darkPatterns = (features.dark_patterns as string[]) ?? [];
const darkHistory7d = (features.dark_history_7d as number) ?? 0;
const darkHistory24h = (features.dark_history_24h as number) ?? 0;
const gapStartLat = features.gap_start_lat as number | undefined;
const gapStartLon = features.gap_start_lon as number | undefined;
const gapStartSog = features.gap_start_sog as number | undefined;
const gapStartState = features.gap_start_state as string | undefined;
// 점수 산출 내역
const breakdown = useMemo(() => buildScoreBreakdown(darkPatterns), [darkPatterns]);
// 7일 이력 조회
const loadHistory = useCallback(async () => {
if (!vessel?.mmsi) return;
try {
const res = await getAnalysisHistory(vessel.mmsi, 168); // 7일
setHistory(res);
} catch { setHistory([]); }
}, [vessel?.mmsi]);
useEffect(() => { loadHistory(); }, [loadHistory]);
// 일별 dark 건수 집계 (차트용)
const dailyDarkData = useMemo(() => {
const dayMap: Record<string, number> = {};
for (const h of history) {
if (!h.isDark) continue;
const day = (h.analyzedAt ?? '').slice(0, 10);
if (day) dayMap[day] = (dayMap[day] || 0) + 1;
}
return Object.entries(dayMap)
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, count]) => ({ name: day.slice(5), value: count }));
}, [history]);
if (!vessel) return null;
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="font-bold text-heading text-sm"> </span>
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
<div className="p-4 space-y-4">
{/* 선박 기본 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
</button>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.vesselType || 'UNKNOWN'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.mmsi?.startsWith('412') ? 'CN (중국)' : vessel.mmsi?.slice(0, 3)}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.zoneCode || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right">{vessel.activityState || '-'}</span>
<span className="text-hint"></span>
<span className="text-right">
<Badge intent={getRiskIntent(vessel.riskScore ?? 0)} size="sm">
{vessel.riskLevel} ({vessel.riskScore})
</Badge>
</span>
</div>
</div>
{/* 점수 산출 내역 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
<span className="text-hint text-[10px]">({breakdown.items.length} )</span>
</div>
<ScoreBreakdown items={breakdown.items} totalScore={darkScore} />
</div>
{/* GAP 상세 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium">GAP </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">GAP </span>
<span className="text-label text-right font-mono">
{vessel.gapDurationMin ? `${vessel.gapDurationMin}분 (${(vessel.gapDurationMin / 60).toFixed(1)}h)` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right font-mono text-[10px]">
{gapStartLat != null ? `${gapStartLat.toFixed(4)}°N ${gapStartLon?.toFixed(4)}°E` : '-'}
</span>
<span className="text-hint"> SOG</span>
<span className="text-label text-right font-mono">
{gapStartSog != null ? `${gapStartSog.toFixed(1)}kn` : '-'}
</span>
<span className="text-hint"> </span>
<span className="text-label text-right">{gapStartState || '-'}</span>
<span className="text-hint"></span>
<span className="text-label text-right text-[10px]">{formatDateTime(vessel.analyzedAt)}</span>
</div>
</div>
{/* 과거 이력 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> (7)</span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">7 dark </span>
<span className="text-label text-right font-bold">{darkHistory7d}</span>
<span className="text-hint">24 dark</span>
<span className="text-label text-right">{darkHistory24h}</span>
</div>
{dailyDarkData.length > 0 && (
<div className="h-24 mt-2">
<EcBarChart
data={dailyDarkData}
xKey="name"
series={[{ key: 'value', name: 'Dark 건수', color: '#ef4444' }]}
height={96}
/>
</div>
)}
{dailyDarkData.length === 0 && (
<div className="text-hint text-[10px] text-center py-2"> </div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
<Ship className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="primary" size="sm" className="flex-1"
onClick={() => { /* TODO: 단속 대상 등록 API 연동 */ }}>
<Clock className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
</div>
);
}