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 기존)
139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
/**
|
|
* 선박 24h 특이운항 판별 구간 상세 패널.
|
|
* 연속된 동일 카테고리 신호는 1개 구간으로 병합하여 시작~종료 시각과 함께 표시한다.
|
|
*/
|
|
import { Loader2, AlertTriangle, ShieldAlert } from 'lucide-react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
|
import {
|
|
type AnomalySegment,
|
|
getAnomalyCategoryIntent,
|
|
getAnomalyCategoryLabel,
|
|
} from './vesselAnomaly';
|
|
|
|
interface Props {
|
|
segments: AnomalySegment[];
|
|
loading?: boolean;
|
|
error?: string;
|
|
totalHistoryCount: number;
|
|
}
|
|
|
|
function formatDuration(min: number): string {
|
|
if (min <= 0) return '단일 샘플';
|
|
if (min < 60) return `${min}분`;
|
|
const h = Math.floor(min / 60);
|
|
const m = min % 60;
|
|
return m === 0 ? `${h}시간` : `${h}시간 ${m}분`;
|
|
}
|
|
|
|
export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount }: Props) {
|
|
const criticalCount = segments.filter((s) => s.severity === 'critical').length;
|
|
const warningCount = segments.filter((s) => s.severity === 'warning').length;
|
|
const infoCount = segments.filter((s) => s.severity === 'info').length;
|
|
const totalSamples = segments.reduce((sum, s) => sum + s.pointCount, 0);
|
|
|
|
return (
|
|
<Card className="bg-surface-raised border-slate-700/30">
|
|
<CardContent className="p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
|
<span className="text-[11px] font-bold text-heading">특이운항 판별 구간</span>
|
|
{segments.length > 0 && (
|
|
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
|
|
· {segments.length}구간
|
|
{criticalCount > 0 && <span className="text-red-600 dark:text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
|
|
{warningCount > 0 && <span className="text-orange-600 dark:text-orange-400 ml-0.5">WARNING {warningCount}</span>}
|
|
{infoCount > 0 && <span className="text-blue-600 dark:text-blue-400 ml-0.5">INFO {infoCount}</span>}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[9px] text-hint">
|
|
최근 24h 분석 {totalHistoryCount}건 중 {totalSamples}건 포함
|
|
</span>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-600 dark:text-red-400">
|
|
<AlertTriangle className="w-3 h-3 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && segments.length === 0 && totalHistoryCount > 0 && (
|
|
<div className="px-3 py-6 text-center text-[11px] text-hint">
|
|
최근 24h 동안 Dark / Spoofing / 환적 / 어구위반 / 고위험 신호가 없습니다.
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && totalHistoryCount === 0 && (
|
|
<div className="px-3 py-6 text-center text-[11px] text-hint">
|
|
분석 이력이 없습니다.
|
|
</div>
|
|
)}
|
|
|
|
{!loading && segments.length > 0 && (
|
|
<div className="max-h-[360px] overflow-y-auto space-y-1.5 pr-1">
|
|
{segments.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className={`rounded border px-2.5 py-2 text-[10px] ${
|
|
s.severity === 'critical'
|
|
? 'border-red-500/30 bg-red-500/5'
|
|
: s.severity === 'warning'
|
|
? 'border-orange-500/30 bg-orange-500/5'
|
|
: 'border-blue-500/30 bg-blue-500/5'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
|
|
<span className="font-mono text-label">
|
|
{formatDateTime(s.startTime)}
|
|
{s.startTime !== s.endTime && (
|
|
<>
|
|
<span className="text-hint mx-1">→</span>
|
|
{formatDateTime(s.endTime)}
|
|
</>
|
|
)}
|
|
</span>
|
|
<span className="text-hint text-[9px]">
|
|
<span className="text-muted-foreground">{formatDuration(s.durationMin)}</span>
|
|
<span className="mx-1">·</span>
|
|
<span>{s.pointCount}회 연속 감지</span>
|
|
{s.representativeLat != null && s.representativeLon != null && (
|
|
<>
|
|
<span className="mx-1">·</span>
|
|
<span
|
|
className="font-mono"
|
|
title="판별 근거가 된 이벤트 발생 지점 (예: AIS 신호 단절 시작 좌표). 실제 분석 시각 위치는 지도의 색칠 궤적 구간을 확인하세요."
|
|
>
|
|
{s.representativeLat.toFixed(3)}°N, {s.representativeLon.toFixed(3)}°E
|
|
</span>
|
|
<span className="text-muted-foreground ml-0.5">(이벤트 기준)</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-wrap mb-1">
|
|
{s.categories.map((c) => (
|
|
<Badge key={c} intent={getAnomalyCategoryIntent(c)} size="xs" className="font-normal">
|
|
{getAnomalyCategoryLabel(c)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div className="text-muted-foreground text-[10px] leading-snug">{s.description}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|