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:
부모
2483174081
커밋
da4dc86e90
@ -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>
|
||||
);
|
||||
|
||||
78
frontend/src/shared/components/ui/tabs.tsx
Normal file
78
frontend/src/shared/components/ui/tabs.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user