Compare commits
2 커밋
2c23049c8e
...
5731fa30a1
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 5731fa30a1 | |||
| c1cc36b134 |
@ -5,6 +5,7 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 변경
|
||||
- **디자인 시스템 SSOT 일괄 준수 (30파일)** — `frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>` → `<Button variant>` / raw `<input>` → `<Input>` / raw `<select>` → `<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
|
||||
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)** — `common.json` 에 `aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
|
||||
- **iran 백엔드 프록시 잔재 제거** — `IranBackendClient` dead class 삭제, `application.yml` 의 `iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)` → `AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
|
||||
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
|
||||
|
||||
@ -146,15 +146,23 @@ export function AccessControl() {
|
||||
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button type="button" onClick={() => setAssignTarget(row)}
|
||||
className="p-1 text-hint hover:text-heading" title="역할 배정">
|
||||
<UserCog className="w-3 h-3" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAssignTarget(row)}
|
||||
aria-label="역할 배정"
|
||||
title="역할 배정"
|
||||
icon={<UserCog className="w-3 h-3" />}
|
||||
/>
|
||||
{row.userSttsCd === 'LOCKED' && (
|
||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||
className="p-1 text-hint hover:text-label" title="잠금 해제">
|
||||
<Key className="w-3 h-3" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||
aria-label="잠금 해제"
|
||||
title="잠금 해제"
|
||||
icon={<Key className="w-3 h-3" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -124,7 +124,7 @@ export function DataModelVerification() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={ListChecks}
|
||||
iconColor="text-green-400"
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
title="데이터 모델 검증"
|
||||
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
|
||||
demo
|
||||
@ -157,7 +157,7 @@ export function DataModelVerification() {
|
||||
{/* 검증 절차 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ClipboardCheck className="w-4 h-4 text-green-400" />
|
||||
<ClipboardCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[12px] font-bold text-heading">검증 절차 (4단계)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@ -168,14 +168,14 @@ export function DataModelVerification() {
|
||||
)}
|
||||
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<s.icon className="w-4 h-4 text-green-400" />
|
||||
<span className="text-[11px] font-bold text-green-400">{s.phase}</span>
|
||||
<s.icon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[11px] font-bold text-green-600 dark:text-green-400">{s.phase}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
|
||||
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
|
||||
<ul className="space-y-1.5">
|
||||
{s.actions.map(a => (
|
||||
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<span>{a}</span>
|
||||
</li>
|
||||
))}
|
||||
@ -190,7 +190,7 @@ export function DataModelVerification() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-blue-400" />
|
||||
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">검증 참여자</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -207,7 +207,7 @@ export function DataModelVerification() {
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Layers className="w-4 h-4 text-purple-400" />
|
||||
<Layers className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">데이터 주제영역 ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)}개 테이블)</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@ -229,7 +229,7 @@ export function DataModelVerification() {
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 text-green-400" />
|
||||
<GitBranch className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[12px] font-bold text-heading">논리 데이터 모델 검증 기준 및 결과</span>
|
||||
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} 통과</Badge>
|
||||
</div>
|
||||
@ -249,7 +249,7 @@ export function DataModelVerification() {
|
||||
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
@ -279,7 +279,7 @@ export function DataModelVerification() {
|
||||
{tab === 'physical' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">물리 데이터 모델 검증 기준 및 결과</span>
|
||||
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} 통과</Badge>
|
||||
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
|
||||
@ -302,7 +302,7 @@ export function DataModelVerification() {
|
||||
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
|
||||
<td className="py-2.5 text-heading font-medium">{c.item}</td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
@ -315,7 +315,7 @@ export function DataModelVerification() {
|
||||
{tab === 'duplication' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-cyan-400" />
|
||||
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">중복 테이블·컬럼 및 데이터 정합성 점검</span>
|
||||
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} 통과</Badge>
|
||||
</div>
|
||||
@ -335,7 +335,7 @@ export function DataModelVerification() {
|
||||
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
@ -348,7 +348,7 @@ export function DataModelVerification() {
|
||||
{tab === 'history' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">검증 결과 이력</span>
|
||||
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}건</Badge>
|
||||
</div>
|
||||
@ -372,7 +372,7 @@ export function DataModelVerification() {
|
||||
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
|
||||
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
|
||||
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
|
||||
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-400 font-bold">{h.issues}건</span> : <span className="text-green-400">0건</span>}</td>
|
||||
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-600 dark:text-yellow-400 font-bold">{h.issues}건</span> : <span className="text-green-600 dark:text-green-400">0건</span>}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -108,7 +108,7 @@ export function DataRetentionPolicy() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-blue-400"
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
title="데이터 보관기간 및 파기 정책"
|
||||
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
|
||||
demo
|
||||
@ -141,14 +141,14 @@ export function DataRetentionPolicy() {
|
||||
{/* 보관 구조 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Settings className="w-4 h-4 text-blue-400" />
|
||||
<Settings className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">전체 보관 구조 (4-Tier)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{STORAGE_ARCHITECTURE.map(s => (
|
||||
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<s.icon className="w-4 h-4 text-blue-400" />
|
||||
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
|
||||
@ -184,7 +184,7 @@ export function DataRetentionPolicy() {
|
||||
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
|
||||
].map(([k, v, s]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
|
||||
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-500" />}
|
||||
<span className="text-heading flex-1">{k}</span>
|
||||
<span className="text-hint">{v}</span>
|
||||
</div>
|
||||
@ -213,7 +213,7 @@ export function DataRetentionPolicy() {
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CalendarClock className="w-4 h-4 text-blue-400" />
|
||||
<CalendarClock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">데이터 유형별 보관기간 기준표</span>
|
||||
<Badge intent="info" size="xs">{RETENTION_TABLE.length}종 관리</Badge>
|
||||
</div>
|
||||
@ -235,7 +235,7 @@ export function DataRetentionPolicy() {
|
||||
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
|
||||
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-bold">{r.period}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-bold">{r.period}</td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
|
||||
@ -273,7 +273,7 @@ export function DataRetentionPolicy() {
|
||||
{/* 파기 승인 워크플로우 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="text-[12px] font-bold text-heading">파기 승인 절차 (4단계)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@ -284,14 +284,14 @@ export function DataRetentionPolicy() {
|
||||
)}
|
||||
<div className="bg-surface-overlay rounded-lg p-3 h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<s.icon className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
|
||||
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-blue-600 dark:text-blue-400">{s.phase}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
|
||||
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
|
||||
<ul className="space-y-1.5">
|
||||
{s.actions.map(a => (
|
||||
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
|
||||
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<span>{a}</span>
|
||||
</li>
|
||||
))}
|
||||
@ -305,7 +305,7 @@ export function DataRetentionPolicy() {
|
||||
{/* 파기 방식 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lock className="w-4 h-4 text-red-400" />
|
||||
<Lock className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="text-[12px] font-bold text-heading">파기 방식 정의</span>
|
||||
</div>
|
||||
<table className="w-full text-[10px]">
|
||||
@ -326,7 +326,7 @@ export function DataRetentionPolicy() {
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
|
||||
<td className="py-2.5 text-muted-foreground">{m.target}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
|
||||
<td className="py-2.5 text-center text-red-400 text-[9px] font-medium">{m.recovery}</td>
|
||||
<td className="py-2.5 text-center text-red-600 dark:text-red-400 text-[9px] font-medium">{m.recovery}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
@ -341,7 +341,7 @@ export function DataRetentionPolicy() {
|
||||
<div className="space-y-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ShieldCheck className="w-4 h-4 text-purple-400" />
|
||||
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">보존 연장 예외 현황</span>
|
||||
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length}건 연장 중</Badge>
|
||||
</div>
|
||||
@ -364,7 +364,7 @@ export function DataRetentionPolicy() {
|
||||
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
|
||||
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
|
||||
</tr>
|
||||
@ -375,13 +375,13 @@ export function DataRetentionPolicy() {
|
||||
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-[12px] font-bold text-heading">보존 연장 사유 유형</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EXCEPTION_RULES.map(r => (
|
||||
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<ShieldCheck className="w-4 h-4 text-purple-400" />
|
||||
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||
@ -398,7 +398,7 @@ export function DataRetentionPolicy() {
|
||||
{tab === 'audit' && (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-green-400" />
|
||||
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[12px] font-bold text-heading">파기 감사 대장</span>
|
||||
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}건</Badge>
|
||||
</div>
|
||||
@ -424,7 +424,7 @@ export function DataRetentionPolicy() {
|
||||
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
|
||||
<td className="py-2.5 text-center text-cyan-400 font-mono">{d.volume}</td>
|
||||
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-mono">{d.volume}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>
|
||||
|
||||
@ -153,7 +153,7 @@ export function PerformanceMonitoring() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
iconColor="text-cyan-400"
|
||||
iconColor="text-cyan-600 dark:text-cyan-400"
|
||||
title="성능 모니터링"
|
||||
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
|
||||
demo
|
||||
@ -192,7 +192,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 사용자 그룹별 SLO */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-cyan-400" />
|
||||
<Users className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">사용자 그룹별 SLO (총 2,900명 + 추정)</span>
|
||||
<Badge intent="info" size="xs">본청 200 · 상황실 100 확정</Badge>
|
||||
</div>
|
||||
@ -225,20 +225,20 @@ export function PerformanceMonitoring() {
|
||||
{/* 성능 영향 최소화 전략 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-amber-400" />
|
||||
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-[12px] font-bold text-heading">성능 영향 최소화 전략 (글로벌 AIS 대응)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{IMPACT_REDUCTION.map((s, i) => (
|
||||
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-400">{i + 1}</div>
|
||||
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-600 dark:text-amber-400">{i + 1}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
|
||||
<Badge intent="info" size="xs">{s.per}</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint mb-0.5">대상: {s.target}</div>
|
||||
<div className="text-[9px] text-green-400">효과: {s.effect}</div>
|
||||
<div className="text-[9px] text-green-600 dark:text-green-400">효과: {s.effect}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -253,7 +253,7 @@ export function PerformanceMonitoring() {
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-4 h-4 text-cyan-400" />
|
||||
<Gauge className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-01 서비스 응답성 — SLO vs 실측 (p50/p95/p99)</span>
|
||||
</div>
|
||||
<Badge intent="success" size="sm">TER-03 검증 통과</Badge>
|
||||
@ -273,7 +273,7 @@ export function PerformanceMonitoring() {
|
||||
{RESPONSE_SLO.map(r => (
|
||||
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-400 font-medium">{r.slo}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400 font-medium">{r.slo}</td>
|
||||
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
|
||||
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
|
||||
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
|
||||
@ -287,7 +287,7 @@ export function PerformanceMonitoring() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-purple-400" />
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">상황실 전용 SLO (24/7 100명)</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -304,8 +304,8 @@ export function PerformanceMonitoring() {
|
||||
<div className="text-[9px] text-hint">목표: {s.target}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-green-400 font-bold">{s.current}</span>
|
||||
{s.met ? <CheckCircle className="w-4 h-4 text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
|
||||
<span className="text-[11px] text-green-600 dark:text-green-400 font-bold">{s.current}</span>
|
||||
{s.met ? <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -314,32 +314,32 @@ export function PerformanceMonitoring() {
|
||||
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-4 h-4 text-blue-400" />
|
||||
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">측정 방법론</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-[11px]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">샘플링:</strong> <span className="text-label">1초 간격 p50/p95/p99 집계</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">도구:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">APM:</strong> <span className="text-label">분산 추적 + Trace ID 요청 단위 관통</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">API 재시도:</strong> <span className="text-label">3회 · Exponential Backoff · 타임아웃 3초</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">경보:</strong> <span className="text-label">SLO 위반 지속 5분 → PagerDuty</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">원인 분석:</strong> <span className="text-label">RED/USE 방법론 + 로그·메트릭·추적 상관 분석</span></div>
|
||||
</li>
|
||||
</ul>
|
||||
@ -354,7 +354,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 동시접속·TPS */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-blue-400" />
|
||||
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-02 동시접속·처리용량 (정상 피크 600 / 작전 피크 900)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@ -375,7 +375,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 배치 작업 현황 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-03 배치 · 대용량 처리 현황</span>
|
||||
<Badge intent="success" size="xs">SLA 준수 6/7</Badge>
|
||||
</div>
|
||||
@ -396,7 +396,7 @@ export function PerformanceMonitoring() {
|
||||
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
|
||||
<td className="py-2 px-2 text-hint">{j.schedule}</td>
|
||||
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-400">{j.sla}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{j.sla}</td>
|
||||
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
|
||||
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
|
||||
</tr>
|
||||
@ -408,7 +408,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 처리 볼륨 산정 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="w-4 h-4 text-cyan-400" />
|
||||
<HardDrive className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">데이터 처리 볼륨 (국내 + S&P 글로벌)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-[11px]">
|
||||
@ -420,12 +420,12 @@ export function PerformanceMonitoring() {
|
||||
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||
<div className="text-[10px] text-hint mb-1">일 적재 (필터·압축 후)</div>
|
||||
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
|
||||
<div className="text-[9px] text-green-400 mt-1">경계 필터링 50~80% 감축</div>
|
||||
<div className="text-[9px] text-green-600 dark:text-green-400 mt-1">경계 필터링 50~80% 감축</div>
|
||||
</div>
|
||||
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
|
||||
<div className="text-[10px] text-hint mb-1">3년 누적 (티어드)</div>
|
||||
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
|
||||
<div className="text-[9px] text-amber-400 mt-1">NAS 100TB → 객체스토리지 이관</div>
|
||||
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB → 객체스토리지 이관</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -438,7 +438,7 @@ export function PerformanceMonitoring() {
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-04 AI 모델 성능 지표</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -467,7 +467,7 @@ export function PerformanceMonitoring() {
|
||||
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
|
||||
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
|
||||
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-400">{m.rocAuc}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{m.rocAuc}</td>
|
||||
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
|
||||
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
|
||||
</tr>
|
||||
@ -479,28 +479,28 @@ export function PerformanceMonitoring() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[12px] font-bold text-heading">모델 성능 저하 대응</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-[11px]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">학습/검증/테스트 분할:</strong> <span className="text-label">70/15/15 비율, K-Fold 5</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">드리프트 탐지:</strong> <span className="text-label">입력 분포 KL divergence 주간 모니터링</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">성능 저하 임계:</strong> <span className="text-label">F1 3%p 하락 시 자동 재학습 트리거</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">설명가능성:</strong> <span className="text-label">Feature Importance + SHAP 값 제공</span></div>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
||||
<div><strong className="text-heading">A/B 테스트:</strong> <span className="text-label">Shadow → Canary 5% → 50% → 100% 단계 배포</span></div>
|
||||
</li>
|
||||
</ul>
|
||||
@ -508,7 +508,7 @@ export function PerformanceMonitoring() {
|
||||
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="w-4 h-4 text-amber-400" />
|
||||
<Cpu className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-[12px] font-bold text-heading">추론 성능 (GPU 활용)</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@ -551,7 +551,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 가용성 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-cyan-400" />
|
||||
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-05 가용성 및 장애복구 (목표 ≥ 99.9%)</span>
|
||||
</div>
|
||||
<table className="w-full text-[11px]">
|
||||
@ -570,7 +570,7 @@ export function PerformanceMonitoring() {
|
||||
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
|
||||
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
|
||||
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-400">{a.rto}</td>
|
||||
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{a.rto}</td>
|
||||
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
|
||||
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
|
||||
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
|
||||
@ -583,7 +583,7 @@ export function PerformanceMonitoring() {
|
||||
{/* 확장성 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Server className="w-4 h-4 text-purple-400" />
|
||||
<Server className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[12px] font-bold text-heading">PER-06 확장성 및 자원 사용률</span>
|
||||
<Badge intent="info" size="xs">2배(6,000명) 확장 목표</Badge>
|
||||
</div>
|
||||
@ -613,34 +613,34 @@ export function PerformanceMonitoring() {
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wifi className="w-4 h-4 text-cyan-400" />
|
||||
<Wifi className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[11px] font-bold text-heading">연간 가동률 목표</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-cyan-400">99.9%</div>
|
||||
<div className="text-2xl font-bold text-cyan-600 dark:text-cyan-400">99.9%</div>
|
||||
<div className="text-[9px] text-hint mt-1">월간 다운타임 ≤ 43분</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-purple-400" />
|
||||
<Clock className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-[11px] font-bold text-heading">RTO 평균</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-purple-400">≤ 60초</div>
|
||||
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">≤ 60초</div>
|
||||
<div className="text-[9px] text-hint mt-1">자동 페일오버 · Self-healing</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Database className="w-4 h-4 text-green-400" />
|
||||
<Database className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-[11px] font-bold text-heading">RPO 평균</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-400">≤ 10초</div>
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">≤ 10초</div>
|
||||
<div className="text-[9px] text-hint mt-1">실시간 복제 + 백업 이중화</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||
<TrendingUp className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-[11px] font-bold text-heading">Scale-out 여유</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-400">×2</div>
|
||||
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">×2</div>
|
||||
<div className="text-[9px] text-hint mt-1">6,000명까지 선형 확장</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import {
|
||||
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
|
||||
type RoleWithPermissions, type PermTreeNode, type PermEntry,
|
||||
@ -360,10 +361,13 @@ export function PermissionsPanel() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={load}
|
||||
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
aria-label={tc('aria.refresh')}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -380,28 +384,44 @@ export function PermissionsPanel() {
|
||||
<div className="text-xs text-label font-bold">역할</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{canCreateRole && (
|
||||
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
||||
className="p-1 text-hint hover:text-label" title="신규 역할">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
aria-label="신규 역할"
|
||||
title="신규 역할"
|
||||
icon={<Plus className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||||
<button type="button" onClick={handleDeleteRole}
|
||||
className="p-1 text-hint hover:text-heading" title="역할 삭제">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDeleteRole}
|
||||
aria-label="역할 삭제"
|
||||
title="역할 삭제"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||||
<input aria-label={tc('aria.roleCode')} value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||
placeholder="ROLE_CD (대문자)"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<input aria-label={tc('aria.roleName')} value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
placeholder={tc('aria.roleName')}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<Input
|
||||
aria-label={tc('aria.roleCode')}
|
||||
size="sm"
|
||||
value={newRoleCd}
|
||||
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||
placeholder="ROLE_CD (대문자)"
|
||||
/>
|
||||
<Input
|
||||
aria-label={tc('aria.roleName')}
|
||||
size="sm"
|
||||
value={newRoleNm}
|
||||
onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
placeholder={tc('aria.roleName')}
|
||||
/>
|
||||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||||
<div className="flex gap-1 pt-1">
|
||||
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { X, Check, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
||||
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
||||
|
||||
@ -101,15 +102,18 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={saving}
|
||||
className="px-4 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" /> : <Check className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
@ -80,7 +82,7 @@ export function AIAssistant() {
|
||||
<PageContainer className="h-full flex flex-col">
|
||||
<PageHeader
|
||||
icon={MessageSquare}
|
||||
iconColor="text-green-400"
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
title={t('assistant.title')}
|
||||
description={t('assistant.desc')}
|
||||
/>
|
||||
@ -92,7 +94,7 @@ export function AIAssistant() {
|
||||
<div className="space-y-1">
|
||||
{SAMPLE_CONVERSATIONS.map(c => (
|
||||
<div key={c.id} onClick={() => setSelectedConv(c.id)}
|
||||
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
|
||||
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
|
||||
<div className="truncate">{c.title}</div>
|
||||
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
|
||||
</div>
|
||||
@ -112,7 +114,7 @@ export function AIAssistant() {
|
||||
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
|
||||
<Bot className="w-4 h-4 text-green-400" />
|
||||
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
|
||||
@ -124,7 +126,7 @@ export function AIAssistant() {
|
||||
{msg.refs && msg.refs.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
|
||||
{msg.refs.map(r => (
|
||||
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
|
||||
<Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
|
||||
<FileText className="w-2.5 h-2.5" />{r}
|
||||
</Badge>
|
||||
))}
|
||||
@ -133,7 +135,7 @@ export function AIAssistant() {
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
|
||||
<User className="w-4 h-4 text-blue-400" />
|
||||
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -141,17 +143,22 @@ export function AIAssistant() {
|
||||
</div>
|
||||
{/* 입력창 */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input
|
||||
<Input
|
||||
aria-label="AI 어시스턴트 질의"
|
||||
size="md"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
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"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleSend}
|
||||
aria-label={tc('aria.send')}
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
<button type="button" aria-label={tc('aria.send')} 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState } 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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import {
|
||||
@ -57,7 +58,7 @@ const MODELS: ModelVersion[] = [
|
||||
];
|
||||
|
||||
const modelColumns: DataColumn<ModelVersion>[] = [
|
||||
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-bold">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
@ -68,7 +69,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
|
||||
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
|
||||
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
|
||||
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-600 dark:text-green-400' : n < 15 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}>{n}%</span>; },
|
||||
},
|
||||
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
|
||||
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
@ -175,7 +176,7 @@ const GEAR_CODES: GearCode[] = [
|
||||
];
|
||||
|
||||
const gearColumns: DataColumn<GearCode>[] = [
|
||||
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
@ -396,14 +397,14 @@ export function AIModelManagement() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Brain}
|
||||
iconColor="text-purple-400"
|
||||
iconColor="text-purple-600 dark:text-purple-400"
|
||||
title={t('modelManagement.title')}
|
||||
description={t('modelManagement.desc')}
|
||||
demo
|
||||
actions={
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
||||
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
||||
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
|
||||
</div>
|
||||
}
|
||||
@ -412,12 +413,12 @@ export function AIModelManagement() {
|
||||
{/* KPI */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-600 dark:text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||
@ -454,13 +455,13 @@ export function AIModelManagement() {
|
||||
{/* 업데이트 알림 */}
|
||||
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm text-blue-300 font-bold">새로운 모델 v2.4.0 테스트 완료</div>
|
||||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<Button variant="primary" size="sm" className="shrink-0">운영 배포</Button>
|
||||
</div>
|
||||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||
</div>
|
||||
@ -495,7 +496,7 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[9px] text-hint">가중치</div>
|
||||
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
|
||||
<div className="text-[12px] font-bold text-cyan-600 dark:text-cyan-400">{rule.weight}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -505,7 +506,7 @@ export function AIModelManagement() {
|
||||
{/* 가중치 합계 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" />위험도 가중치</div>
|
||||
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />위험도 가중치</div>
|
||||
<div className="space-y-4">
|
||||
{rules.filter((r) => r.enabled).map((r, i) => (
|
||||
<div key={i}>
|
||||
@ -564,7 +565,7 @@ export function AIModelManagement() {
|
||||
{/* 파이프라인 스테이지 */}
|
||||
<div className="flex gap-2">
|
||||
{PIPELINE_STAGES.map((stage, i) => {
|
||||
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
|
||||
const stColor = stage.status === '정상' ? 'text-green-600 dark:text-green-400' : stage.status === '진행중' ? 'text-blue-600 dark:text-blue-400' : 'text-hint';
|
||||
return (
|
||||
<div key={stage.stage} className="flex-1 flex items-start gap-2">
|
||||
<Card className="flex-1 bg-surface-raised border-border">
|
||||
@ -695,7 +696,7 @@ export function AIModelManagement() {
|
||||
<div key={kpi.label}>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">{kpi.label}</span>
|
||||
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
|
||||
<span className={achieved ? 'text-green-600 dark:text-green-400 font-bold' : 'text-red-600 dark:text-red-400 font-bold'}>
|
||||
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
|
||||
</span>
|
||||
</div>
|
||||
@ -761,7 +762,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Anchor className="w-4 h-4 text-cyan-400" />
|
||||
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
5종 어구 특성 비교 요약
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -781,7 +782,7 @@ export function AIModelManagement() {
|
||||
{DAR03_GEAR_SUMMARY.map((g) => (
|
||||
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
|
||||
<td className="py-2 px-2">
|
||||
<span className="text-cyan-400 font-mono mr-2">{g.no}</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-2">{g.no}</span>
|
||||
<span className="text-heading font-medium">{g.name}</span>
|
||||
</td>
|
||||
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
|
||||
@ -790,7 +791,7 @@ export function AIModelManagement() {
|
||||
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
|
||||
<td className="py-2 px-2 text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
|
||||
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -802,7 +803,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4 text-blue-400" />
|
||||
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
어구별 구조 도식 비교
|
||||
</CardTitle>
|
||||
<p className="text-[9px] text-hint italic">
|
||||
@ -815,7 +816,7 @@ export function AIModelManagement() {
|
||||
<Card key={g.no} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-[12px] font-bold text-heading">{g.name}</div>
|
||||
<div className="text-[9px] text-hint">{g.nameEn}</div>
|
||||
@ -854,7 +855,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-purple-400" />
|
||||
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
어구별 AIS 신호 특성 및 이상 판정 기준
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -873,7 +874,7 @@ export function AIModelManagement() {
|
||||
{DAR03_AIS_SIGNALS.map((s) => (
|
||||
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
|
||||
<td className="py-2.5 px-2">
|
||||
<span className="text-cyan-400 font-mono mr-1">{s.no}</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-1">{s.no}</span>
|
||||
<span className="text-heading font-medium">{s.name}</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-label">{s.aisType}</td>
|
||||
@ -891,13 +892,13 @@ export function AIModelManagement() {
|
||||
<ul className="space-y-0.5">
|
||||
{s.threshold.map((th) => (
|
||||
<li key={th} className="text-muted-foreground flex items-start gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-orange-400 shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
|
||||
<span>{th}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
|
||||
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -921,8 +922,8 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div className="ml-auto flex gap-3 shrink-0 text-center">
|
||||
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint">허가 선박</div></div>
|
||||
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
||||
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
||||
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
||||
<div><div className="text-lg font-bold text-green-600 dark:text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -974,7 +975,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<Ship className="w-4 h-4 text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
||||
<Ship className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
||||
</div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
@ -991,7 +992,7 @@ export function AIModelManagement() {
|
||||
<tbody>
|
||||
{TARGET_VESSELS.map((v) => (
|
||||
<tr key={v.code} className="border-b border-border">
|
||||
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
|
||||
<td className="py-1.5 text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v.code}</td>
|
||||
<td className="py-1.5 text-label">{v.name}</td>
|
||||
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
|
||||
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
|
||||
@ -1014,7 +1015,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />알람 심각도 체계
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />알람 심각도 체계
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{ALARM_SEVERITY.map((a) => (
|
||||
@ -1064,8 +1065,8 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div className="flex gap-4 shrink-0 text-center">
|
||||
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API 엔드포인트</div></div>
|
||||
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
||||
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
||||
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
||||
<div><div className="text-lg font-bold text-blue-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1114,7 +1115,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<Code className="w-4 h-4 text-cyan-400" />
|
||||
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
RESTful API 엔드포인트
|
||||
</div>
|
||||
<table className="w-full text-[10px] table-fixed">
|
||||
@ -1155,7 +1156,7 @@ export function AIModelManagement() {
|
||||
<td className="py-1.5">
|
||||
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
|
||||
<td className="py-1.5 font-mono text-cyan-600 dark:text-cyan-400">{api.endpoint}</td>
|
||||
<td className="py-1.5 text-hint">{api.unit}</td>
|
||||
<td className="py-1.5 text-label">{api.desc}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
|
||||
@ -1175,7 +1176,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<Code className="w-4 h-4 text-cyan-400" />
|
||||
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
API 호출 예시
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@ -1231,7 +1232,7 @@ export function AIModelManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<ExternalLink className="w-4 h-4 text-purple-400" />
|
||||
<ExternalLink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
후속 서비스 연계 매핑
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -1255,7 +1256,7 @@ export function AIModelManagement() {
|
||||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{s.apis.map((a) => (
|
||||
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
|
||||
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-600 dark:text-cyan-400">{a}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -1272,13 +1273,13 @@ export function AIModelManagement() {
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ label: '총 호출', value: '142,856', color: 'text-heading' },
|
||||
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
|
||||
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
|
||||
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
|
||||
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
|
||||
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
|
||||
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
|
||||
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
|
||||
{ label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
|
||||
{ label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
|
||||
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
|
||||
{ label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
|
||||
{ label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
|
||||
{ label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
|
||||
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
@ -117,7 +118,7 @@ export function MLOpsPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Cpu}
|
||||
iconColor="text-purple-400"
|
||||
iconColor="text-purple-600 dark:text-purple-400"
|
||||
title={t('mlops.title')}
|
||||
description={t('mlops.desc')}
|
||||
demo
|
||||
@ -135,7 +136,7 @@ export function MLOpsPage() {
|
||||
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
|
||||
]).map(t => (
|
||||
<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'}`}>
|
||||
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-600 dark: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>
|
||||
))}
|
||||
@ -160,7 +161,7 @@ export function MLOpsPage() {
|
||||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||||
<span className="text-[10px] text-hint">{m.ver}</span>
|
||||
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
|
||||
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
@ -188,7 +189,7 @@ export function MLOpsPage() {
|
||||
{TEMPLATES.map((t, i) => (
|
||||
<div key={t.name} onClick={() => setSelectedTmpl(i)}
|
||||
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
|
||||
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" />
|
||||
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
|
||||
<div className="text-[10px] font-bold text-heading">{t.name}</div>
|
||||
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
|
||||
</div>
|
||||
@ -198,7 +199,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 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>
|
||||
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>새 실험</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EXPERIMENTS.map(e => (
|
||||
@ -209,7 +210,7 @@ export function MLOpsPage() {
|
||||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>}
|
||||
{e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -262,7 +263,7 @@ export function MLOpsPage() {
|
||||
<tbody>{DEPLOYS.map((d, i) => (
|
||||
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td>
|
||||
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
|
||||
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
|
||||
<td className="px-3 py-2 text-label">{d.latency}</td>
|
||||
@ -289,7 +290,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 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>
|
||||
<Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}>배포</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -314,15 +315,15 @@ export function MLOpsPage() {
|
||||
"version": "v2.1.0"
|
||||
}`} />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<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 variant="primary" size="sm" icon={<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">
|
||||
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
|
||||
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
|
||||
<span className="text-muted-foreground">상태 <span className="text-green-400 font-bold">200 OK</span></span>
|
||||
<span className="text-muted-foreground">지연 <span className="text-green-400 font-bold">23ms</span></span>
|
||||
<span className="text-muted-foreground">상태 <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
|
||||
<span className="text-muted-foreground">지연 <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
|
||||
</div>
|
||||
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
|
||||
"risk_score": 87.5,
|
||||
@ -354,7 +355,7 @@ export function MLOpsPage() {
|
||||
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
||||
]).map(t => (
|
||||
<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>
|
||||
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -368,7 +369,7 @@ export function MLOpsPage() {
|
||||
{LLM_MODELS.map((m, i) => (
|
||||
<div key={m.name} onClick={() => setSelectedLLM(i)}
|
||||
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
||||
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" />
|
||||
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
|
||||
<div className="text-[10px] font-bold text-heading">{m.name}</div>
|
||||
<div className="text-[8px] text-hint">{m.sub}</div>
|
||||
</div>
|
||||
@ -382,7 +383,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 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>
|
||||
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}>학습 시작</Button>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -418,10 +419,10 @@ 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 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>
|
||||
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<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>
|
||||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||||
<table className="w-full text-[10px]">
|
||||
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
|
||||
<tbody>{HPS_TRIALS.map(t => (
|
||||
@ -506,7 +507,7 @@ export function MLOpsPage() {
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input aria-label="LLM 질의 입력" 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 type="button" aria-label={tc('aria.send')} 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 variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { LoginError } from '@/services/authApi';
|
||||
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
|
||||
@ -105,7 +106,7 @@ export function LoginPage() {
|
||||
{/* 로고 영역 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
|
||||
<Shield className="w-8 h-8 text-blue-400" />
|
||||
<Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
|
||||
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
|
||||
@ -122,7 +123,7 @@ export function LoginPage() {
|
||||
disabled={m.disabled}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
|
||||
authMethod === m.key
|
||||
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
|
||||
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
title={m.disabled ? '향후 도입 예정' : ''}
|
||||
@ -188,16 +189,18 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
className="w-full font-bold"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@ -205,7 +208,7 @@ export function LoginPage() {
|
||||
{t('button.authenticating')}
|
||||
</>
|
||||
) : t('button.login')}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
|
||||
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
|
||||
@ -215,7 +218,7 @@ export function LoginPage() {
|
||||
{/* GPKI 인증 (Phase 9 도입 예정) */}
|
||||
{authMethod === 'gpki' && (
|
||||
<div className="space-y-4 text-center py-12">
|
||||
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
|
||||
<Fingerprint className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||
</div>
|
||||
@ -224,7 +227,7 @@ export function LoginPage() {
|
||||
{/* SSO 연동 (Phase 9 도입 예정) */}
|
||||
{authMethod === 'sso' && (
|
||||
<div className="space-y-4 text-center py-12">
|
||||
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<KeyRound className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
||||
<p className="text-[10px] text-hint mt-1">향후 도입 예정 (Phase 9)</p>
|
||||
</div>
|
||||
|
||||
@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
|
||||
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
|
||||
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
|
||||
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
|
||||
const textColor = pct > 70 ? 'text-red-600 dark:text-red-400' : pct > 50 ? 'text-orange-600 dark:text-orange-400' : pct > 30 ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400';
|
||||
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -78,7 +78,7 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
|
||||
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
|
||||
<Icon className="w-4 h-4" style={{ color }} />
|
||||
</div>
|
||||
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
|
||||
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
|
||||
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{Math.abs(diff)}
|
||||
</div>
|
||||
@ -207,16 +207,16 @@ function SeaAreaMap() {
|
||||
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
|
||||
<div className="text-[8px] text-muted-foreground font-bold mb-1">위협 등급</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[7px] text-blue-400">낮음</span>
|
||||
<span className="text-[7px] text-blue-600 dark:text-blue-400">낮음</span>
|
||||
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
|
||||
<span className="text-[7px] text-red-400">높음</span>
|
||||
<span className="text-[7px] text-red-600 dark:text-red-400">높음</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* LIVE 인디케이터 */}
|
||||
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||
<Radar className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-[9px] text-blue-400 font-medium">실시간 해역 위협도</span>
|
||||
<span className="text-[9px] text-blue-600 dark:text-blue-400 font-medium">실시간 해역 위협도</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -468,8 +468,8 @@ export function Dashboard() {
|
||||
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
|
||||
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
|
||||
}}>{area.risk}</span>
|
||||
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
|
||||
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
|
||||
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-600 dark:text-red-400" />}
|
||||
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-600 dark:text-green-400" />}
|
||||
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]">—</span>}
|
||||
</div>
|
||||
))}
|
||||
@ -544,7 +544,7 @@ export function Dashboard() {
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Waves className="w-3.5 h-3.5 text-blue-400" />
|
||||
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
해상 기상 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -557,19 +557,19 @@ export function Dashboard() {
|
||||
<div className="text-[8px] text-hint">돌풍 {WEATHER_DATA.wind.gust}m/s</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
|
||||
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 mx-auto mb-1" />
|
||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
|
||||
<div className="text-[8px] text-hint">파고</div>
|
||||
<div className="text-[8px] text-hint">주기 {WEATHER_DATA.wave.period}s</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
|
||||
<Thermometer className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 mx-auto mb-1" />
|
||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
|
||||
<div className="text-[8px] text-hint">기온</div>
|
||||
<div className="text-[8px] text-hint">수온 {WEATHER_DATA.temp.water}°C</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
|
||||
<Eye className="w-3.5 h-3.5 text-green-600 dark:text-green-400 mx-auto mb-1" />
|
||||
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
|
||||
<div className="text-[8px] text-hint">시정</div>
|
||||
<div className="text-[8px] text-hint">해상{WEATHER_DATA.seaState}급</div>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||
import { PageContainer } from '@shared/components/layout';
|
||||
import {
|
||||
Search, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
@ -339,22 +343,19 @@ export function ChinaFishing() {
|
||||
return (
|
||||
<PageContainer size="sm">
|
||||
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
|
||||
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
|
||||
<TabBar variant="segmented">
|
||||
{modeTabs.map((tab) => (
|
||||
<button type="button"
|
||||
<TabButton
|
||||
key={tab.key}
|
||||
variant="segmented"
|
||||
active={mode === 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 ${
|
||||
mode === tab.key
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
||||
}`}
|
||||
icon={<tab.icon className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<tab.icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
</TabBar>
|
||||
|
||||
{/* 환적 탐지 모드 */}
|
||||
{mode === 'transfer' && <TransferView />}
|
||||
@ -372,7 +373,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiError && <div className="text-xs text-red-400">에러: {apiError}</div>}
|
||||
{apiError && <div className="text-xs text-red-600 dark:text-red-400">{tcCommon('error.errorPrefix', { msg: apiError })}</div>}
|
||||
|
||||
{apiLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
@ -389,16 +390,21 @@ export function ChinaFishing() {
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] text-label">기준 : {formatDateTime(new Date())}</span>
|
||||
</div>
|
||||
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadApi}
|
||||
aria-label={tcCommon('aria.refresh')}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
||||
<Search className="w-3.5 h-3.5 text-hint mr-2" />
|
||||
<input aria-label={tcCommon('aria.searchAreaOrZone')}
|
||||
<input
|
||||
aria-label={tcCommon('aria.searchAreaOrZone')}
|
||||
placeholder="해역 또는 해구 번호 검색"
|
||||
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
|
||||
<Search className="w-4 h-4 text-blue-600 dark:text-blue-500 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -456,13 +462,13 @@ export function ChinaFishing() {
|
||||
<div className="flex items-center justify-around mt-4">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">종합</span> 위험지수
|
||||
</div>
|
||||
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
||||
종합 <span className="text-blue-600 dark:text-blue-400 font-medium">안전지수</span>
|
||||
</div>
|
||||
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
|
||||
</div>
|
||||
@ -480,29 +486,32 @@ export function ChinaFishing() {
|
||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<select aria-label={tcCommon('aria.areaOfInterestSelect')} className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<Select
|
||||
size="sm"
|
||||
aria-label={tcCommon('aria.areaOfInterestSelect')}
|
||||
>
|
||||
<option>영역 A</option>
|
||||
<option>영역 B</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[9px] text-hint mb-3">설정한 관심 영역을 선택후 조회를 눌러주세요.</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Eye className="w-3.5 h-3.5 text-blue-400" />
|
||||
<Eye className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-muted-foreground">특이운항</span>
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||
<span className="text-muted-foreground">불법조업</span>
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<Radio className="w-3.5 h-3.5 text-purple-400" />
|
||||
<Radio className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-muted-foreground">비허가</span>
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
<span className="text-green-600 dark:text-green-400 font-bold ml-auto">정상</span>
|
||||
</div>
|
||||
</div>
|
||||
<CircleGauge
|
||||
@ -527,30 +536,26 @@ export function ChinaFishing() {
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
<TabBar variant="underline" className="border-slate-700/30">
|
||||
{vesselTabs.map((tab) => {
|
||||
const disabled = tab !== '특이운항';
|
||||
return (
|
||||
<button type="button"
|
||||
<TabButton
|
||||
key={tab}
|
||||
variant="underline"
|
||||
active={vesselTab === tab}
|
||||
onClick={() => !disabled && setVesselTab(tab)}
|
||||
disabled={disabled}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
vesselTab === tab
|
||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||
: disabled
|
||||
? 'text-hint opacity-50 cursor-not-allowed'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
className="flex-1 justify-center"
|
||||
>
|
||||
{tab}
|
||||
{disabled && (
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
)}
|
||||
</button>
|
||||
</TabButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabBar>
|
||||
|
||||
{/* 선박 목록 */}
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
@ -599,22 +604,20 @@ export function ChinaFishing() {
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 — 월별 집계 API 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
<TabBar variant="underline" className="border-slate-700/30">
|
||||
{statsTabs.map((tab) => (
|
||||
<button type="button"
|
||||
<TabButton
|
||||
key={tab}
|
||||
variant="underline"
|
||||
active={statsTab === tab}
|
||||
onClick={() => setStatsTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
statsTab === tab
|
||||
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
className="flex-1 justify-center"
|
||||
>
|
||||
{tab}
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
</button>
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
</TabBar>
|
||||
|
||||
<div className="p-4 flex gap-4">
|
||||
{/* 월별 통계 - API 미지원, 준비중 안내 */}
|
||||
@ -659,9 +662,9 @@ export function ChinaFishing() {
|
||||
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="px-4 pb-3 flex justify-end">
|
||||
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||
<Button variant="secondary" size="sm">
|
||||
다운로드
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -677,7 +680,7 @@ export function ChinaFishing() {
|
||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
<div className="flex gap-2">
|
||||
@ -704,11 +707,11 @@ export function ChinaFishing() {
|
||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
|
||||
<Cloud className="w-8 h-8 text-yellow-600 dark:text-yellow-400 mx-auto" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-muted-foreground">전남서부남해앞바다</div>
|
||||
@ -730,7 +733,7 @@ export function ChinaFishing() {
|
||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{VTS_ITEMS.map((vts) => (
|
||||
@ -738,22 +741,28 @@ export function ChinaFishing() {
|
||||
key={vts.name}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
|
||||
vts.active
|
||||
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
|
||||
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/20'
|
||||
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-500' : 'bg-muted'}`} />
|
||||
{vts.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button type="button" aria-label={tcCommon('aria.previous')} className="text-hint hover:text-heading transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" aria-label={tcCommon('aria.next')} className="text-hint hover:text-heading transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tcCommon('aria.previous')}
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tcCommon('aria.next')}
|
||||
icon={<ChevronRight className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -93,15 +93,18 @@ export function DarkVesselDetection() {
|
||||
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
|
||||
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
|
||||
: n >= 50 ? 'text-orange-600 dark:text-orange-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400';
|
||||
return <span className={`font-bold font-mono ${c}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'name', label: '선박 유형', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (v) => {
|
||||
const mmsi = v as string;
|
||||
return (
|
||||
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||
{mmsi}
|
||||
</button>
|
||||
@ -116,7 +119,10 @@ export function DarkVesselDetection() {
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
|
||||
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
|
||||
: n >= 50 ? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-green-600 dark:text-green-400';
|
||||
return <span className={`font-bold ${c}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
|
||||
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
|
||||
@ -252,7 +258,7 @@ export function DarkVesselDetection() {
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
@ -263,10 +269,10 @@ export function DarkVesselDetection() {
|
||||
{/* KPI — tier 기반 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
|
||||
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
|
||||
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
|
||||
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
|
||||
{ l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
|
||||
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
|
||||
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
|
||||
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
|
||||
].map((k) => (
|
||||
<div key={k.l}
|
||||
onClick={() => setTierFilter(k.filter)}
|
||||
@ -304,7 +310,7 @@ export function DarkVesselDetection() {
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
||||
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
||||
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -2,6 +2,8 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Checkbox } from '@shared/components/ui/checkbox';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
|
||||
@ -143,11 +145,13 @@ function FilterCheckGroup({ label, selected, onChange, options }: {
|
||||
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{options.map(o => (
|
||||
<label key={o.value} className="flex items-center gap-1.5 text-[10px] text-label cursor-pointer hover:text-heading">
|
||||
<input type="checkbox" checked={selected.has(o.value)} onChange={() => toggle(o.value)}
|
||||
className="w-3 h-3 rounded border-border accent-primary" />
|
||||
{o.label}
|
||||
</label>
|
||||
<Checkbox
|
||||
key={o.value}
|
||||
checked={selected.has(o.value)}
|
||||
onChange={() => toggle(o.value)}
|
||||
label={o.label}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -169,7 +173,7 @@ export function GearDetection() {
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
|
||||
{ key: 'owner', label: '어구 그룹', sortable: true,
|
||||
render: v => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||
render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||
{ key: 'memberCount', label: '멤버', width: '50px', align: 'center',
|
||||
render: v => <span className="font-mono text-[10px] text-label">{v as number}척</span> },
|
||||
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
|
||||
@ -191,7 +195,13 @@ export function GearDetection() {
|
||||
</div>
|
||||
) : <span className="text-hint text-[10px]">-</span> },
|
||||
{ key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true,
|
||||
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
render: v => {
|
||||
const r = v as string;
|
||||
const c = r === '고위험' ? 'text-red-600 dark:text-red-400'
|
||||
: r === '중위험' ? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-green-600 dark:text-green-400';
|
||||
return <span className={`text-[10px] font-bold ${c}`}>{r}</span>;
|
||||
} },
|
||||
{ key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
|
||||
render: v => {
|
||||
const s = v as string;
|
||||
@ -200,13 +210,13 @@ export function GearDetection() {
|
||||
return <Badge intent={intent} size="sm">{label}</Badge>;
|
||||
} },
|
||||
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
|
||||
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
|
||||
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
|
||||
{ key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
|
||||
render: (v: unknown) => {
|
||||
const s = v as number;
|
||||
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
|
||||
const pct = Math.round(s * 100);
|
||||
const c = pct >= 72 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint';
|
||||
const c = pct >= 72 ? 'text-green-600 dark:text-green-400' : pct >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-hint';
|
||||
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
|
||||
} },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
@ -460,7 +470,7 @@ export function GearDetection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
@ -472,9 +482,9 @@ export function GearDetection() {
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[
|
||||
{ l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
|
||||
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
|
||||
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
|
||||
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-400' },
|
||||
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
|
||||
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
|
||||
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
|
||||
].map(k => (
|
||||
<div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||
@ -510,11 +520,15 @@ export function GearDetection() {
|
||||
{hasActiveFilter && (
|
||||
<>
|
||||
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}건</span>
|
||||
<button type="button" aria-label={tc('aria.filterReset')}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tc('aria.filterReset')}
|
||||
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
|
||||
<X className="w-3 h-3" /> 초기화
|
||||
</button>
|
||||
icon={<X className="w-3 h-3" />}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -562,11 +576,15 @@ export function GearDetection() {
|
||||
{/* 패널 내 초기화 */}
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between">
|
||||
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}건 표시</span>
|
||||
<button type="button" aria-label={tc('aria.filterReset')}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={tc('aria.filterReset')}
|
||||
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
|
||||
<X className="w-3 h-3" /> 전체 초기화
|
||||
</button>
|
||||
icon={<X className="w-3 h-3" />}
|
||||
>
|
||||
전체 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -620,8 +638,8 @@ export function GearDetection() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<Anchor className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}건</span>
|
||||
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
<span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}건</span>
|
||||
<span className="text-[9px] text-hint">어구 그룹</span>
|
||||
</div>
|
||||
{/* 리플레이 컨트롤러 (활성 시 표시) */}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
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 { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
|
||||
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
@ -573,7 +574,7 @@ function GearComparisonTable() {
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Info className="w-3.5 h-3.5 text-blue-400" />
|
||||
<Info className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
한·중 어구 비교 레퍼런스 (GB/T 5147-2003 기반)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -593,18 +594,18 @@ function GearComparisonTable() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] text-red-400 font-medium mb-1">중국어선 특징</div>
|
||||
<div className="text-[9px] text-red-600 dark:text-red-400 font-medium mb-1">중국어선 특징</div>
|
||||
{row.chinaFeatures.map((f, i) => (
|
||||
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
||||
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
|
||||
<span className="text-red-600 dark:text-red-500 mt-0.5 shrink-0">-</span>{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-blue-400 font-medium mb-1">한국어선 특징</div>
|
||||
<div className="text-[9px] text-blue-600 dark:text-blue-400 font-medium mb-1">한국어선 특징</div>
|
||||
{row.koreaFeatures.map((f, i) => (
|
||||
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
|
||||
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
|
||||
<span className="text-blue-600 dark:text-blue-500 mt-0.5 shrink-0">-</span>{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -714,7 +715,7 @@ export function GearIdentification() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-cyan-500" />
|
||||
<Search className="w-5 h-5 text-cyan-600 dark:text-cyan-500" />
|
||||
{t('gearId.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
@ -722,13 +723,14 @@ export function GearIdentification() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
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"
|
||||
icon={<Info className="w-3 h-3" />}
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -741,7 +743,7 @@ export function GearIdentification() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge intent="info" size="sm">자동탐지 연계</Badge>
|
||||
<span className="text-hint">MMSI</span>
|
||||
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
|
||||
<span className="font-mono text-cyan-600 dark:text-cyan-400">{autoSelected.mmsi}</span>
|
||||
<span className="text-hint">·</span>
|
||||
<span className="text-hint">어구</span>
|
||||
<span className="font-mono text-label">{autoSelected.gearCode}</span>
|
||||
@ -751,12 +753,14 @@ export function GearIdentification() {
|
||||
</Badge>
|
||||
<span className="text-hint ml-2">하단 자동탐지 목록에서 클릭한 선박 정보가 아래 폼에 프리필되었습니다. 추가 정보 입력 후 판별 실행하세요.</span>
|
||||
</div>
|
||||
<button type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
|
||||
className="text-[10px] text-hint hover:text-heading shrink-0"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
해제
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -878,19 +882,22 @@ export function GearIdentification() {
|
||||
|
||||
{/* 판별 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
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"
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
className="flex-1 font-bold"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
어구 국적 판별 실행
|
||||
</button>
|
||||
<button type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
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"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -950,7 +957,7 @@ export function GearIdentification() {
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500" />
|
||||
판별 근거 ({result.reasons.length}건)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -958,7 +965,7 @@ export function GearIdentification() {
|
||||
<div className="space-y-1.5">
|
||||
{result.reasons.map((reason, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
|
||||
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
|
||||
<ChevronRight className="w-3 h-3 text-green-600 dark:text-green-500 mt-0.5 shrink-0" />
|
||||
<span className="text-[11px] text-label">{reason}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -970,7 +977,7 @@ export function GearIdentification() {
|
||||
{result.warnings.length > 0 && (
|
||||
<Card className="bg-surface-raised border-orange-500/20">
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
|
||||
<CardTitle className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
경고 / 위반 사항 ({result.warnings.length}건)
|
||||
</CardTitle>
|
||||
@ -979,8 +986,8 @@ export function GearIdentification() {
|
||||
<div className="space-y-1.5">
|
||||
{result.warnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
|
||||
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
|
||||
<span className="text-[11px] text-orange-300">{warning}</span>
|
||||
<XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
|
||||
<span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -992,13 +999,13 @@ export function GearIdentification() {
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-purple-500" />
|
||||
<Shield className="w-3.5 h-3.5 text-purple-600 dark:text-purple-500" />
|
||||
AI 탐지 Rule (해당 어구)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-2">
|
||||
{result.gearType === 'trawl' && (
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{`# 트롤 탐지 조건 (Trawl Detection Rule)
|
||||
if speed in range(2.0, 5.0) # knots
|
||||
and trajectory == 'parallel_sweep' # 반복 평행선
|
||||
@ -1013,7 +1020,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
|
||||
</pre>
|
||||
)}
|
||||
{result.gearType === 'gillnet' && (
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{`# 자망 탐지 조건 (Gillnet Detection Rule)
|
||||
if speed < 2.0 # knots
|
||||
and stop_duration > 30 # min
|
||||
@ -1028,7 +1035,7 @@ and sar_vessel_detect == True # SAR 위치 확인
|
||||
</pre>
|
||||
)}
|
||||
{result.gearType === 'purseSeine' && (
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
|
||||
if trajectory == 'circular' # 원형 궤적
|
||||
and speed_change > 5.0 # kt (고→저 급변)
|
||||
@ -1044,7 +1051,7 @@ and vessel_spacing < 1000 # m
|
||||
</pre>
|
||||
)}
|
||||
{result.gearType === 'setNet' && (
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-600 dark:text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{`# 정치망 — EEZ 내 중국어선 미허가 어구
|
||||
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
|
||||
#
|
||||
@ -1070,7 +1077,7 @@ and vessel_spacing < 1000 # m
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Waves className="w-3.5 h-3.5 text-cyan-500" />
|
||||
<Waves className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-500" />
|
||||
다중 센서 교차 검증 파이프라인
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -1164,20 +1171,23 @@ function AutoGearDetectionSection({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-cyan-500" />
|
||||
<Radar className="w-4 h-4 text-cyan-600 dark:text-cyan-500" />
|
||||
최근 자동탐지 결과 (prediction, 최근 1시간 중국 선박)
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL · 행 클릭 시 상단 입력 폼에 프리필
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={load}
|
||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
aria-label={t('aria.refresh')}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
@ -1212,7 +1222,7 @@ function AutoGearDetectionSection({
|
||||
}`}
|
||||
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
|
||||
>
|
||||
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
||||
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
|
||||
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
|
||||
@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { Loader2, RefreshCw, MapPin } 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 { Select } from '@shared/components/ui/select';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
@ -62,29 +65,37 @@ export function RealGearGroups() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select aria-label={tc('aria.groupTypeFilter')} value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<Select
|
||||
size="sm"
|
||||
aria-label={tc('aria.groupTypeFilter')}
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="FLEET">FLEET</option>
|
||||
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
|
||||
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
aria-label={tc('aria.refresh')}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
|
||||
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
|
||||
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
|
||||
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
|
||||
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
|
||||
<StatBox label="총 그룹" value={stats.total} intent="muted" />
|
||||
<StatBox label="FLEET" value={stats.fleet} intent="info" />
|
||||
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
|
||||
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
|
||||
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
@ -142,11 +153,22 @@ export function RealGearGroups() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
|
||||
critical: 'text-red-600 dark:text-red-400',
|
||||
high: 'text-orange-600 dark:text-orange-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
success: 'text-green-600 dark:text-green-400',
|
||||
muted: 'text-heading',
|
||||
purple: 'text-purple-600 dark:text-purple-400',
|
||||
cyan: 'text-cyan-600 dark:text-cyan-400',
|
||||
};
|
||||
|
||||
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
|
||||
return (
|
||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||
<div className="text-[9px] text-hint">{label}</div>
|
||||
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
||||
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@ import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
@ -118,31 +121,38 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select aria-label={t('aria.regionFilter')} value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<Select
|
||||
size="sm"
|
||||
aria-label={t('aria.regionFilter')}
|
||||
value={zoneFilter}
|
||||
onChange={(e) => setZoneFilter(e.target.value)}
|
||||
>
|
||||
<option value="">전체 해역</option>
|
||||
<option value="TERRITORIAL_SEA">영해</option>
|
||||
<option value="CONTIGUOUS_ZONE">접속수역</option>
|
||||
<option value="EEZ_OR_BEYOND">EEZ 외</option>
|
||||
</select>
|
||||
<button type="button" onClick={load}
|
||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
aria-label={t('aria.refresh')}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
|
||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
||||
<StatBox label="전체" value={stats.total} intent="muted" />
|
||||
<StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
|
||||
<StatBox label="HIGH" value={stats.highCount} intent="high" />
|
||||
<StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
|
||||
<StatBox label="Dark" value={stats.darkCount} intent="purple" />
|
||||
<StatBox label="필터링" value={filtered.length} intent="cyan" />
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
@ -168,7 +178,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
)}
|
||||
{sortedByRisk.slice(0, 100).map((v) => (
|
||||
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
||||
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium">
|
||||
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
|
||||
{v.classification.confidence > 0 && (
|
||||
@ -193,7 +203,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
|
||||
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
|
||||
<span className="text-orange-600 dark:text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
@ -220,11 +230,22 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }: { label: string; value: number | undefined; color: string }) {
|
||||
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
|
||||
critical: 'text-red-600 dark:text-red-400',
|
||||
high: 'text-orange-600 dark:text-orange-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
success: 'text-green-600 dark:text-green-400',
|
||||
muted: 'text-heading',
|
||||
purple: 'text-purple-600 dark:text-purple-400',
|
||||
cyan: 'text-cyan-600 dark:text-cyan-400',
|
||||
};
|
||||
|
||||
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number | undefined; intent?: BadgeIntent }) {
|
||||
return (
|
||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||
<div className="text-[9px] text-hint">{label}</div>
|
||||
<div className={`text-lg font-bold ${color}`}>{(value ?? 0).toLocaleString()}</div>
|
||||
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-red-400" />
|
||||
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="font-bold text-heading text-sm">판정 상세</span>
|
||||
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
|
||||
<span className="text-xs font-mono font-bold text-heading">{darkScore}점</span>
|
||||
@ -87,12 +87,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||
{/* 선박 기본 정보 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Ship className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-label font-medium">선박 정보</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
<span className="text-hint">MMSI</span>
|
||||
<button type="button" className="text-cyan-400 hover:underline text-right font-mono"
|
||||
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
|
||||
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
|
||||
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
|
||||
</button>
|
||||
@ -116,7 +116,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||
{/* 점수 산출 내역 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
<span className="text-label font-medium">점수 산출 내역</span>
|
||||
<span className="text-hint text-[10px]">({breakdown.items.length}개 패턴 적용)</span>
|
||||
</div>
|
||||
@ -126,7 +126,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||
{/* GAP 상세 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
|
||||
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-label font-medium">GAP 상세</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
@ -152,7 +152,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
|
||||
{/* 과거 이력 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-label font-medium">과거 이력 (7일)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
|
||||
@ -270,7 +270,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-orange-400" />
|
||||
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
|
||||
<span className="font-bold text-heading text-sm">어구 판정 상세</span>
|
||||
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
|
||||
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
|
||||
@ -287,7 +287,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{gear.gCodes.length > 0 && (
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||
<span className="text-label font-medium">G코드 위반 내역</span>
|
||||
<span className="text-hint text-[10px]">총 {gear.gearViolationScore}점</span>
|
||||
</div>
|
||||
@ -312,7 +312,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 어구 그룹 정보 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Anchor className="w-3.5 h-3.5 text-orange-400" />
|
||||
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
<span className="text-label font-medium">어구 그룹 정보</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
@ -344,7 +344,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 모선 추론 정보 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Ship className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-label font-medium">모선 추론</span>
|
||||
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
|
||||
</div>
|
||||
@ -352,7 +352,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<span className="text-hint">추정 모선</span>
|
||||
<span className="text-label text-right font-mono">
|
||||
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
|
||||
<button type="button" className="text-cyan-400 hover:underline"
|
||||
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
||||
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
|
||||
{gear.parentMmsi}
|
||||
</button>
|
||||
@ -366,7 +366,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 모선 추론 후보 상세 (Correlation) */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-label font-medium">추론 후보 상세</span>
|
||||
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
|
||||
<span className="text-hint text-[10px]">{correlations.length}건</span>
|
||||
@ -393,7 +393,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
|
||||
aria-label={`${c.targetMmsi} 리플레이 선택`} />
|
||||
<button type="button"
|
||||
className="text-cyan-400 hover:underline font-mono text-[11px]"
|
||||
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
|
||||
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
|
||||
{c.targetMmsi}
|
||||
</button>
|
||||
@ -467,9 +467,9 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<BarChart3 className="w-3.5 h-3.5 text-purple-400" />
|
||||
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-label font-medium">후보 검토</span>
|
||||
<span className="text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
|
||||
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
|
||||
</div>
|
||||
|
||||
@ -575,7 +575,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{hasPairTrawl && (
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Users className="w-3.5 h-3.5 text-red-400" />
|
||||
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||
<span className="text-label font-medium">쌍끌이 트롤 공조</span>
|
||||
<Badge intent="critical" size="sm">G-06</Badge>
|
||||
</div>
|
||||
@ -583,7 +583,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
<span className="text-hint">상대 선박</span>
|
||||
<span className="text-label text-right font-mono">
|
||||
{gear.pairTrawlPairMmsi ? (
|
||||
<button type="button" className="text-cyan-400 hover:underline"
|
||||
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
||||
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
|
||||
{gear.pairTrawlPairMmsi}
|
||||
</button>
|
||||
@ -615,7 +615,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
||||
{/* 위치 + 액션 */}
|
||||
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
|
||||
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-label font-medium">위치</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
||||
|
||||
@ -38,14 +38,14 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">특이운항 판별 구간</span>
|
||||
{segments.length > 0 && (
|
||||
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
|
||||
· {segments.length}구간
|
||||
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
|
||||
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
|
||||
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
|
||||
{criticalCount > 0 && <span className="text-red-600 dark:text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
|
||||
{warningCount > 0 && <span className="text-orange-600 dark:text-orange-400 ml-0.5">WARNING {warningCount}</span>}
|
||||
{infoCount > 0 && <span className="text-blue-600 dark:text-blue-400 ml-0.5">INFO {infoCount}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -55,7 +55,7 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-400">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
@ -182,7 +182,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<Ship className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
|
||||
{vesselName ?? mmsi}
|
||||
@ -218,7 +218,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-600 dark:text-red-400 px-3 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -131,7 +131,7 @@ export function EventList() {
|
||||
},
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
render: (val) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||
render: (_val, row) => {
|
||||
@ -140,7 +140,7 @@ export function EventList() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
|
||||
>
|
||||
{mmsi}
|
||||
@ -172,25 +172,25 @@ export function EventList() {
|
||||
<div className="flex items-center gap-1">
|
||||
{isNew && (
|
||||
<button type="button" aria-label={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" aria-label={tc('aria.vesselDetail')} title="선박 상세"
|
||||
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
|
||||
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400"
|
||||
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
|
||||
<Ship className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{isActionable && (
|
||||
<>
|
||||
<button type="button" aria-label={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-0.5 rounded hover:bg-green-500/20 text-green-600 dark:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-600 dark:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -317,7 +317,7 @@ export function EventList() {
|
||||
|
||||
{/* 에러 표시 */}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400">
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-600 dark:text-red-400">
|
||||
데이터 로딩 실패: {error}
|
||||
</div>
|
||||
)}
|
||||
@ -327,7 +327,7 @@ export function EventList() {
|
||||
<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 type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
|
||||
<button type="button" aria-label={tc('aria.close')} title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -343,7 +343,7 @@ export function EventList() {
|
||||
{/* 로딩 인디케이터 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -37,7 +37,7 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
key: 'eventId',
|
||||
label: '이벤트',
|
||||
width: '80px',
|
||||
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
|
||||
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
@ -58,7 +58,7 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
{
|
||||
key: 'recipient',
|
||||
label: '수신 대상',
|
||||
render: (v) => <span className="text-cyan-400">{v as string}</span>,
|
||||
render: (v) => <span className="text-cyan-600 dark:text-cyan-400">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'confidence',
|
||||
@ -70,7 +70,7 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
const s = v as string;
|
||||
if (!s) return <span className="text-hint">-</span>;
|
||||
const n = parseFloat(s);
|
||||
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
|
||||
const color = n > 0.9 ? 'text-red-600 dark:text-red-400' : n > 0.8 ? 'text-orange-600 dark:text-orange-400' : 'text-yellow-600 dark:text-yellow-400';
|
||||
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
|
||||
},
|
||||
},
|
||||
@ -149,10 +149,10 @@ export function AIAlert() {
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
|
||||
<div className="flex items-center justify-center gap-2 text-red-600 dark:text-red-400 py-8">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>알림 조회 실패: {error}</span>
|
||||
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
|
||||
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-600 dark:text-cyan-400">
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
@ -164,15 +164,15 @@ export function AIAlert() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Send}
|
||||
iconColor="text-yellow-400"
|
||||
iconColor="text-yellow-600 dark:text-yellow-400"
|
||||
title={t('aiAlert.title')}
|
||||
description={t('aiAlert.desc')}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: '총 발송', v: totalElements, c: 'text-heading' },
|
||||
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
|
||||
{ l: '실패', v: failedCount, c: 'text-red-400' },
|
||||
{ l: '수신확인', v: deliveredCount, c: 'text-green-600 dark:text-green-400' },
|
||||
{ l: '실패', v: failedCount, c: 'text-red-600 dark:text-red-400' },
|
||||
].map((k) => (
|
||||
<div
|
||||
key={k.l}
|
||||
|
||||
@ -56,7 +56,7 @@ export function MobileService() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Smartphone}
|
||||
iconColor="text-blue-400"
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
title={t('mobileService.title')}
|
||||
description={t('mobileService.desc')}
|
||||
/>
|
||||
@ -70,7 +70,7 @@ export function MobileService() {
|
||||
<div className="p-3 space-y-2">
|
||||
{/* 긴급 경보 */}
|
||||
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
|
||||
<div className="text-[9px] text-red-400 font-bold">[긴급] EEZ 침범 탐지</div>
|
||||
<div className="text-[9px] text-red-600 dark:text-red-400 font-bold">[긴급] EEZ 침범 탐지</div>
|
||||
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
|
||||
</div>
|
||||
{/* 지도 영역 — MapLibre GL */}
|
||||
@ -125,14 +125,14 @@ export function MobileService() {
|
||||
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
|
||||
].map(f => (
|
||||
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
|
||||
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<f.icon className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" />푸시 알림 설정</div>
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />푸시 알림 설정</div>
|
||||
<div className="space-y-2">
|
||||
{PUSH_SETTINGS.map(p => (
|
||||
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
|
||||
@ -3,6 +3,7 @@ import { Tag, X, Loader2 } 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 { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
@ -119,26 +120,29 @@ export function LabelSession() {
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
|
||||
<Tag className="w-3.5 h-3.5" /> 신규 학습 세션 등록
|
||||
{!canCreate && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
{!canCreate && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input aria-label={tc('aria.groupKey')} value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<input aria-label={tc('aria.subClusterId')} type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
|
||||
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<input aria-label={tc('aria.correctParentMmsi')} value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
|
||||
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<button type="button" onClick={handleCreate}
|
||||
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
|
||||
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
|
||||
<Input aria-label={tc('aria.groupKey')} size="sm" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1" disabled={!canCreate} />
|
||||
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
|
||||
className="w-24" disabled={!canCreate} />
|
||||
<Input aria-label={tc('aria.correctParentMmsi')} size="sm" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
|
||||
className="w-48" disabled={!canCreate} />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
|
||||
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
|
||||
>
|
||||
세션 생성
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && <div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
@ -171,7 +175,7 @@ export function LabelSession() {
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
|
||||
</td>
|
||||
@ -180,7 +184,7 @@ export function LabelSession() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.status === 'ACTIVE' && (
|
||||
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
||||
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
|
||||
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-600 dark:text-red-400" title="취소" aria-label="취소">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -3,6 +3,7 @@ import { Ban, RotateCcw, Loader2, Globe, Layers } 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 { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
@ -138,23 +139,26 @@ export function ParentExclusion() {
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-xs font-medium text-heading flex items-center gap-2">
|
||||
<Layers className="w-3.5 h-3.5" /> GROUP 제외 (특정 그룹 한정)
|
||||
{!canCreateGroup && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
{!canCreateGroup && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input aria-label={tc('aria.groupKey')} value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input aria-label={tc('aria.subClusterId')} type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
|
||||
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input aria-label={tc('aria.excludedMmsi')} value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input aria-label={tc('aria.exclusionReason')} value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<button type="button" onClick={handleAddGroup}
|
||||
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
|
||||
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-orange-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
|
||||
<Input aria-label={tc('aria.groupKey')} size="sm" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1" disabled={!canCreateGroup} />
|
||||
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
|
||||
className="w-24" disabled={!canCreateGroup} />
|
||||
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40" disabled={!canCreateGroup} />
|
||||
<Input aria-label={tc('aria.exclusionReason')} size="sm" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1" disabled={!canCreateGroup} />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAddGroup}
|
||||
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
|
||||
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
|
||||
>
|
||||
제외
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -164,24 +168,27 @@ export function ParentExclusion() {
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-xs font-medium text-heading flex items-center gap-2">
|
||||
<Globe className="w-3.5 h-3.5" /> GLOBAL 제외 (모든 그룹 영구 차단, 관리자 권한)
|
||||
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
{!canCreateGlobal && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input aria-label={tc('aria.excludedMmsi')} value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
|
||||
<input aria-label={tc('aria.globalExclusionReason')} value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
|
||||
<button type="button" onClick={handleAddGlobal}
|
||||
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
|
||||
className="px-3 py-1.5 bg-red-600 hover:bg-red-500 disabled:bg-red-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
|
||||
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40" disabled={!canCreateGlobal} />
|
||||
<Input aria-label={tc('aria.globalExclusionReason')} size="sm" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1" disabled={!canCreateGlobal} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleAddGlobal}
|
||||
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
|
||||
icon={busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
|
||||
>
|
||||
전역 제외
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && <div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
@ -220,13 +227,13 @@ export function ParentExclusion() {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey || '-'}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
|
||||
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.excludedMmsi}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-600 dark:text-blue-400" title="해제" aria-label="해제">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@ -3,6 +3,7 @@ import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } 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 { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
@ -151,39 +152,40 @@ export function ParentReview() {
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-muted-foreground mb-2">신규 모선 확정 등록 (테스트)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Input
|
||||
aria-label={tc('aria.groupKey')}
|
||||
type="text"
|
||||
size="sm"
|
||||
value={newGroupKey}
|
||||
onChange={(e) => setNewGroupKey(e.target.value)}
|
||||
placeholder="group_key (예: 渔船A)"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
aria-label={tc('aria.subClusterId')}
|
||||
size="sm"
|
||||
type="number"
|
||||
value={newSubCluster}
|
||||
onChange={(e) => setNewSubCluster(e.target.value)}
|
||||
placeholder="sub_cluster_id"
|
||||
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
||||
className="w-32"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
aria-label="parent MMSI"
|
||||
type="text"
|
||||
size="sm"
|
||||
value={newMmsi}
|
||||
onChange={(e) => setNewMmsi(e.target.value)}
|
||||
placeholder="parent MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
||||
className="w-40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
|
||||
icon={actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
확정 등록
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -192,7 +194,7 @@ export function ParentReview() {
|
||||
{!canUpdate && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-yellow-400">
|
||||
<div className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
조회 전용 모드 (UPDATE 권한 없음). 확정/거부/리셋 액션이 비활성화됩니다.
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -202,7 +204,7 @@ export function ParentReview() {
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -247,39 +249,42 @@ export function ParentReview() {
|
||||
{getParentResolutionLabel(it.status, tc, lang)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">
|
||||
{formatDateTime(it.updatedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'CONFIRM')}
|
||||
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
|
||||
title="확정"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="확정"
|
||||
className="text-green-600 dark:text-green-400 hover:bg-green-500/20"
|
||||
icon={<CheckCircle className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'REJECT')}
|
||||
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
|
||||
title="거부"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="거부"
|
||||
className="text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
icon={<XCircle className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'RESET')}
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
|
||||
title="리셋"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
aria-label="리셋"
|
||||
className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/20"
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -121,8 +121,8 @@ export function ReportManagement() {
|
||||
</div>
|
||||
<div className="text-[11px] text-hint mt-1">증거 {r.evidence}건</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<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>
|
||||
<Button variant="primary" size="sm">PDF</Button>
|
||||
<Button variant="secondary" size="sm">한글</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -136,9 +136,9 @@ export function ReportManagement() {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-label">보고서 미리보기</div>
|
||||
<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>
|
||||
<Button variant="primary" size="sm" icon={<Download className="w-3.5 h-3.5" />}>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">
|
||||
|
||||
@ -56,7 +56,7 @@ function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' })
|
||||
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
|
||||
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">{value.toFixed(2)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -244,7 +244,7 @@ export function LiveMapView() {
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="ml-2 text-[11px] text-hint">로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
@ -252,8 +252,8 @@ export function LiveMapView() {
|
||||
{!serviceAvailable && !loading && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiOff className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-[11px] text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
||||
<WifiOff className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-[11px] text-yellow-600 dark:text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-hint mt-1">이벤트 데이터만 표시됩니다.</p>
|
||||
</div>
|
||||
@ -278,7 +278,7 @@ export function LiveMapView() {
|
||||
<IconComp className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold text-heading">{evt.type}</span>
|
||||
</div>
|
||||
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
|
||||
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-600 dark:hover:text-orange-400 transition-colors" />
|
||||
</div>
|
||||
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
|
||||
<RiskBar value={evt.risk} size="sm" />
|
||||
@ -318,7 +318,7 @@ export function LiveMapView() {
|
||||
{/* 실시간 표시 */}
|
||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
|
||||
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">LIVE</span>
|
||||
<span className="text-[9px] text-hint">경보 {mapEvents.length}건 · 분석 {vesselItems.length}척</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -333,7 +333,7 @@ export function LiveMapView() {
|
||||
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
|
||||
<Ship className="w-4.5 h-4.5 text-red-400" />
|
||||
<Ship className="w-4.5 h-4.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
|
||||
@ -348,7 +348,7 @@ export function LiveMapView() {
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] text-muted-foreground mb-1">위험도 점수</div>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
|
||||
<span className="text-3xl font-bold text-red-600 dark:text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
|
||||
<span className="text-sm text-hint">/100</span>
|
||||
</div>
|
||||
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
|
||||
@ -364,29 +364,29 @@ export function LiveMapView() {
|
||||
<Card className="bg-surface-overlay border-slate-700/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-blue-400" />
|
||||
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm text-heading font-medium">AI 판단 근거</span>
|
||||
<Badge intent="critical" size="md">신뢰도: High</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-2 border-red-500 pl-3">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<AlertTriangle className="w-3 h-3 text-red-400" />
|
||||
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
|
||||
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">{selectedEvent.type}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">선박: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
|
||||
</div>
|
||||
<div className="border-l-2 border-orange-500 pl-3">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Activity className="w-3 h-3 text-orange-400" />
|
||||
<span className="text-orange-400 font-medium">위치 정보</span>
|
||||
<Activity className="w-3 h-3 text-orange-600 dark:text-orange-400" />
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">위치 정보</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">좌표: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="border-l-2 border-green-500 pl-3">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Clock className="w-3 h-3 text-green-400" />
|
||||
<span className="text-green-400 font-medium">발생 시각</span>
|
||||
<Clock className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">발생 시각</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
|
||||
</div>
|
||||
|
||||
@ -124,7 +124,7 @@ const NTM_DATA: NtmRecord[] = [
|
||||
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
|
||||
|
||||
const ntmColumns: DataColumn<NtmRecord>[] = [
|
||||
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
|
||||
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
|
||||
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
|
||||
render: v => {
|
||||
@ -146,7 +146,7 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
|
||||
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
|
||||
|
||||
const columns: DataColumn<TrainingZone>[] = [
|
||||
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ key: 'sea', label: '해역', width: '60px', sortable: true },
|
||||
@ -301,7 +301,7 @@ export function MapControl() {
|
||||
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
|
||||
]).map(t => (
|
||||
<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'}`}>
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-600 dark:text-cyan-400 border-cyan-500 dark:border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
@ -310,7 +310,7 @@ export function MapControl() {
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
{['', '서해', '남해', '동해', '제주'].map(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'}`}>
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
{s || '전체'}
|
||||
</button>
|
||||
))}
|
||||
@ -324,9 +324,9 @@ export function MapControl() {
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
|
||||
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
|
||||
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-600 dark:text-red-400' },
|
||||
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
|
||||
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
|
||||
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-600 dark:text-orange-400' },
|
||||
].map(k => (
|
||||
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
|
||||
@ -341,14 +341,14 @@ export function MapControl() {
|
||||
<span className="text-[10px] text-hint">구분:</span>
|
||||
{NTM_CATEGORIES.map(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>
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 최근 발령 중 통보 하이라이트 */}
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />현재 발령 중 항행통보
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />현재 발령 중 항행통보
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
|
||||
@ -411,7 +411,7 @@ export function MapControl() {
|
||||
</div>
|
||||
{/* 표시 구역 수 */}
|
||||
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}개</span>
|
||||
<span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">{visibleZones.length}개</span>
|
||||
<span className="text-[9px] text-hint ml-1">훈련구역 표시 중</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user