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 기존)
175 lines
8.0 KiB
TypeScript
175 lines
8.0 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { Loader2, RefreshCw, MapPin } 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 type { BadgeIntent } from '@lib/theme/variants';
|
|
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
|
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
|
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
/**
|
|
* prediction 분석 엔진이 산출한 실시간 어구/선단 그룹을 표시.
|
|
* - GET /api/vessel-analysis/groups
|
|
* - 자체 DB의 ParentResolution 운영자 결정이 합성되어 있음
|
|
*/
|
|
|
|
export function RealGearGroups() {
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language);
|
|
const [items, setItems] = useState<GearGroupItem[]>([]);
|
|
const [available, setAvailable] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [filterType, setFilterType] = useState<string>('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError('');
|
|
try {
|
|
const res = await fetchGroups();
|
|
setItems(res.items);
|
|
setAvailable(res.serviceAvailable);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'unknown');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const filtered = filterType ? items.filter((i) => i.groupType === filterType) : items;
|
|
|
|
const stats = {
|
|
total: items.length,
|
|
fleet: items.filter((i) => i.groupType === 'FLEET').length,
|
|
gearInZone: items.filter((i) => i.groupType === 'GEAR_IN_ZONE').length,
|
|
gearOutZone: items.filter((i) => i.groupType === 'GEAR_OUT_ZONE').length,
|
|
confirmed: items.filter((i) => i.resolution?.status === 'MANUAL_CONFIRMED').length,
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
|
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹
|
|
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
|
</div>
|
|
<div className="text-[10px] text-hint mt-0.5">
|
|
GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
size="sm"
|
|
aria-label={tc('aria.groupTypeFilter')}
|
|
value={filterType}
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
>
|
|
<option value="">전체</option>
|
|
<option value="FLEET">FLEET</option>
|
|
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
|
|
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={load}
|
|
aria-label={tc('aria.refresh')}
|
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 */}
|
|
<div className="grid grid-cols-5 gap-2">
|
|
<StatBox label="총 그룹" value={stats.total} intent="muted" />
|
|
<StatBox label="FLEET" value={stats.fleet} intent="info" />
|
|
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
|
|
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
|
|
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
|
|
</div>
|
|
|
|
{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-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
|
|
|
|
{!loading && (
|
|
<div className="overflow-x-auto max-h-96">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-surface-overlay text-hint sticky top-0">
|
|
<tr>
|
|
<th className="px-2 py-1.5 text-left">유형</th>
|
|
<th className="px-2 py-1.5 text-left">그룹 키</th>
|
|
<th className="px-2 py-1.5 text-center">서브</th>
|
|
<th className="px-2 py-1.5 text-right">멤버</th>
|
|
<th className="px-2 py-1.5 text-right">면적(NM²)</th>
|
|
<th className="px-2 py-1.5 text-left">중심 좌표</th>
|
|
<th className="px-2 py-1.5 text-left">운영자 결정</th>
|
|
<th className="px-2 py-1.5 text-left">스냅샷 시각</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 && (
|
|
<tr><td colSpan={8} className="px-3 py-6 text-center text-hint">데이터가 없습니다.</td></tr>
|
|
)}
|
|
{filtered.slice(0, 100).map((g) => (
|
|
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
|
|
<td className="px-2 py-1.5">
|
|
<Badge intent={getGearGroupTypeIntent(g.groupType)} size="sm">{getGearGroupTypeLabel(g.groupType, tc, lang)}</Badge>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
|
|
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
|
|
<td className="px-2 py-1.5 text-right text-cyan-400 font-bold">{g.memberCount}</td>
|
|
<td className="px-2 py-1.5 text-right text-muted-foreground">{g.areaSqNm?.toFixed(2)}</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px] font-mono">
|
|
{g.centerLat?.toFixed(3)}, {g.centerLon?.toFixed(3)}
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
{g.resolution ? (
|
|
<Badge intent={getParentResolutionIntent(g.resolution.status)} size="sm">
|
|
{getParentResolutionLabel(g.resolution.status, tc, lang)}
|
|
</Badge>
|
|
) : <span className="text-hint text-[10px]">-</span>}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
|
{g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{filtered.length > 100 && (
|
|
<div className="text-center text-[10px] text-hint mt-2">상위 100건만 표시 (전체 {filtered.length}건)</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
|
|
critical: 'text-red-600 dark:text-red-400',
|
|
high: 'text-orange-600 dark:text-orange-400',
|
|
warning: 'text-yellow-600 dark:text-yellow-400',
|
|
info: 'text-blue-600 dark:text-blue-400',
|
|
success: 'text-green-600 dark:text-green-400',
|
|
muted: 'text-heading',
|
|
purple: 'text-purple-600 dark:text-purple-400',
|
|
cyan: 'text-cyan-600 dark:text-cyan-400',
|
|
};
|
|
|
|
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
|
|
return (
|
|
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
|
<div className="text-[9px] text-hint">{label}</div>
|
|
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|