axe/forms/backdrop 에러 3종 모두 해결:
1) CSS: backdrop-filter Safari 호환성
- design-system CSS에 -webkit-backdrop-filter 추가
- trk-pulse 애니메이션을 outline-color → opacity로 변경
(composite만 트리거, paint/layout 없음 → 더 나은 성능)
2) 아이콘 전용 <button> aria-label 추가 (9곳):
- MainLayout 알림 버튼 → '알림'
- UserRoleAssignDialog 닫기 → '닫기'
- AIAssistant/MLOpsPage 전송 → '전송'
- ChinaFishing 좌/우 네비 → '이전'/'다음'
- 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정
3) <input>/<textarea> 접근 이름 27곳 추가:
- 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
- NoticeManagement 제목/내용/시작일/종료일 (4)
- SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
- VesselDetail 조회 시작/종료/MMSI (3)
- GearIdentification InputField에 label prop 추가
- AIAssistant/MLOpsPage 질의 input/textarea
- MainLayout 페이지 내 검색
- 공통 placeholder → aria-label 자동 복제 (3)
Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).
검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc ✅, eslint ✅, vite build ✅
- dist CSS에 -webkit-backdrop-filter 확인됨
491 lines
24 KiB
TypeScript
491 lines
24 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
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 { PageContainer, PageHeader } from '@shared/components/layout';
|
|
import type { BadgeIntent } from '@lib/theme/variants';
|
|
import {
|
|
Settings, Database, Search, ChevronDown, ChevronRight,
|
|
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
|
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
|
|
} from 'lucide-react';
|
|
import {
|
|
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
|
CODE_STATS, getAreaMajors, getSpeciesMajors, getFisheryMajors, getVesselTypeMajors,
|
|
filterByMajor, searchCodes,
|
|
type AreaCode, type SpeciesCode, type FisheryCode, type VesselTypeCode,
|
|
} from '@/data/commonCodes';
|
|
|
|
/*
|
|
* SFR-02: 시스템 기본 환경설정 및 공통 기능
|
|
* - 공통코드 기준정보 조회·검색·필터링
|
|
* - 해역분류(52) / 어종(578) / 어업유형(59) / 선박유형(186)
|
|
* - 시스템 기본 환경설정 관리
|
|
*/
|
|
|
|
type CodeTab = 'areas' | 'species' | 'fishery' | 'vessels' | 'settings';
|
|
|
|
const TAB_ITEMS: { key: CodeTab; icon: React.ElementType; label: string; count?: number }[] = [
|
|
{ key: 'areas', icon: Map, label: '해역분류', count: CODE_STATS.areas },
|
|
{ key: 'species', icon: Fish, label: '어종', count: CODE_STATS.species },
|
|
{ key: 'fishery', icon: Anchor, label: '어업유형', count: CODE_STATS.fishery },
|
|
{ key: 'vessels', icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes },
|
|
{ key: 'settings', icon: Settings, label: '환경설정' },
|
|
];
|
|
|
|
const PAGE_SIZE = 30;
|
|
|
|
// ─── 시스템 설정 기본값 ──────────────────
|
|
const SYSTEM_SETTINGS = {
|
|
general: [
|
|
{ key: 'system_name', label: '시스템명', value: 'AI 기반 불법조업 탐지·차단 플랫폼', type: 'text' },
|
|
{ key: 'org_name', label: '운영기관', value: '해양경찰청', type: 'text' },
|
|
{ key: 'version', label: '시스템 버전', value: 'v1.0.0', type: 'text' },
|
|
{ key: 'timezone', label: '시간대', value: 'Asia/Seoul (UTC+9)', type: 'text' },
|
|
{ key: 'language', label: '기본 언어', value: '한국어 (ko-KR)', type: 'text' },
|
|
{ key: 'date_format', label: '날짜 형식', value: 'YYYY-MM-DD HH:mm:ss', type: 'text' },
|
|
{ key: 'coord_format', label: '좌표 형식', value: 'DD.DDDDDD (십진도)', type: 'select' },
|
|
],
|
|
map: [
|
|
{ key: 'default_center', label: '기본 지도 중심', value: 'N35.5° E127.0°', type: 'text' },
|
|
{ key: 'default_zoom', label: '기본 줌 레벨', value: '7', type: 'number' },
|
|
{ key: 'map_provider', label: '지도 타일', value: 'CartoDB Dark Matter', type: 'select' },
|
|
{ key: 'eez_layer', label: 'EEZ 경계선', value: '활성', type: 'toggle' },
|
|
{ key: 'nll_layer', label: 'NLL 표시', value: '활성', type: 'toggle' },
|
|
{ key: 'ais_refresh', label: 'AIS 갱신 주기', value: '10초', type: 'select' },
|
|
{ key: 'trail_length', label: '항적 표시 기간', value: '24시간', type: 'select' },
|
|
],
|
|
alert: [
|
|
{ key: 'eez_alert', label: 'EEZ 침범 경보', value: '활성', type: 'toggle' },
|
|
{ key: 'dark_vessel', label: '다크베셀 경보', value: '활성', type: 'toggle' },
|
|
{ key: 'mmsi_spoof', label: 'MMSI 변조 경보', value: '활성', type: 'toggle' },
|
|
{ key: 'transfer_alert', label: '불법환적 경보', value: '활성', type: 'toggle' },
|
|
{ key: 'speed_alert', label: '속도이상 경보', value: '활성', type: 'toggle' },
|
|
{ key: 'alert_sound', label: '경보 사운드', value: '활성', type: 'toggle' },
|
|
{ key: 'alert_retention', label: '경보 보존기간', value: '90일', type: 'select' },
|
|
],
|
|
data: [
|
|
{ key: 'ais_source', label: 'AIS 데이터 출처', value: 'LRIT / S-AIS / T-AIS 통합', type: 'text' },
|
|
{ key: 'vms_source', label: 'VMS 데이터 출처', value: '해수부 VMS 연동', type: 'text' },
|
|
{ key: 'sat_source', label: '위성 데이터', value: 'SAR / 광학위성 연동', type: 'text' },
|
|
{ key: 'data_retention', label: '데이터 보존기간', value: '5년', type: 'select' },
|
|
{ key: 'backup_cycle', label: '백업 주기', value: '일 1회 (03:00)', type: 'select' },
|
|
{ key: 'db_encryption', label: 'DB 암호화', value: 'AES-256', type: 'text' },
|
|
],
|
|
};
|
|
|
|
export function SystemConfig() {
|
|
const { t } = useTranslation('admin');
|
|
const [tab, setTab] = useState<CodeTab>('areas');
|
|
const [query, setQuery] = useState('');
|
|
const [majorFilter, setMajorFilter] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
|
|
// 탭 변경 시 필터 초기화
|
|
const changeTab = (t: CodeTab) => {
|
|
setTab(t);
|
|
setQuery('');
|
|
setMajorFilter('');
|
|
setPage(0);
|
|
setExpandedRow(null);
|
|
};
|
|
|
|
// ─── 해역분류 필터링 ─────────────────
|
|
const filteredAreas = useMemo(() => {
|
|
const result = filterByMajor(AREA_CODES, majorFilter);
|
|
return searchCodes(result, query);
|
|
}, [query, majorFilter]);
|
|
|
|
// ─── 어종 필터링 ──────────────────────
|
|
const filteredSpecies = useMemo(() => {
|
|
const result = filterByMajor(SPECIES_CODES, majorFilter);
|
|
if (query) {
|
|
const q = query.toLowerCase();
|
|
return result.filter(
|
|
(s) => s.code.includes(q) || s.name.includes(q) || s.nameEn.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return result;
|
|
}, [query, majorFilter]);
|
|
|
|
// ─── 어업유형 필터링 ──────────────────
|
|
const filteredFishery = useMemo(() => {
|
|
const result = filterByMajor(FISHERY_CODES, majorFilter);
|
|
return searchCodes(result, query);
|
|
}, [query, majorFilter]);
|
|
|
|
// ─── 선박유형 필터링 ──────────────────
|
|
const filteredVessels = useMemo(() => {
|
|
const result = filterByMajor(VESSEL_TYPE_CODES, majorFilter);
|
|
if (query) {
|
|
const q = query.toLowerCase();
|
|
return result.filter(
|
|
(v) => v.code.toLowerCase().includes(q) || v.name.toLowerCase().includes(q) || v.aisCode.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return result;
|
|
}, [query, majorFilter]);
|
|
|
|
// ─── 현재 탭에 따른 데이터 ────────────
|
|
const currentData = tab === 'areas' ? filteredAreas
|
|
: tab === 'species' ? filteredSpecies
|
|
: tab === 'fishery' ? filteredFishery
|
|
: tab === 'vessels' ? filteredVessels
|
|
: [];
|
|
|
|
const totalItems = currentData.length;
|
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
|
|
const pagedData = currentData.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
|
|
const majors = tab === 'areas' ? getAreaMajors()
|
|
: tab === 'species' ? getSpeciesMajors()
|
|
: tab === 'fishery' ? getFisheryMajors()
|
|
: tab === 'vessels' ? getVesselTypeMajors()
|
|
: [];
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
icon={Database}
|
|
iconColor="text-cyan-400"
|
|
title={t('systemConfig.title')}
|
|
description={t('systemConfig.desc')}
|
|
demo
|
|
actions={
|
|
<>
|
|
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
|
|
내보내기
|
|
</Button>
|
|
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
|
|
코드 동기화
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* KPI 카드 */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
{[
|
|
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
|
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
|
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
|
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
|
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
|
].map((kpi) => (
|
|
<Card key={kpi.label} className="bg-surface-raised border-border">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
|
<kpi.icon className={`w-4 h-4 ${kpi.color}`} />
|
|
</div>
|
|
<div>
|
|
<div className={`text-lg font-bold ${kpi.color}`}>{kpi.count.toLocaleString()}</div>
|
|
<div className="text-[10px] text-muted-foreground">{kpi.label}</div>
|
|
<div className="text-[8px] text-hint">{kpi.desc}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* 탭 */}
|
|
<div className="flex gap-1">
|
|
{TAB_ITEMS.map((t) => (
|
|
<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 ${
|
|
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
|
}`}
|
|
>
|
|
<t.icon className="w-3.5 h-3.5" />
|
|
{t.label}
|
|
{t.count != null && (
|
|
<span className={`text-[9px] ml-1 px-1.5 py-0.5 rounded ${
|
|
tab === t.key ? 'bg-white/20' : 'bg-switch-background/50'
|
|
}`}>{t.count}</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 검색·필터 (코드 탭에서만) */}
|
|
{tab !== 'settings' && (
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
|
<input
|
|
aria-label="코드 검색"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
|
placeholder={
|
|
tab === 'areas' ? '코드번호, 해역명 검색...'
|
|
: tab === 'species' ? '코드, 어종명, 영문명 검색...'
|
|
: tab === 'fishery' ? '코드, 어업유형명 검색...'
|
|
: '코드, 선박유형명, AIS코드 검색...'
|
|
}
|
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-4 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-cyan-500/50"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Filter className="w-3.5 h-3.5 text-hint" />
|
|
<select
|
|
aria-label="대분류 필터"
|
|
value={majorFilter}
|
|
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
|
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
|
>
|
|
<option value="">전체 대분류</option>
|
|
{majors.map((m) => (
|
|
<option key={m} value={m}>{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="text-[10px] text-hint ml-auto">
|
|
<Hash className="w-3 h-3 inline mr-1" />
|
|
{totalItems.toLocaleString()}건
|
|
{query && ` (검색: "${query}")`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 해역분류 테이블 ── */}
|
|
{tab === 'areas' && (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<table className="w-full text-[11px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-hint">
|
|
<th className="px-4 py-2.5 text-left font-medium w-24">코드</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-20">대분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-28">중분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">해역명</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">관할기관</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-32">비고</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(pagedData as AreaCode[]).map((a) => (
|
|
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
|
|
<td className="px-4 py-2">
|
|
{(() => {
|
|
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
|
return <Badge intent={intent} size="xs">{a.major}</Badge>;
|
|
})()}
|
|
</td>
|
|
<td className="px-4 py-2 text-label">{a.mid}</td>
|
|
<td className="px-4 py-2 text-heading font-medium">{a.name}</td>
|
|
<td className="px-4 py-2 text-muted-foreground">{a.authority}</td>
|
|
<td className="px-4 py-2 text-hint text-[10px]">{a.note}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── 어종 테이블 ── */}
|
|
{tab === 'species' && (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<table className="w-full text-[11px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-hint">
|
|
<th className="px-4 py-2.5 text-left font-medium w-20">코드</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-20">대분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-24">중분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">어종명</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">영문명</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">서식해역</th>
|
|
<th className="px-4 py-2.5 text-center font-medium w-14">사용</th>
|
|
<th className="px-4 py-2.5 text-center font-medium w-14">낚시</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(pagedData as SpeciesCode[]).map((s) => (
|
|
<tr
|
|
key={s.code}
|
|
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
|
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
|
>
|
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
|
<td className="px-4 py-2">
|
|
<Badge intent="muted" size="sm">{s.major}</Badge>
|
|
</td>
|
|
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
|
|
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
|
|
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.nameEn}</td>
|
|
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
|
|
<td className="px-4 py-2 text-center">
|
|
{s.active
|
|
? <span className="text-green-400 text-[9px]">Y</span>
|
|
: <span className="text-hint text-[9px]">N</span>
|
|
}
|
|
</td>
|
|
<td className="px-4 py-2 text-center">
|
|
{s.fishing && <span className="text-yellow-400">★</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── 어업유형 테이블 ── */}
|
|
{tab === 'fishery' && (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<table className="w-full text-[11px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-hint">
|
|
<th className="px-4 py-2.5 text-left font-medium w-24">코드</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-24">대분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-20">중분류</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">어업유형명</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">주요 어획대상</th>
|
|
<th className="px-4 py-2.5 text-left font-medium w-16">허가</th>
|
|
<th className="px-4 py-2.5 text-left font-medium">법적 근거</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(pagedData as FisheryCode[]).map((f) => (
|
|
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
|
|
<td className="px-4 py-2">
|
|
{(() => {
|
|
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
|
|
return <Badge intent={intent} size="xs">{f.major}</Badge>;
|
|
})()}
|
|
</td>
|
|
<td className="px-4 py-2 text-label">{f.mid}</td>
|
|
<td className="px-4 py-2 text-heading font-medium">{f.name}</td>
|
|
<td className="px-4 py-2 text-muted-foreground">{f.target}</td>
|
|
<td className="px-4 py-2">
|
|
<Badge intent={f.permit === '허가' ? 'info' : f.permit === '면허' ? 'success' : 'muted'} size="xs">{f.permit}</Badge>
|
|
</td>
|
|
<td className="px-4 py-2 text-hint text-[10px]">{f.law}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── 선박유형 테이블 ── */}
|
|
{tab === 'vessels' && (
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<table className="w-full text-[11px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-hint">
|
|
<th className="px-3 py-2.5 text-left font-medium w-20">코드</th>
|
|
<th className="px-3 py-2.5 text-left font-medium w-20">대분류</th>
|
|
<th className="px-3 py-2.5 text-left font-medium w-24">중분류</th>
|
|
<th className="px-3 py-2.5 text-left font-medium">선박유형명</th>
|
|
<th className="px-3 py-2.5 text-left font-medium w-14">출처</th>
|
|
<th className="px-3 py-2.5 text-left font-medium w-28">톤수기준</th>
|
|
<th className="px-3 py-2.5 text-left font-medium">주요용도</th>
|
|
<th className="px-3 py-2.5 text-left font-medium w-20">AIS코드</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(pagedData as VesselTypeCode[]).map((v) => (
|
|
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
|
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
|
|
<td className="px-3 py-2">
|
|
{(() => {
|
|
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
|
|
return <Badge intent={intent} size="xs">{v.major}</Badge>;
|
|
})()}
|
|
</td>
|
|
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
|
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`text-[9px] font-mono ${
|
|
v.source === 'AIS' ? 'text-cyan-400'
|
|
: v.source === 'GIC' ? 'text-green-400'
|
|
: v.source === 'RRA' ? 'text-blue-400'
|
|
: v.source === 'PMS' ? 'text-orange-400'
|
|
: 'text-muted-foreground'
|
|
}`}>{v.source}</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
|
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
|
|
<td className="px-3 py-2 text-hint font-mono text-[10px]">{v.aisCode}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 페이지네이션 (코드 탭에서만) */}
|
|
{tab !== 'settings' && totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<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"
|
|
>
|
|
이전
|
|
</button>
|
|
<span className="text-[11px] text-hint">
|
|
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
|
</span>
|
|
<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"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 환경설정 탭 ── */}
|
|
{tab === 'settings' && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{Object.entries(SYSTEM_SETTINGS).map(([section, items]) => {
|
|
const sectionLabels: Record<string, { title: string; icon: React.ElementType }> = {
|
|
general: { title: '일반 설정', icon: Settings },
|
|
map: { title: '지도 설정', icon: Map },
|
|
alert: { title: '경보 설정', icon: BarChart3 },
|
|
data: { title: '데이터 설정', icon: Database },
|
|
};
|
|
const meta = sectionLabels[section] || { title: section, icon: Info };
|
|
return (
|
|
<Card key={section} className="bg-surface-raised border-border">
|
|
<CardHeader className="px-4 pt-3 pb-2">
|
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
|
<meta.icon className="w-3.5 h-3.5 text-cyan-400" />
|
|
{meta.title}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 space-y-2">
|
|
{items.map((item) => (
|
|
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
|
<span className="text-hint">{item.label}</span>
|
|
<span className={`font-medium ${
|
|
item.value === '활성' ? 'text-green-400' : 'text-label'
|
|
}`}>{item.value}</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
}
|