kcg-ai-monitoring/frontend/src/features/detection/RealGearGroups.tsx
htlee a07c745cbc feat(frontend): 40+ 페이지 Badge/시맨틱 토큰 마이그레이션
- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환
- 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid
- 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정)
- ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/
  PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동)
- Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리)
- ReportManagement, TransferDetection p-5 space-y-4 padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 formatDateTime 적용 (ISO T 구분자 처리)
- 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
2026-04-08 10:53:58 +09:00

153 lines
7.4 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 { 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';
/**
* iran 백엔드의 실시간 어구/선단 그룹을 표시.
* - 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" /> / (iran )
{!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 value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<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 type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 통계 */}
<div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
</div>
{error && <div className="text-xs text-red-400">: {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>
);
}
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
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 ${color}`}>{value}</div>
</div>
);
}