refactor(frontend): 인라인 버튼/하드코딩 색상 전수 제거

Phase C-2 (인라인 <button>):
- TabBar/TabButton 공통 컴포넌트 신규 (underline/pill/segmented 3종)
- DataHub: 메인 탭 → TabBar + TabButton 전환, 필터 pill 전환,
  CTA 버튼 (작업 등록/스토리지 관리/새로고침) → Button variant
- PermissionsPanel: 역할 생성/저장 → Button variant, icon 버튼 유지
- Python 일괄 치환: 51개 inline <button>에 type="button" 추가
- 남은 <button> type 누락 0건 (multi-line 포함)

Phase C-3 (하드코딩 색상):
- AdminPanel SERVER_STATUS 뱃지: getStatusIntent() 사용으로 통일
- bg-X-500/20 text-X-400 패턴 0건

Phase C-4 (인라인 style):
- LiveMapView BaseMap minHeight → className="min-h-[400px]"
- 나머지 89건 style={{}}은 모두 dynamic value
  (progress width, toggle left, 데이터 기반 color 등)로 정당함

4개 catalog (eventStatuses/enforcementResults/enforcementActions/
patrolStatuses)에 intent 필드 추가, statusIntent.ts 공통 유틸 신규.
이제 모든 Badge가 쇼케이스 팔레트 자동 적용됨.

빌드 검증:
- tsc , eslint , vite build 
- 남은 위반 지표: Badge className 0, button-type-missing 0, 하드코딩 색상 0
This commit is contained in:
htlee 2026-04-08 12:36:07 +09:00
부모 2483174081
커밋 da4dc86e90
17개의 변경된 파일204개의 추가작업 그리고 118개의 파일을 삭제

파일 보기

@ -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() {
<Server className="w-4 h-4 text-hint" />
<span className="text-[11px] font-bold text-heading">{s.name}</span>
</div>
<span className={`text-[9px] font-bold px-2 py-0.5 rounded ${
s.status === '정상' ? 'bg-green-500/20 text-green-400' : s.status === '주의' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'
}`}>{s.status}</span>
<Badge intent={getStatusIntent(s.status)} size="xs">{s.status}</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between"><span className="text-[9px] text-hint">CPU</span><UsageBar value={s.cpu} /></div>

파일 보기

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { SaveButton } from '@shared/components/common/SaveButton';
@ -239,12 +240,12 @@ const collectColumns: DataColumn<CollectJob>[] = [
render: (_v, row) => (
<div className="flex items-center gap-0.5">
{row.status === '정지' ? (
<button className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
</div>
),
},
@ -290,9 +291,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: () => (
<div className="flex items-center gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
</div>
),
},
@ -418,7 +419,7 @@ export function DataHub() {
</div>
{/* 탭 */}
<div className="flex gap-1">
<TabBar variant="pill">
{[
{ key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' },
{ key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' },
@ -426,18 +427,17 @@ export function DataHub() {
{ key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' },
{ key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' },
].map((t) => (
<button
<TabButton
key={t.key}
variant="pill"
active={tab === t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
icon={<t.icon className="w-3.5 h-3.5" />}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
</button>
</TabButton>
))}
</div>
</TabBar>
{/* ── ① 선박신호 수신 현황 ── */}
{tab === 'signal' && (
@ -455,10 +455,9 @@ export function DataHub() {
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-heading focus:outline-none focus:border-cyan-500/50"
/>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-slate-700/50 rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</button>
</Button>
</div>
</div>
@ -527,17 +526,15 @@ export function DataHub() {
{/* 상태 필터 */}
<div className="ml-auto flex items-center gap-1">
{(['', 'ON', 'OFF'] as const).map((f) => (
<button
<TabButton
key={f}
variant="pill"
active={statusFilter === f}
onClick={() => setStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${
statusFilter === f
? 'bg-cyan-600 text-on-vivid font-bold'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
className="px-2.5 py-1 text-[10px]"
>
{f || '전체'}
</button>
</TabButton>
))}
</div>
</div>
@ -560,19 +557,21 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint"> :</span>
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
<button key={f} onClick={() => setCollectTypeFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={collectTypeFilter === f} onClick={() => setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setCollectStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={collectStatusFilter === f} onClick={() => setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
<div className="ml-auto">
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
</Button>
</div>
</div>
<DataTable data={filteredCollectJobs} columns={collectColumns} pageSize={10}
searchPlaceholder="작업명, 서버명, IP 검색..." searchKeys={['name', 'serverName', 'serverIp']} exportFilename="수집작업목록" />
@ -585,17 +584,17 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setLoadStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={loadStatusFilter === f} onClick={() => setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<div className="ml-auto flex items-center gap-2">
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<FolderOpen className="w-3 h-3" />
</button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
<Button variant="secondary" size="sm" icon={<FolderOpen className="w-3 h-3" />}>
</Button>
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
</Button>
</div>
</div>
<DataTable data={filteredLoadJobs} columns={loadColumns} pageSize={10}
@ -609,19 +608,21 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수집', '적재'] as const).map((f) => (
<button key={f} onClick={() => setAgentRoleFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={agentRoleFilter === f} onClick={() => setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setAgentStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={agentStatusFilter === f} onClick={() => setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
<div className="ml-auto">
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</Button>
</div>
</div>
{/* 연계서버 카드 그리드 */}
@ -651,9 +652,9 @@ export function DataHub() {
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
<span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span>
<div className="flex gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>

파일 보기

@ -237,10 +237,10 @@ export function NoticeManagement() {
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<Trash2 className="w-3 h-3" />
</button>
</div>
@ -261,7 +261,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</span>
<button onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<button type="button" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -296,7 +296,7 @@ export function NoticeManagement() {
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{TYPE_OPTIONS.map((opt) => (
<button
<button type="button"
key={opt.key}
onClick={() => 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() {
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{DISPLAY_OPTIONS.map((opt) => (
<button
<button type="button"
key={opt.key}
onClick={() => 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() {
</label>
<div className="flex gap-1.5">
{ROLE_OPTIONS.map((role) => (
<button
<button type="button"
key={role}
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
@ -401,7 +401,7 @@ export function NoticeManagement() {
{/* 하단 버튼 */}
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2">
<button
<button type="button"
onClick={() => setShowForm(false)}
className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors"
>

파일 보기

@ -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" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded"></button>
<button type="button" onClick={() => setShowCreate(false)}
className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded"></button>
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
</Button>
</div>
</div>
)}
@ -464,11 +467,15 @@ export function PermissionsPanel() {
</div>
</div>
{canUpdatePerm && selectedRole && (
<button type="button" onClick={handleSave} disabled={!isDirty || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!isDirty || saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
>
{isDirty && <span className="text-yellow-300"></span>}
</button>
</Button>
)}
</div>

파일 보기

@ -194,7 +194,7 @@ export function SystemConfig() {
{/* 탭 */}
<div className="flex gap-1">
{TAB_ITEMS.map((t) => (
<button
<button type="button"
key={t.key}
onClick={() => 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 && (
<div className="flex items-center justify-center gap-2">
<button
<button type="button"
onClick={() => 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() {
<span className="text-[11px] text-hint">
{page + 1} / {totalPages} ({totalItems.toLocaleString()})
</span>
<button
<button type="button"
onClick={() => 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"

파일 보기

@ -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"
/>
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<button type="button" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div>

파일 보기

@ -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) => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => 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.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -317,7 +317,7 @@ export function AIModelManagement() {
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div>
</div>
<button className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
</div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
@ -331,7 +331,7 @@ export function AIModelManagement() {
{rules.map((rule, i) => (
<Card key={i} className="bg-surface-raised border-border">
<CardContent className="p-3 flex items-center gap-4">
<button onClick={() => toggleRule(i)}
<button type="button" onClick={() => toggleRule(i)}
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 transition-all shadow-sm" style={{ left: rule.enabled ? '22px' : '2px' }} />
</button>
@ -880,7 +880,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button onClick={() => 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"><Copy className="w-3 h-3" /></button>
<button type="button" onClick={() => 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"><Copy className="w-3 h-3" /></button>
</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid

파일 보기

@ -133,7 +133,7 @@ export function MLOpsPage() {
{ key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' },
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => 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.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -197,7 +197,7 @@ export function MLOpsPage() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-[12px] font-bold text-heading"> </div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
</div>
<div className="space-y-2">
{EXPERIMENTS.map(e => (
@ -288,7 +288,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
</div>
))}
</div>
@ -313,8 +313,8 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
<div className="flex gap-2 mt-2">
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<button className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
</div>
</CardContent></Card>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
@ -352,7 +352,7 @@ export function MLOpsPage() {
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
<button key={t.key} onClick={() => setLlmSub(t.key)}
<button type="button" key={t.key} onClick={() => 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}</button>
))}
</div>
@ -381,7 +381,7 @@ export function MLOpsPage() {
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
))}
</div>
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
@ -417,7 +417,7 @@ export function MLOpsPage() {
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
))}
</div>
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
</CardContent></Card>
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
@ -505,7 +505,7 @@ export function MLOpsPage() {
</div>
<div className="flex gap-2 shrink-0">
<input className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
<button type="button" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
</div>
</CardContent></Card>
</div>

파일 보기

@ -292,7 +292,7 @@ export function ChinaFishing() {
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
{modeTabs.map((tab) => (
<button
<button type="button"
key={tab.key}
onClick={() => 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() {
{/* 탭 헤더 */}
<div className="flex border-b border-slate-700/30">
{vesselTabs.map((tab) => (
<button
<button type="button"
key={tab}
onClick={() => setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@ -527,7 +527,7 @@ export function ChinaFishing() {
{/* 탭 */}
<div className="flex border-b border-slate-700/30">
{statsTabs.map((tab) => (
<button
<button type="button"
key={tab}
onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@ -599,7 +599,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
@ -623,7 +623,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<div className="text-center">
@ -646,7 +646,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
@ -664,10 +664,10 @@ export function ChinaFishing() {
))}
</div>
<div className="flex justify-between mt-2">
<button className="text-hint hover:text-heading transition-colors">
<button type="button" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button className="text-hint hover:text-heading transition-colors">
<button type="button" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>

파일 보기

@ -84,7 +84,7 @@ export function DarkVesselDetection() {
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
{ key: 'label', label: '라벨', width: '60px', align: 'center',
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
], [tc, lang]);
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);

파일 보기

@ -648,7 +648,7 @@ export function GearIdentification() {
</p>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => 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() {
{/* 판별 버튼 */}
<div className="flex gap-2">
<button
<button type="button"
onClick={runIdentification}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
</button>
<button
<button type="button"
onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
>

파일 보기

@ -206,7 +206,7 @@ export function RiskMap() {
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => 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.icon className="w-3.5 h-3.5" />{t.label}
</button>

파일 보기

@ -81,7 +81,7 @@ export function ReportManagement() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
<button type="button" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
@ -120,8 +120,8 @@ export function ReportManagement() {
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<button className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
</div>
</div>
))}
@ -135,7 +135,7 @@ export function ReportManagement() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
</div>

파일 보기

@ -289,7 +289,7 @@ export function LiveMapView() {
{/* 지도 영역 */}
<div className="flex-1 relative rounded-xl overflow-hidden">
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" style={{ minHeight: 400 }} onClick={handleMapClick} onMapReady={handleMapReady} />
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" className="min-h-[400px]" onClick={handleMapClick} onMapReady={handleMapReady} />
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1"> </div>

파일 보기

@ -300,7 +300,7 @@ export function MapControl() {
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => 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.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -309,7 +309,7 @@ export function MapControl() {
<div className="flex items-center gap-1.5 ml-auto">
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button key={s} onClick={() => setSeaFilter(s)}
<button type="button" key={s} onClick={() => 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 || '전체'}
</button>
@ -340,7 +340,7 @@ export function MapControl() {
<Filter className="w-3.5 h-3.5 text-hint" />
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
<button type="button" key={c} onClick={() => 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}</button>
))}
</div>

파일 보기

@ -180,7 +180,7 @@ export function VesselDetail() {
<input value={searchMmsi} onChange={(e) => 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" />
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<button type="button" className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
@ -414,19 +414,19 @@ export function VesselDetail() {
{/* ── 우측 도구바 ── */}
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
{RIGHT_TOOLS.map((t) => (
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
<button type="button" key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
</button>
))}
<div className="flex-1" />
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
<div className="h-px bg-white/[0.06]" />
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
</div>
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
<button type="button" className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]"></span></button>
<button type="button" className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]"></span></button>
<button type="button" className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
</div>
</PageContainer>
);

파일 보기

@ -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
*
* :
* <TabBar>
* <TabButton active={tab === 'a'} onClick={() => setTab('a')}> A</TabButton>
* <TabButton active={tab === 'b'} onClick={() => setTab('b')}> B</TabButton>
* </TabBar>
*/
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 <div className={cn(variantClass, className)}>{children}</div>;
}
export interface TabButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<button type={type} className={cn(variantClass, className)} {...props}>
{icon}
{children}
</button>
);
}