diff --git a/frontend/src/features/admin/AdminPanel.tsx b/frontend/src/features/admin/AdminPanel.tsx
index 5761c36..e83874c 100644
--- a/frontend/src/features/admin/AdminPanel.tsx
+++ b/frontend/src/features/admin/AdminPanel.tsx
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
+import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
+import { getStatusIntent } from '@shared/constants/statusIntent';
import { Settings, Server, Shield, Database } from 'lucide-react';
/*
@@ -49,9 +51,7 @@ export function AdminPanel() {
{s.name}
- {s.status}
+ {s.status}
종류:
{(['', '수집', '적재'] as const).map((f) => (
-
+ setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]">
+ {f || '전체'}
+
))}
상태:
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
-
+ setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
+ {f || '전체'}
+
))}
-
+
+ }>
+ 새로고침
+
+
{/* 연계서버 카드 그리드 */}
@@ -651,9 +652,9 @@ export function DataHub() {
작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}
-
-
-
+
+
+
diff --git a/frontend/src/features/admin/NoticeManagement.tsx b/frontend/src/features/admin/NoticeManagement.tsx
index 7608264..6888d59 100644
--- a/frontend/src/features/admin/NoticeManagement.tsx
+++ b/frontend/src/features/admin/NoticeManagement.tsx
@@ -237,10 +237,10 @@ export function NoticeManagement() {
-
@@ -261,7 +261,7 @@ export function NoticeManagement() {
{editingId ? '알림 수정' : '새 알림 등록'}
- setShowForm(false)} className="text-hint hover:text-heading">
+ setShowForm(false)} className="text-hint hover:text-heading">
@@ -296,7 +296,7 @@ export function NoticeManagement() {
{TYPE_OPTIONS.map((opt) => (
- setForm({ ...form, type: opt.key })}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] transition-colors ${
@@ -315,7 +315,7 @@ export function NoticeManagement() {
{DISPLAY_OPTIONS.map((opt) => (
- setForm({ ...form, display: opt.key })}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
@@ -361,7 +361,7 @@ export function NoticeManagement() {
{ROLE_OPTIONS.map((role) => (
- toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
@@ -401,7 +401,7 @@ export function NoticeManagement() {
{/* 하단 버튼 */}
- setShowForm(false)}
className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors"
>
diff --git a/frontend/src/features/admin/PermissionsPanel.tsx b/frontend/src/features/admin/PermissionsPanel.tsx
index eb53ae0..d45f5e3 100644
--- a/frontend/src/features/admin/PermissionsPanel.tsx
+++ b/frontend/src/features/admin/PermissionsPanel.tsx
@@ -4,6 +4,7 @@ import {
} 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 {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
@@ -381,10 +382,12 @@ export function PermissionsPanel() {
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
- 생성
- setShowCreate(false)}
- className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded">취소
+
+ 생성
+
+ setShowCreate(false)} className="flex-1">
+ 취소
+
)}
@@ -464,11 +467,15 @@ export function PermissionsPanel() {
{canUpdatePerm && selectedRole && (
-
- {saving ? : }
+ : }
+ >
저장 {isDirty && ●}
-
+
)}
diff --git a/frontend/src/features/admin/SystemConfig.tsx b/frontend/src/features/admin/SystemConfig.tsx
index b97f823..d731be9 100644
--- a/frontend/src/features/admin/SystemConfig.tsx
+++ b/frontend/src/features/admin/SystemConfig.tsx
@@ -194,7 +194,7 @@ export function SystemConfig() {
{/* 탭 */}
{TAB_ITEMS.map((t) => (
- changeTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
@@ -429,7 +429,7 @@ export function SystemConfig() {
{/* 페이지네이션 (코드 탭에서만) */}
{tab !== 'settings' && totalPages > 1 && (
- setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -439,7 +439,7 @@ export function SystemConfig() {
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
- setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
diff --git a/frontend/src/features/ai-operations/AIAssistant.tsx b/frontend/src/features/ai-operations/AIAssistant.tsx
index 3358903..ca2817a 100644
--- a/frontend/src/features/ai-operations/AIAssistant.tsx
+++ b/frontend/src/features/ai-operations/AIAssistant.tsx
@@ -147,7 +147,7 @@ export function AIAssistant() {
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
/>
-
+
diff --git a/frontend/src/features/ai-operations/AIModelManagement.tsx b/frontend/src/features/ai-operations/AIModelManagement.tsx
index e7b19df..898b61e 100644
--- a/frontend/src/features/ai-operations/AIModelManagement.tsx
+++ b/frontend/src/features/ai-operations/AIModelManagement.tsx
@@ -298,7 +298,7 @@ export function AIModelManagement() {
{ key: 'engines' as Tab, icon: Shield, label: '7대 탐지 엔진' },
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
].map((t) => (
- setTab(t.key)}
+ setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
{t.label}
@@ -317,7 +317,7 @@ export function AIModelManagement() {
정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화
- 운영 배포
+ 운영 배포
@@ -331,7 +331,7 @@ export function AIModelManagement() {
{rules.map((rule, i) => (
- toggleRule(i)}
+ toggleRule(i)}
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
@@ -880,7 +880,7 @@ export function AIModelManagement() {
격자별 위험도 조회 (파라미터: 좌표 범위, 시간)
- navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground">
+ navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground">
{`GET /api/v1/predictions/grid
diff --git a/frontend/src/features/ai-operations/MLOpsPage.tsx b/frontend/src/features/ai-operations/MLOpsPage.tsx
index e82fa1a..4b5819d 100644
--- a/frontend/src/features/ai-operations/MLOpsPage.tsx
+++ b/frontend/src/features/ai-operations/MLOpsPage.tsx
@@ -133,7 +133,7 @@ export function MLOpsPage() {
{ key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' },
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
- setTab(t.key)}
+ setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
{t.label}
@@ -197,7 +197,7 @@ export function MLOpsPage() {
{EXPERIMENTS.map(e => (
@@ -288,7 +288,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
{m.name} {m.ver}
- 배포
+ 배포
))}
@@ -313,8 +313,8 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
- 실행
- 초기화
+ 실행
+ 초기화
@@ -352,7 +352,7 @@ export function MLOpsPage() {
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
- setLlmSub(t.key)}
+ setLlmSub(t.key)}
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}
))}
@@ -381,7 +381,7 @@ export function MLOpsPage() {
))}
- 학습 시작
+ 학습 시작
@@ -417,7 +417,7 @@ export function MLOpsPage() {
{k}{v}
))}
- 검색 시작
+ 검색 시작
HPS 시도 결과 Best: Trial #3 (F1=0.912)
@@ -505,7 +505,7 @@ export function MLOpsPage() {
-
+
diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx
index fcd62dd..4bba840 100644
--- a/frontend/src/features/detection/ChinaFishing.tsx
+++ b/frontend/src/features/detection/ChinaFishing.tsx
@@ -292,7 +292,7 @@ export function ChinaFishing() {
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
{modeTabs.map((tab) => (
- setMode(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
@@ -470,7 +470,7 @@ export function ChinaFishing() {
{/* 탭 헤더 */}
{vesselTabs.map((tab) => (
- setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@@ -527,7 +527,7 @@ export function ChinaFishing() {
{/* 탭 */}
{statsTabs.map((tab) => (
- setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@@ -599,7 +599,7 @@ export function ChinaFishing() {
최근 위성영상 분석
- 자세히 보기
+ 자세히 보기
@@ -623,7 +623,7 @@ export function ChinaFishing() {
기상 예보
- 자세히 보기
+ 자세히 보기
@@ -646,7 +646,7 @@ export function ChinaFishing() {
VTS연계 현황
- 자세히 보기
+ 자세히 보기
{VTS_ITEMS.map((vts) => (
@@ -664,10 +664,10 @@ export function ChinaFishing() {
))}
-
+
-
+
diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx
index 445cc39..ccd8568 100644
--- a/frontend/src/features/detection/DarkVesselDetection.tsx
+++ b/frontend/src/features/detection/DarkVesselDetection.tsx
@@ -84,7 +84,7 @@ export function DarkVesselDetection() {
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => {getVesselSurveillanceLabel(v as string, tc, lang)} },
{ key: 'label', label: '라벨', width: '60px', align: 'center',
- render: v => { const l = v as string; return l === '-' ? 분류 : {l}; } },
+ render: v => { const l = v as string; return l === '-' ? 분류 : {l}; } },
], [tc, lang]);
const [darkItems, setDarkItems] = useState([]);
diff --git a/frontend/src/features/detection/GearIdentification.tsx b/frontend/src/features/detection/GearIdentification.tsx
index 9900a4f..a606bb4 100644
--- a/frontend/src/features/detection/GearIdentification.tsx
+++ b/frontend/src/features/detection/GearIdentification.tsx
@@ -648,7 +648,7 @@ export function GearIdentification() {
- setShowReference(!showReference)}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
>
@@ -779,14 +779,14 @@ export function GearIdentification() {
{/* 판별 버튼 */}
-
어구 국적 판별 실행
-
diff --git a/frontend/src/features/risk-assessment/RiskMap.tsx b/frontend/src/features/risk-assessment/RiskMap.tsx
index 1c57d6d..082deb4 100644
--- a/frontend/src/features/risk-assessment/RiskMap.tsx
+++ b/frontend/src/features/risk-assessment/RiskMap.tsx
@@ -206,7 +206,7 @@ export function RiskMap() {
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
]).map(t => (
- setTab(t.key)}
+ setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
{t.label}
diff --git a/frontend/src/features/statistics/ReportManagement.tsx b/frontend/src/features/statistics/ReportManagement.tsx
index b1ea820..9afd932 100644
--- a/frontend/src/features/statistics/ReportManagement.tsx
+++ b/frontend/src/features/statistics/ReportManagement.tsx
@@ -81,7 +81,7 @@ export function ReportManagement() {
증거 파일 업로드 (사진·영상·문서)
- setShowUpload(false)} className="text-hint hover:text-muted-foreground">
+ setShowUpload(false)} className="text-hint hover:text-muted-foreground">
@@ -120,8 +120,8 @@ export function ReportManagement() {
증거 {r.evidence}건
- PDF
- 한글
+ PDF
+ 한글
))}
@@ -135,7 +135,7 @@ export function ReportManagement() {
diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx
index 27a71cf..9b81840 100644
--- a/frontend/src/features/surveillance/LiveMapView.tsx
+++ b/frontend/src/features/surveillance/LiveMapView.tsx
@@ -289,7 +289,7 @@ export function LiveMapView() {
{/* 지도 영역 */}
-
+
{/* 범례 */}
선박 범례
diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx
index 90d07e9..60a0657 100644
--- a/frontend/src/features/surveillance/MapControl.tsx
+++ b/frontend/src/features/surveillance/MapControl.tsx
@@ -300,7 +300,7 @@ export function MapControl() {
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
- setTab(t.key)}
+ setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
{t.label}
@@ -309,7 +309,7 @@ export function MapControl() {
{['', '서해', '남해', '동해', '제주'].map(s => (
- setSeaFilter(s)}
+ setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
@@ -340,7 +340,7 @@ export function MapControl() {
구분:
{NTM_CATEGORIES.map(c => (
- setNtmCatFilter(c === '전체' ? '' : c)}
+ setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}
))}
diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx
index b6930bc..1e65326 100644
--- a/frontend/src/features/vessel/VesselDetail.tsx
+++ b/frontend/src/features/vessel/VesselDetail.tsx
@@ -180,7 +180,7 @@ export function VesselDetail() {
setSearchMmsi(e.target.value)}
placeholder="MMSI 입력"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
-
+
검색
@@ -414,19 +414,19 @@ export function VesselDetail() {
{/* ── 우측 도구바 ── */}
{RIGHT_TOOLS.map((t) => (
-
+
{t.label}
))}
- 범례
- 미니맵
- AI모드
+ 범례
+ 미니맵
+ AI모드
);
diff --git a/frontend/src/shared/components/ui/tabs.tsx b/frontend/src/shared/components/ui/tabs.tsx
new file mode 100644
index 0000000..f54c874
--- /dev/null
+++ b/frontend/src/shared/components/ui/tabs.tsx
@@ -0,0 +1,78 @@
+import { type ButtonHTMLAttributes, type ReactNode } from 'react';
+import { cn } from '@lib/utils/cn';
+
+/**
+ * TabBar — 탭 버튼 그룹 공통 컴포넌트
+ *
+ * 3가지 스타일:
+ * - 'underline' (기본): border-b 밑줄 탭 (MLOps, RiskMap 스타일)
+ * - 'pill': 라운드 pill 탭 (ChinaFishing 스타일)
+ * - 'segmented': 배경 그룹 segmented control 스타일
+ *
+ * 사용:
+ *
+ * setTab('a')}>탭 A
+ * setTab('b')}>탭 B
+ *
+ */
+
+export interface TabBarProps {
+ variant?: 'underline' | 'pill' | 'segmented';
+ children: ReactNode;
+ className?: string;
+}
+
+export function TabBar({ variant = 'underline', children, className }: TabBarProps) {
+ const variantClass = {
+ underline: 'flex gap-0 border-b border-border',
+ pill: 'flex items-center gap-1',
+ segmented: 'flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-border w-fit',
+ }[variant];
+ return {children} ;
+}
+
+export interface TabButtonProps extends ButtonHTMLAttributes {
+ active?: boolean;
+ variant?: 'underline' | 'pill' | 'segmented';
+ icon?: ReactNode;
+ children: ReactNode;
+}
+
+/**
+ * TabButton — 단일 탭 버튼. active 상태에 따라 스타일 변화.
+ */
+export function TabButton({
+ active = false,
+ variant = 'underline',
+ icon,
+ children,
+ className,
+ type = 'button',
+ ...props
+}: TabButtonProps) {
+ const variantClass = {
+ underline: cn(
+ 'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 transition-colors',
+ active ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label',
+ ),
+ pill: cn(
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium transition-colors',
+ active
+ ? 'bg-blue-600 text-on-vivid'
+ : 'text-muted-foreground hover:bg-secondary hover:text-foreground',
+ ),
+ segmented: cn(
+ 'flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors',
+ active
+ ? 'bg-blue-600 text-on-vivid'
+ : 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay',
+ ),
+ }[variant];
+
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
|