kcg-ai-monitoring/frontend/src/features/admin/SystemConfig.tsx
htlee c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
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 확인됨
2026-04-08 13:04:23 +09:00

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>
);
}