Merge pull request 'release: 2026-04-08 (35건 커밋)' (#19) from release/2026-04-08 into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s

This commit is contained in:
htlee 2026-04-08 13:43:24 +09:00
커밋 30ef2cd593
124개의 변경된 파일6576개의 추가작업 그리고 1432개의 파일을 삭제

122
CLAUDE.md
파일 보기

@ -9,7 +9,7 @@ kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
@ -101,6 +101,126 @@ make format # 프론트 prettier
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
## 디자인 시스템 (필수 준수)
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
### 쇼케이스 진입
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
- **단축키 `A`**: 다크/라이트 테마 토글
### 공통 컴포넌트 (반드시 사용)
| 컴포넌트 | 위치 | 용도 |
|---|---|---|
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
### 카탈로그 기반 라벨/색상
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
```tsx
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>
```
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
숫자 위험도는 `getRiskIntent(0~100)` 사용.
### CSS 작성 규칙
1. **인라인 색상 금지**`style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
2. **하드코딩 Tailwind 색상 금지**`bg-red-500/20 text-red-400` 같은 직접 작성 금지
- 반드시 Badge intent 또는 카탈로그 API 호출
3. **className override 정책**
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
4. **시맨틱 토큰 우선**`theme.css @layer utilities`의 토큰 사용
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
### 페이지 작성 표준 템플릿
```tsx
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="페이지 제목"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
추가
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
</Section>
</PageContainer>
);
}
```
### 접근성 (a11y) 필수
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
### 변경 사이클
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
### 금지 패턴 체크리스트
- ❌ `<Badge className="bg-red-500/20 text-red-400">``<Badge intent="critical">`
- ❌ `<button className="bg-blue-600 ...">``<Button variant="primary">`
- ❌ `<input className="bg-surface ...">``<Input>`
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
- ❌ `!important``cn()` 활용
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
## System Flow 뷰어 (개발 단계용)
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)

파일 보기

@ -47,15 +47,16 @@ public class PermTreeController {
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
return roles.stream().<Map<String, Object>>map(r -> {
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
return Map.of(
"roleSn", r.getRoleSn(),
"roleCd", r.getRoleCd(),
"roleNm", r.getRoleNm(),
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
"dfltYn", r.getDfltYn(),
"builtinYn", r.getBuiltinYn(),
"permissions", perms
);
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("roleSn", r.getRoleSn());
result.put("roleCd", r.getRoleCd());
result.put("roleNm", r.getRoleNm());
result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc());
result.put("colorHex", r.getColorHex());
result.put("dfltYn", r.getDfltYn());
result.put("builtinYn", r.getBuiltinYn());
result.put("permissions", perms);
return result;
}).toList();
}

파일 보기

@ -28,6 +28,10 @@ public class Role {
@Column(name = "role_dc", columnDefinition = "text")
private String roleDc;
/** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */
@Column(name = "color_hex", length = 7)
private String colorHex;
@Column(name = "dflt_yn", nullable = false, length = 1)
private String dfltYn;

파일 보기

@ -38,6 +38,7 @@ public class RoleManagementService {
.roleCd(req.roleCd().toUpperCase())
.roleNm(req.roleNm())
.roleDc(req.roleDc())
.colorHex(req.colorHex())
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
.builtinYn("N")
.build();
@ -70,6 +71,7 @@ public class RoleManagementService {
}
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
if (req.colorHex() != null) role.setColorHex(req.colorHex());
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
return roleRepository.save(role);
}

파일 보기

@ -6,5 +6,6 @@ public record RoleCreateRequest(
@NotBlank String roleCd,
@NotBlank String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -3,5 +3,6 @@ package gc.mda.kcg.permission.dto;
public record RoleUpdateRequest(
String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -0,0 +1,18 @@
-- ============================================================================
-- V017: auth_role.color_hex 컬럼 추가
-- ============================================================================
-- 역할별 UI 표기 색상을 DB에서 관리.
-- 프론트엔드는 RoleResponse.colorHex로 받아 ColorPicker 카탈로그에 적용.
-- ----------------------------------------------------------------------------
ALTER TABLE kcg.auth_role
ADD COLUMN IF NOT EXISTS color_hex VARCHAR(7);
COMMENT ON COLUMN kcg.auth_role.color_hex IS '역할 표기 색상 (#RRGGBB hex). NULL이면 프론트 기본 팔레트에서 결정.';
-- 빌트인 5개 역할의 기본 색상 (기존 프론트 ROLE_COLORS 정책과 매칭)
UPDATE kcg.auth_role SET color_hex = '#ef4444' WHERE role_cd = 'ADMIN';
UPDATE kcg.auth_role SET color_hex = '#3b82f6' WHERE role_cd = 'OPERATOR';
UPDATE kcg.auth_role SET color_hex = '#a855f7' WHERE role_cd = 'ANALYST';
UPDATE kcg.auth_role SET color_hex = '#22c55e' WHERE role_cd = 'FIELD';
UPDATE kcg.auth_role SET color_hex = '#eab308' WHERE role_cd = 'VIEWER';

파일 보기

@ -4,13 +4,93 @@
## [Unreleased]
## [2026-04-08]
### 추가
- **디자인 시스템 쇼케이스** (`/design-system.html`) — UI 단일 진실 공급원(SSOT)
- 별도 Vite entry, 메인 SPA와 분리 (designSystem-*.js 54KB)
- 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog / Guide
- 추적 ID 체계 `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드", URL 해시 딥링크 `#trk=...`
- 단축키 `A`로 다크/라이트 토글
- 한글/영문 라벨 병기로 카탈로그 검토 용이
- **신규 공통 컴포넌트**:
- `Button` (5 variant × 3 size = 15) `@shared/components/ui/button`
- `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` `@shared/components/ui/`
- `TabBar` / `TabButton` (underline / pill / segmented 3 variant) `@shared/components/ui/tabs`
- `PageContainer` (size sm/md/lg + fullBleed) `@shared/components/layout/PageContainer`
- `PageHeader` (icon + title + description + demo + actions) `@shared/components/layout/PageHeader`
- `Section` (Card 단축) `@shared/components/layout/Section`
- **중앙 레지스트리**:
- `catalogRegistry.ts` — 23개 카탈로그 메타 (id/title/description/source/items 자동 enumerate)
- `variantMeta.ts` — Badge intent 8종 + Button variant 5종 의미 가이드
- `statusIntent.ts` — 한글/영문 ad-hoc 상태 → BadgeIntent + getRiskIntent(0~100)
- **4 catalog에 intent 필드 추가**: eventStatuses / enforcementResults / enforcementActions / patrolStatuses + getXxxIntent() 헬퍼
- **UI 공통 카탈로그 19종** (`frontend/src/shared/constants/`) — 백엔드 enum/code_master 기반 SSOT
- violation/alert/event/enforcement/patrol/engine/userRole/device/parentResolution/
modelDeployment/gearGroup/darkVessel/httpStatus/userAccount/loginResult/permission/
vesselAnalysis/connection/trainingZone + kpiUiMap
- 표준 API: `get{Cat}Intent(code)`, `get{Cat}Label(code, t, lang)`, `get{Cat}Classes/Hex`
- **시맨틱 텍스트 토큰** (`theme.css @layer utilities`) — Tailwind v4 복합 이름 매핑 실패 대응
- `text-heading/label/hint/on-vivid/on-bright`, `bg-surface-raised/overlay` 직접 정의
- **Badge 시스템 재구축** — CVA 기반 8 intent × 4 size (rem), !important 제거
- **cn() 유틸** (`lib/utils/cn.ts`) — clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록
- **ColorPicker 컴포넌트** — 팔레트 grid + native color + hex 입력
- **Role.colorHex** (백엔드 V017 migration) — auth_role.color_hex VARCHAR(7), 빌트인 5개 역할 기본 색상 시드
- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
- 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim)
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
- 백엔드 `GET /api/stats/hourly?hours=24` — 시간별 통계 조회 (PredictionStatsHourly)
- V014 prediction 보조 테이블 12개 (fleet_vessels, gear_correlation_scores 등)
- V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2)
- V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column)
### 변경
- **35+ feature 페이지 PageContainer/PageHeader 마이그레이션** — admin/detection/enforcement/field-ops/patrol/statistics/ai-operations/parent-inference/dashboard/monitoring/surveillance/vessel/risk-assessment 전체
- **VesselDetail `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`** 정리
- **LiveMapView fullBleed 패턴** 적용
- **Badge intent 팔레트 테마 분리**: 라이트(파스텔 `bg-X-100 text-X-900`) + 다크(translucent `bg-X-500/20 text-X-400`)
- **40+ 페이지 Badge/시맨틱 토큰 마이그레이션**
- Badge className 직접 작성 → intent/size prop 변환
- 컬러풀 액션 버튼 → `text-on-vivid` (흰색), 검색/필터 버튼 → `bg-blue-400 text-on-bright`
- ROLE_COLORS 4곳 중복 제거 → `getRoleBadgeStyle()` 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- DataTable `width` 의미 변경: 고정 → 선호 최소 너비 (minWidth), 컨텐츠 자동 확장 + truncate
- `dateFormat.ts` sv-SE 로케일로 `YYYY-MM-DD HH:mm:ss` 일관된 KST 출력
- MonitoringDashboard `PagePagination` 제거 (데이터 페이지네이션 오해 해소)
### 수정
- **접근성 (WCAG 2.1 Level A)** — axe DevTools 위반 전수 처리:
- `<Select>` 컴포넌트 TypeScript union 타입으로 `aria-label`/`aria-labelledby`/`title` 컴파일 강제
- 네이티브 `<select>` 5곳 aria-label
- 아이콘 전용 `<button>` 16곳 aria-label
- `<input>`/`<textarea>` 28곳 aria-label (placeholder 자동 복제 포함)
- AIModelManagement 토글 → `role="switch"` + `aria-checked`
- **Badge className 위반 37건 전수 제거**`<Badge intent="...">` 패턴으로 통일
- **하드코딩 `bg-X-500/20 text-X-400` 56곳 제거** — 카탈로그 API + intent 사용
- **인라인 `<button>` type 누락 86곳 보정**
- **CSS Safari 호환성**: `backdrop-filter` `-webkit-` prefix 추가 (디자인 쇼케이스)
- `trk-pulse` keyframe outline-color → opacity (composite-only 최적화)
- Dashboard RiskBar 단위 버그 (0~100 정수를 `*100` 하던 코드 → 범위 감지)
- ReportManagement, TransferDetection `p-5 space-y-4` padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 `formatDateTime` 적용 (ISO `T` 구분자 처리)
- **prediction e2e 5가지 이슈 수정** (2026-04-08)
- gear_correlation: psycopg2 Decimal × float TypeError → `_load_all_scores()` float 변환
- violation_classifier: `(mmsi, analyzed_at)` 기준 UPDATE + 중국선박 EEZ 판정 로직
- kpi_writer / stats_aggregator: UTC → KST 날짜 경계 통일
- parent workflow 스키마 ↔ 코드 불일치 → V016로 일괄 해결
- DemoQuickLogin hostname 기반 노출 (Gitea CI `.env` 차단 대응)
- 프론트 전수 mock 정리: eventStore.alerts, enforcementStore.plans, transferStore 완전 제거
- Dashboard/MonitoringDashboard/Statistics 하드코딩 → 실 API 전환
- UTC → KST 시간 표시 통일 (`@shared/utils/dateFormat.ts` 공통 유틸)
- i18n `group.parentInference` JSON 중복키 제거
- RiskMap Math.random() 격자 제거, MTIS 라벨 + "AI 분석 데이터 수집 중" 안내
- 12개 mock 화면에 "데모 데이터" 노란 배지 추가
## [2026-04-07]

파일 보기

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCG AI Monitoring — Design System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/designSystemMain.tsx"></script>
</body>
</html>

파일 보기

@ -11,6 +11,7 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -20,6 +21,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
@ -2824,7 +2826,7 @@
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
@ -4907,6 +4909,16 @@
"kdbush": "^4.0.2"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",

파일 보기

@ -15,6 +15,7 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -24,6 +25,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {

파일 보기

@ -6,12 +6,12 @@ import {
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers,
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
ChevronsLeft, ChevronsRight,
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
} from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore';
@ -27,13 +27,6 @@ import { useSettingsStore } from '@stores/settingsStore';
* - 하단: 페이지네이션
*/
const ROLE_COLORS: Record<UserRole, string> = {
ADMIN: 'text-red-400',
OPERATOR: 'text-blue-400',
ANALYST: 'text-purple-400',
FIELD: 'text-green-400',
VIEWER: 'text-yellow-400',
};
const AUTH_METHOD_LABELS: Record<string, string> = {
password: 'ID/PW',
@ -51,7 +44,7 @@ const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
{ to: '/events', icon: Radar, labelKey: 'nav.eventList' },
{ to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' },
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
// ── 위험도·단속 ──
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
@ -114,40 +107,6 @@ function formatRemaining(seconds: number) {
return `${m}:${String(s).padStart(2, '0')}`;
}
// ─── 공통 페이지네이션 (간소형) ─────────────
function PagePagination({ page, totalPages, onPageChange }: {
page: number; totalPages: number; onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
const range: number[] = [];
const maxVis = 5;
let s = Math.max(0, page - Math.floor(maxVis / 2));
const e = Math.min(totalPages - 1, s + maxVis - 1);
if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1);
for (let i = s; i <= e; i++) range.push(i);
const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors";
return (
<div className="flex items-center justify-center gap-1">
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
{range.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
}`}
>{p + 1}</button>
))}
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
</div>
);
}
export function MainLayout() {
const { t } = useTranslation('common');
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
@ -166,33 +125,6 @@ export function MainLayout() {
// 공통 검색
const [pageSearch, setPageSearch] = useState('');
// 공통 스크롤 페이징 (페이지 단위 스크롤)
const [scrollPage, setScrollPage] = useState(0);
const scrollPageSize = 800; // px per page
const handleScrollPageChange = (p: number) => {
setScrollPage(p);
if (contentRef.current) {
contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' });
}
};
// 스크롤 이벤트로 현재 페이지 추적
const handleScroll = () => {
if (contentRef.current) {
const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1);
setScrollPage(currentPage);
}
};
const getTotalScrollPages = () => {
if (!contentRef.current) return 1;
const { scrollHeight, clientHeight } = contentRef.current;
return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
};
// 인쇄
const handlePrint = () => {
const el = contentRef.current;
@ -257,7 +189,7 @@ export function MainLayout() {
});
// RBAC
const roleColor = user ? ROLE_COLORS[user.role] : null;
const roleColor = user ? getRoleColorHex(user.role) : null;
const isSessionWarning = sessionRemaining <= 5 * 60;
// SFR-02: 공통알림 데이터
@ -310,7 +242,7 @@ export function MainLayout() {
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-1.5">
<Lock className="w-3 h-3 text-hint" />
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
</div>
<div className="text-[8px] text-hint mt-0.5">
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
@ -429,7 +361,7 @@ export function MainLayout() {
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
<input aria-label={t('layout.searchPlaceholder')}
type="text"
placeholder={t('layout.searchPlaceholder')}
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
@ -442,7 +374,7 @@ export function MainLayout() {
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
</div>
<button className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<Bell className="w-4 h-4" />
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
</button>
@ -485,7 +417,7 @@ export function MainLayout() {
<div className="text-[8px] text-hint">{user.org}</div>
</div>
{roleColor && (
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
{user.role}
</span>
)}
@ -506,6 +438,7 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label="페이지 내 검색"
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {
@ -522,7 +455,7 @@ export function MainLayout() {
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
}
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors"
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors"
>
<Search className="w-3 h-3" />
{t('action.search')}
@ -559,19 +492,9 @@ export function MainLayout() {
<main
ref={contentRef}
className="flex-1 overflow-auto"
onScroll={handleScroll}
>
<Outlet />
</main>
{/* SFR-02: 공통 페이지네이션 (하단) */}
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
<PagePagination
page={scrollPage}
totalPages={getTotalScrollPages()}
onPageChange={handleScrollPageChange}
/>
</div>
</div>
{/* SFR-02: 공통알림 팝업 */}

파일 보기

@ -0,0 +1,140 @@
/* 디자인 쇼케이스 전용 스타일 */
.ds-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background, #0b1220);
color: var(--foreground, #e2e8f0);
}
.ds-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.ds-body {
display: flex;
flex: 1;
min-height: 0;
}
.ds-nav {
width: 220px;
flex-shrink: 0;
padding: 1rem 0.75rem;
border-right: 1px solid rgb(51 65 85 / 0.5);
overflow-y: auto;
position: sticky;
top: 0;
}
.ds-main {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem 6rem;
scroll-behavior: smooth;
}
.ds-main section {
margin-bottom: 4rem;
}
/* 추적 ID 시스템 */
.trk-item {
position: relative;
transition: outline-color 0.2s;
}
.trk-copyable {
cursor: pointer;
}
.trk-copyable:hover {
outline: 1px dashed rgb(59 130 246 / 0.5);
outline-offset: 4px;
}
.trk-active {
outline: 2px solid rgb(59 130 246);
outline-offset: 4px;
animation: trk-pulse 1.2s ease-out;
will-change: opacity;
}
.trk-item[data-copied='true'] {
outline: 2px solid rgb(34 197 94) !important;
outline-offset: 4px;
}
.trk-item[data-copied='true']::after {
content: '복사됨 ✓';
position: absolute;
top: -1.5rem;
left: 0;
font-size: 0.625rem;
color: rgb(34 197 94);
background: rgb(34 197 94 / 0.15);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
pointer-events: none;
z-index: 10;
}
@keyframes trk-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.35;
}
100% {
opacity: 1;
}
}
/* 쇼케이스 그리드 */
.ds-grid {
display: grid;
gap: 0.75rem;
}
.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.ds-sample {
padding: 1rem;
background: var(--surface-raised, rgb(30 41 59 / 0.4));
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.5rem;
}
.ds-sample-label {
font-size: 0.625rem;
color: var(--text-hint, rgb(148 163 184));
font-family: ui-monospace, monospace;
margin-top: 0.5rem;
word-break: break-all;
}
.ds-code {
display: block;
padding: 0.75rem 1rem;
background: rgb(15 23 42 / 0.7);
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: rgb(203 213 225);
white-space: pre;
overflow-x: auto;
}

파일 보기

@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { TrkProvider, useTrk } from './lib/TrkContext';
import { IntroSection } from './sections/IntroSection';
import { TokenSection } from './sections/TokenSection';
import { TypographySection } from './sections/TypographySection';
import { BadgeSection } from './sections/BadgeSection';
import { ButtonSection } from './sections/ButtonSection';
import { FormSection } from './sections/FormSection';
import { CardSectionShowcase } from './sections/CardSection';
import { LayoutSection } from './sections/LayoutSection';
import { CatalogSection } from './sections/CatalogSection';
import { GuideSection } from './sections/GuideSection';
import './DesignSystemApp.css';
interface NavItem {
id: string;
label: string;
anchor: string;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' },
{ id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' },
{ id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' },
{ id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' },
{ id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' },
{ id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' },
{ id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' },
{ id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' },
{ id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' },
{ id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' },
];
function DesignSystemShell() {
const { copyMode, setCopyMode } = useTrk();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeNav, setActiveNav] = useState<string>('intro');
// 테마 토글
useEffect(() => {
const root = document.documentElement;
root.classList.remove('dark', 'light');
root.classList.add(theme);
}, [theme]);
// 단축키: 'a' 입력 시 테마 전환 (input/textarea 등 편집 중에는 무시)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'a' && e.key !== 'A') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
return;
}
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// 스크롤 감지로 현재 네비 하이라이트
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-section-id');
if (id) setActiveNav(id);
}
}
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 },
);
const sections = document.querySelectorAll('[data-section-id]');
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}, []);
const scrollTo = (anchor: string) => {
const el = document.querySelector(`[data-trk="${anchor}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="ds-shell">
{/* 고정 헤더 */}
<header className="ds-header">
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
<code className="text-[10px] text-hint font-mono">v0.1.0 · </code>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
<input
type="checkbox"
checked={copyMode}
onChange={(e) => setCopyMode(e.target.checked)}
className="accent-blue-500"
/>
ID
</label>
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="단축키: A"
className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30 flex items-center gap-1.5"
>
<span>{theme === 'dark' ? '☾ Dark' : '☀ Light'}</span>
<kbd className="text-[9px] font-mono text-hint border border-slate-600/40 rounded px-1 py-0">A</kbd>
</button>
</div>
</header>
<div className="ds-body">
{/* 좌측 네비 */}
<nav className="ds-nav">
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
<ul className="space-y-0.5">
{NAV_ITEMS.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.anchor)}
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
activeNav === item.id
? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500'
: 'text-label hover:bg-slate-700/20'
}`}
>
{item.label}
</button>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
<div>
<strong className="text-label"> ID </strong>
</div>
<code className="block font-mono">TRK-&lt;&gt;-&lt;&gt;</code>
<div className="mt-2">
: <code className="font-mono">#trk=ID</code>
</div>
</div>
</nav>
{/* 우측 컨텐츠 */}
<main className="ds-main">
<section data-section-id="intro">
<IntroSection />
</section>
<section data-section-id="token">
<TokenSection />
</section>
<section data-section-id="typography">
<TypographySection />
</section>
<section data-section-id="badge">
<BadgeSection />
</section>
<section data-section-id="button">
<ButtonSection />
</section>
<section data-section-id="form">
<FormSection />
</section>
<section data-section-id="card">
<CardSectionShowcase />
</section>
<section data-section-id="layout">
<LayoutSection />
</section>
<section data-section-id="catalog">
<CatalogSection />
</section>
<section data-section-id="guide">
<GuideSection />
</section>
</main>
</div>
</div>
);
}
export function DesignSystemApp() {
return (
<TrkProvider>
<DesignSystemShell />
</TrkProvider>
);
}

파일 보기

@ -0,0 +1,71 @@
import { type ReactNode, type CSSProperties, type MouseEvent } from 'react';
import { useTrk } from './TrkContext';
/**
* ID
* ID를 :
* 1. ID
* 2. "ID 복사 모드"
* 3. URL hash (#trk=TRK-BADGE-critical-sm)
*/
interface TrkProps {
id: string;
children: ReactNode;
className?: string;
style?: CSSProperties;
/** 인라인 요소로 렌더할지 여부 (기본: block) */
inline?: boolean;
}
export function Trk({ id, children, className = '', style, inline = false }: TrkProps) {
const { copyMode, activeId } = useTrk();
const isActive = activeId === id;
const handleClick = async (e: MouseEvent) => {
if (!copyMode) return;
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(id);
const el = e.currentTarget as HTMLElement;
el.dataset.copied = 'true';
setTimeout(() => delete el.dataset.copied, 800);
} catch {
// clipboard API 미지원 시 무시
}
};
const Wrapper = inline ? 'span' : 'div';
return (
<Wrapper
data-trk={id}
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
style={style}
title={id}
onClick={handleClick}
>
{children}
</Wrapper>
);
}
export function TrkSectionHeader({
id,
title,
description,
}: {
id: string;
title: string;
description?: string;
}) {
return (
<div data-trk={id} className="mb-4">
<div className="flex items-baseline gap-2">
<h2 className="text-xl font-bold text-heading">{title}</h2>
<code className="text-[10px] text-hint font-mono">{id}</code>
</div>
{description && <p className="text-xs text-hint mt-1">{description}</p>}
</div>
);
}

파일 보기

@ -0,0 +1,50 @@
import { useContext, createContext, useState, useEffect, type ReactNode } from 'react';
interface TrkContextValue {
copyMode: boolean;
setCopyMode: (v: boolean) => void;
activeId: string | null;
setActiveId: (id: string | null) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
export const TrkContext = createContext<TrkContextValue | null>(null);
export function TrkProvider({ children }: { children: ReactNode }) {
const [copyMode, setCopyMode] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// 딥링크 처리: #trk=TRK-BADGE-critical-sm
useEffect(() => {
const applyHash = () => {
const hash = window.location.hash;
const match = hash.match(/#trk=([\w-]+)/);
if (match) {
const id = match[1];
setActiveId(id);
setTimeout(() => {
const el = document.querySelector(`[data-trk="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
}
};
applyHash();
window.addEventListener('hashchange', applyHash);
return () => window.removeEventListener('hashchange', applyHash);
}, []);
return (
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
{children}
</TrkContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTrk() {
const ctx = useContext(TrkContext);
if (!ctx) throw new Error('useTrk must be used within TrkProvider');
return ctx;
}

파일 보기

@ -0,0 +1,109 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Badge } from '@shared/components/ui/badge';
import {
BADGE_INTENT_META,
BADGE_INTENT_ORDER,
BADGE_SIZE_ORDER,
} from '@lib/theme/variantMeta';
export function BadgeSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-badge"
title="Badge"
description={`${BADGE_INTENT_ORDER.length} intent × ${BADGE_SIZE_ORDER.length} size = ${BADGE_INTENT_ORDER.length * BADGE_SIZE_ORDER.length} 변형. CVA + cn()로 className override 허용, !important 없음.`}
/>
{/* 변형 그리드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2"> </h3>
<Trk id="TRK-BADGE-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
intent / size
</th>
{BADGE_SIZE_ORDER.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-2">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{BADGE_INTENT_ORDER.map((intent) => (
<tr key={intent}>
<td className="text-[11px] text-label font-mono pr-3 py-1.5">{intent}</td>
{BADGE_SIZE_ORDER.map((size) => (
<td key={size} className="px-2 py-1.5">
<Trk id={`TRK-BADGE-${intent}-${size}`} inline>
<Badge intent={intent} size={size}>
{intent.toUpperCase()}
</Badge>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* intent 의미 가이드 (variantMeta에서 자동 열거) */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Intent </h3>
<div className="ds-grid ds-grid-2">
{BADGE_INTENT_ORDER.map((intent) => {
const meta = BADGE_INTENT_META[intent];
return (
<Trk key={intent} id={`TRK-BADGE-usage-${intent}`} className="ds-sample">
<div className="flex items-center gap-3">
<Badge intent={intent} size="md">
{meta.titleKo}
</Badge>
<span className="text-xs text-label flex-1">{meta.description}</span>
</div>
</Trk>
);
})}
</div>
{/* 사용 예시 코드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
// 카탈로그 API와 결합 — 라벨/색상 변경은 카탈로그 파일에서만
<Badge intent={getAlertLevelIntent('CRITICAL')} size="sm">
{getAlertLevelLabel('CRITICAL', t, lang)}
</Badge>
// className override (tailwind-merge가 같은 그룹 충돌 감지)
<Badge intent="info" size="md" className="rounded-full px-3">
</Badge>`}
</code>
</Trk>
{/* 금지 패턴 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-antipattern" className="ds-sample border-red-500/30">
<div className="text-xs text-red-400 mb-2"> </div>
<code className="ds-code">
{`// ❌ className 직접 작성 (intent prop 무시)
<Badge className="bg-red-400 text-white text-[11px]">...</Badge>
// ❌ !important 사용
<Badge className="!bg-red-400 !text-slate-900">...</Badge>
// ❌ <div className="bg-red-400 ..."> → Badge 컴포넌트 사용 필수
<div className="inline-flex bg-red-400 text-white rounded px-2"></div>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,129 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Button } from '@shared/components/ui/button';
import {
BUTTON_VARIANT_META,
BUTTON_VARIANT_ORDER,
BUTTON_SIZE_ORDER,
} from '@lib/theme/variantMeta';
import { Plus, Download, Trash2, Search, Save } from 'lucide-react';
export function ButtonSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-button"
title="Button"
description={`${BUTTON_VARIANT_ORDER.length} variant × ${BUTTON_SIZE_ORDER.length} size = ${BUTTON_VARIANT_ORDER.length * BUTTON_SIZE_ORDER.length} 변형. CVA 기반, 직접 className 작성 금지.`}
/>
{/* 매트릭스 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2"> </h3>
<Trk id="TRK-BUTTON-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
variant / size
</th>
{BUTTON_SIZE_ORDER.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-3">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{BUTTON_VARIANT_ORDER.map((variant) => (
<tr key={variant}>
<td className="text-[11px] text-label font-mono pr-3 py-2">{variant}</td>
{BUTTON_SIZE_ORDER.map((size) => (
<td key={size} className="px-3 py-2">
<Trk id={`TRK-BUTTON-${variant}-${size}`} inline>
<Button variant={variant} size={size}>
{variant}
</Button>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* 아이콘 버튼 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-with-icon" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
</Button>
<Button variant="outline" icon={<Search className="w-4 h-4" />}>
</Button>
<Button variant="ghost" icon={<Save className="w-4 h-4" />}>
</Button>
<Button variant="destructive" icon={<Trash2 className="w-4 h-4" />}>
</Button>
</div>
</Trk>
{/* 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"></h3>
<Trk id="TRK-BUTTON-states" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Normal</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
</Trk>
{/* variant 의미 가이드 (variantMeta에서 자동 열거) */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Variant </h3>
<div className="ds-grid ds-grid-2">
{BUTTON_VARIANT_ORDER.map((variant) => {
const meta = BUTTON_VARIANT_META[variant];
return (
<Trk key={variant} id={`TRK-BUTTON-usage-${variant}`} className="ds-sample">
<div className="flex items-center gap-3">
<Button variant={variant} size="sm">
{meta.titleKo}
</Button>
<span className="text-xs text-label flex-1">{meta.description}</span>
</div>
</Trk>
);
})}
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Button } from '@shared/components/ui/button';
import { Plus } from 'lucide-react';
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
</Button>
// 금지
// ❌ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
// ❌ <Button className="bg-red-500"> → variant="destructive" 사용`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,111 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import { Database, Activity, Bell } from 'lucide-react';
export function CardSectionShowcase() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-card"
title="Card · Section"
description="4 variant (default/elevated/inner/transparent). CardHeader+CardTitle+CardContent 조합."
/>
{/* 4 variant */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Variant</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-CARD-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=default</label>
<Card variant="default">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">PostgreSQL</span>
<span className="text-label">v15.4 </span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">8 / 20</span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-elevated" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=elevated ()</label>
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-green-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">API</span>
<span className="text-green-400"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint">Prediction</span>
<span className="text-green-400">5 </span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-inner" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=inner</label>
<Card variant="inner">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Bell className="w-3.5 h-3.5 text-yellow-400" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label"> . depth.</p>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-transparent" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=transparent</label>
<Card variant="transparent">
<CardHeader className="pb-2">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label">/ ( ).</p>
</CardContent>
</Card>
</Trk>
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-CARD-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import { Database } from 'lucide-react';
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* 콘텐츠 */}
</CardContent>
</Card>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,130 @@
import { type ReactNode } from 'react';
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Badge } from '@shared/components/ui/badge';
import type { BadgeIntent } from '@lib/theme/variants';
import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRegistry';
/**
* `CATALOG_REGISTRY` .
* `catalogRegistry.ts` .
* .
*/
interface AnyMeta {
code: string;
intent?: BadgeIntent;
fallback?: { ko: string; en: string };
classes?: string | { bg?: string; text?: string; border?: string };
label?: string;
}
function getKoLabel(meta: AnyMeta): string {
return meta.fallback?.ko ?? meta.label ?? meta.code;
}
function getEnLabel(meta: AnyMeta): string | undefined {
return meta.fallback?.en;
}
function getFallbackClasses(meta: AnyMeta): string | undefined {
if (typeof meta.classes === 'string') return meta.classes;
if (typeof meta.classes === 'object' && meta.classes) {
return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' ');
}
return undefined;
}
function renderBadge(meta: AnyMeta, label: string): ReactNode {
if (meta.intent) {
return (
<Badge intent={meta.intent} size="sm">
{label}
</Badge>
);
}
const classes =
getFallbackClasses(meta) ??
'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30';
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md border text-[12px] font-semibold ${classes}`}
>
{label}
</span>
);
}
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
const items = Object.values(entry.items) as AnyMeta[];
return (
<div className="space-y-1.5">
{items.map((meta) => {
const koLabel = getKoLabel(meta);
const enLabel = getEnLabel(meta);
const trkId = `${entry.showcaseId}-${meta.code}`;
return (
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
{meta.code}
</code>
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
<div className="flex-1">
{enLabel ? (
renderBadge(meta, enLabel)
) : (
<span className="text-[10px] text-hint italic">(no en)</span>
)}
</div>
</Trk>
);
})}
</div>
);
}
export function CatalogSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-catalog"
title={`분류 카탈로그 (${CATALOG_REGISTRY.length}+)`}
description="백엔드 enum/code_master 기반 SSOT. 쇼케이스와 실 페이지가 동일 레지스트리 참조."
/>
<Trk id="TRK-CAT-intro" className="ds-sample mb-4">
<p className="text-xs text-label leading-relaxed">
API를 (/intent/ ):
</p>
<code className="ds-code mt-2">
{`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>`}
</code>
<p className="text-[11px] text-hint mt-2">
<strong> </strong>: <code className="font-mono">shared/constants/catalogRegistry.ts</code>
.
</p>
</Trk>
{CATALOG_REGISTRY.map((entry) => (
<Trk key={entry.id} id={entry.showcaseId} className="ds-sample mb-3">
<div className="mb-2">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-sm font-semibold text-heading">
{entry.titleKo} · {entry.titleEn}
</h3>
<code className="text-[10px] text-hint font-mono">{entry.showcaseId}</code>
</div>
<p className="text-[11px] text-hint">{entry.description}</p>
{entry.source && (
<p className="text-[10px] text-hint italic mt-0.5">: {entry.source}</p>
)}
</div>
<CatalogBadges entry={entry} />
</Trk>
))}
</>
);
}

파일 보기

@ -0,0 +1,124 @@
import { useState } from 'react';
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Textarea } from '@shared/components/ui/textarea';
import { Checkbox } from '@shared/components/ui/checkbox';
import { Radio } from '@shared/components/ui/radio';
export function FormSection() {
const [radio, setRadio] = useState('a');
return (
<>
<TrkSectionHeader
id="TRK-SEC-form"
title="Form 요소"
description="Input · Select · Textarea · Checkbox · Radio. 공통 스타일 토큰 공유."
/>
{/* Input 사이즈 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Input · </h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-input-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Input size={size} placeholder={`Input size=${size}`} />
</Trk>
))}
</div>
{/* Input 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Input · </h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-FORM-input-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=default</label>
<Input placeholder="기본 상태" />
</Trk>
<Trk id="TRK-FORM-input-error" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=error</label>
<Input state="error" defaultValue="잘못된 값" />
</Trk>
<Trk id="TRK-FORM-input-success" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=success</label>
<Input state="success" defaultValue="유효한 값" />
</Trk>
<Trk id="TRK-FORM-input-disabled" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">disabled</label>
<Input disabled defaultValue="비활성" />
</Trk>
<Trk id="TRK-FORM-input-readonly" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">readOnly</label>
<Input readOnly defaultValue="읽기 전용" />
</Trk>
<Trk id="TRK-FORM-input-type-number" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">type=number</label>
<Input type="number" defaultValue={42} />
</Trk>
</div>
{/* Select */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Select</h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Select aria-label={`쇼케이스 샘플 Select ${size}`} size={size} defaultValue="">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
</Select>
</Trk>
))}
</div>
{/* Textarea */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Textarea</h3>
<Trk id="TRK-FORM-textarea" className="ds-sample">
<Textarea placeholder="여러 줄 입력..." rows={3} />
</Trk>
{/* Checkbox */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Checkbox</h3>
<Trk id="TRK-FORM-checkbox" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Checkbox label="옵션 1" defaultChecked />
<Checkbox label="옵션 2" />
<Checkbox label="비활성" disabled />
<Checkbox label="체크됨 비활성" disabled defaultChecked />
</div>
</Trk>
{/* Radio */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radio</h3>
<Trk id="TRK-FORM-radio" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Radio name="grp" label="선택 A" checked={radio === 'a'} onChange={() => setRadio('a')} />
<Radio name="grp" label="선택 B" checked={radio === 'b'} onChange={() => setRadio('b')} />
<Radio name="grp" label="선택 C" checked={radio === 'c'} onChange={() => setRadio('c')} />
<Radio name="grp" label="비활성" disabled />
</div>
</Trk>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-FORM-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Checkbox } from '@shared/components/ui/checkbox';
<Input size="md" placeholder="검색어 입력" value={q} onChange={(e) => setQ(e.target.value)} />
<Select size="sm" value={level} onChange={(e) => setLevel(e.target.value)}>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
</Select>
<Checkbox label="활성화" checked={active} onChange={(e) => setActive(e.target.checked)} />`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,130 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function GuideSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-guide"
title="예외 처리 · 가이드"
description="공통 패턴에서 벗어날 때의 명시적 규칙"
/>
<Trk id="TRK-GUIDE-fullbleed" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> fullBleed를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> (LiveMapView,
VesselDetail)
</li>
<li>
<strong className="text-heading">3 </strong> + +
padding이
</li>
<li>
<strong className="text-heading"> 100% </strong>
</li>
</ul>
<p className="text-xs text-hint mt-2">
: <code className="font-mono">&lt;PageContainer fullBleed&gt;</code> .{' '}
<code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin .
</p>
</Trk>
<Trk id="TRK-GUIDE-classname-override" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> className override를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> width/margin (
<code className="font-mono">w-48</code>, <code className="font-mono">flex-1</code> )
</li>
<li>
<strong className="text-heading"> </strong> sm/md/lg
</li>
</ul>
<p className="text-xs text-label mt-2">
<strong className="text-green-400">:</strong>{' '}
<code className="font-mono">
&lt;Badge intent="info" className="w-full justify-center"&gt;
</code>
</p>
<p className="text-xs text-label">
<strong className="text-red-400">:</strong>{' '}
<code className="font-mono">&lt;Badge className="bg-red-500 text-white"&gt;</code>{' '}
<span className="text-hint">intent prop을 </span>
</p>
</Trk>
<Trk id="TRK-GUIDE-dynamic-color" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> hex </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
DB에서 (: Role.colorHex) <code className="font-mono">style={`{{ background: role.colorHex }}`}</code>{' '}
</li>
<li>
<code className="font-mono">getAlertLevelHex(level)</code> API에서 hex
</li>
<li>
deck.gl RGB , hex
</li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-anti-patterns" className="ds-sample mt-3 border border-red-500/30">
<h3 className="text-sm font-semibold text-red-400 mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 leading-relaxed">
<li> <code className="font-mono">!important</code> prefix (<code className="font-mono">!bg-red-500</code>)</li>
<li> <code className="font-mono">className="bg-X text-Y"</code> Badge </li>
<li> <code className="font-mono">&lt;button className="bg-blue-600 ..."&gt;</code> Button </li>
<li> <code className="font-mono">&lt;input className="bg-surface ..."&gt;</code> Input </li>
<li> <code className="font-mono">p-4 space-y-5</code> padding PageContainer size </li>
<li> <code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin fullBleed </li>
<li> <code className="font-mono">const STATUS_COLORS = {`{...}`}</code> shared/constants </li>
<li> <code className="font-mono">date.toLocaleString('ko-KR', ...)</code> <code className="font-mono">formatDateTime</code> </li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-new-page" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> 릿</h3>
<code className="ds-code">
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { formatDateTime } from '@shared/utils/dateFormat';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyNewPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="새 페이지"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
<span className="text-xs text-hint">{formatDateTime(row.createdAt)}</span>
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,68 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function IntroSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-intro"
title="KCG Design System"
description="kcg-ai-monitoring 프론트엔드의 시각 언어 · 컴포넌트 · 레이아웃 규칙 단일 참조 페이지"
/>
<Trk id="TRK-INTRO-overview" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong>
. `className="bg-red-600"` .
</li>
<li>
<strong className="text-heading"> </strong> · ·
.
</li>
<li>
<strong className="text-heading">ID </strong> <code>TRK-*</code> ID가 ,
· .
</li>
</ul>
</Trk>
<Trk id="TRK-INTRO-howto" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ol className="text-xs text-label space-y-1.5 list-decimal list-inside leading-relaxed">
<li>
<strong className="text-heading">"ID 복사 모드"</strong> ID가
.
</li>
<li>
URL <code className="font-mono text-blue-400">#trk=TRK-BADGE-critical-sm</code>
+ .
</li>
<li>
<strong className="text-heading">Dark / Light</strong> .
</li>
<li>
. <code>TRK-SEC-*</code> ID가 .
</li>
</ol>
</Trk>
<Trk id="TRK-INTRO-naming" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> ID </h3>
<code className="ds-code">
{`TRK-<카테고리>-<슬러그>[-<변형>]
:
TRK-TOKEN-text-heading --text-heading
TRK-BADGE-critical-sm Badge intent=critical size=sm
TRK-BUTTON-primary-md Button variant=primary size=md
TRK-LAYOUT-page-container PageContainer
TRK-CAT-alert-level-HIGH alertLevels HIGH
TRK-SEC-badge Badge
카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,177 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Shield, BarChart3, Plus, Search } from 'lucide-react';
export function LayoutSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-layout"
title="Layout 컴포넌트"
description="PageContainer · PageHeader · Section. 40+ 페이지 레이아웃 표준."
/>
{/* PageContainer */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">PageContainer</h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-LAYOUT-page-container-sm" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=sm (p-4 space-y-3)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="sm">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-md" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=md · (p-5 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="md">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-lg" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=lg (p-6 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="lg">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
</div>
<Trk id="TRK-LAYOUT-page-container-fullbleed" className="ds-sample mt-3">
<label className="text-[10px] text-hint font-mono mb-1 block">
fullBleed / (padding 0, space-y 0)
</label>
<div className="border border-dashed border-slate-600/40 rounded h-16 relative">
<PageContainer fullBleed className="h-full">
<div className="h-full bg-blue-500/30 rounded flex items-center justify-center text-xs text-heading">
fullBleed: 가장자리까지 (LiveMapView / VesselDetail )
</div>
</PageContainer>
</div>
</Trk>
{/* PageHeader */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">PageHeader</h3>
<div className="space-y-3">
<Trk id="TRK-LAYOUT-page-header-simple" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> (title + description)</label>
<PageHeader title="대시보드" description="실시간 종합 상황판" />
</Trk>
<Trk id="TRK-LAYOUT-page-header-icon" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자/역할/권한 설정"
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-demo" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title="AI 모델 관리"
description="ML 모델 배포 상태"
demo
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
title="보고서 관리"
description="단속 증거 보고서 목록"
actions={
<>
<Input size="sm" placeholder="검색..." className="w-40" />
<Button variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />}>
</Button>
<Button variant="primary" size="sm" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
</>
}
/>
</Trk>
</div>
{/* Section */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Section (Card )</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-LAYOUT-section-basic" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> Section</label>
<Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
<div className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">23</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">12</span>
</div>
</div>
</Section>
</Trk>
<Trk id="TRK-LAYOUT-section-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<Section
title="최근 이벤트"
icon={BarChart3}
actions={
<Button variant="ghost" size="sm">
</Button>
}
>
<div className="text-[11px] text-hint"> 3</div>
</Section>
</Trk>
</div>
{/* 전체 조합 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-LAYOUT-full-example" className="ds-sample">
<code className="ds-code">
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Shield, Plus } from 'lucide-react';
export function AccessControlPage() {
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자별 역할 및 권한 매핑"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="사용자 목록">
<DataTable ... />
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,160 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
/** 시맨틱 색상 토큰 목록. theme.css :root 정의 기준 */
const SURFACE_TOKENS = [
{ id: 'background', label: '--background', desc: '페이지 최하위 배경' },
{ id: 'card', label: '--card', desc: 'Card 컴포넌트 배경' },
{ id: 'surface-raised', label: '--surface-raised', desc: '카드 내부 섹션' },
{ id: 'surface-overlay', label: '--surface-overlay', desc: '모달/오버레이' },
{ id: 'muted', label: '--muted', desc: '약한 배경 (inactive/disabled)' },
{ id: 'popover', label: '--popover', desc: '드롭다운/툴팁 배경' },
];
const TEXT_TOKENS = [
{ id: 'text-heading', label: '--text-heading', example: '제목 Heading', desc: 'h1~h4, 강조 텍스트' },
{ id: 'text-label', label: '--text-label', example: '본문 Label', desc: '일반 본문, 라벨' },
{ id: 'text-hint', label: '--text-hint', example: '보조 Hint', desc: '설명, 힌트, placeholder' },
{ id: 'text-on-vivid', label: '--text-on-vivid', example: '컬러풀 위 텍스트', desc: '-600/-700 진한 배경 위 (버튼)', bg: '#2563eb' },
{ id: 'text-on-bright', label: '--text-on-bright', example: '밝은 위 텍스트', desc: '-300/-400 밝은 배경 위 (배지)', bg: '#60a5fa' },
];
const BRAND_COLORS = [
{ id: 'primary', label: '--primary', desc: '기본 강조색 (파랑)' },
{ id: 'destructive', label: '--destructive', desc: '위험/삭제 (빨강)' },
{ id: 'ring', label: '--ring', desc: 'focus ring' },
{ id: 'border', label: '--border', desc: '경계선' },
];
const CHART_COLORS = [
{ id: 'chart-1', label: '--chart-1' },
{ id: 'chart-2', label: '--chart-2' },
{ id: 'chart-3', label: '--chart-3' },
{ id: 'chart-4', label: '--chart-4' },
{ id: 'chart-5', label: '--chart-5' },
];
const RADIUS_SCALE = [
{ id: 'sm', label: 'radius-sm', cls: 'rounded-sm', px: 'calc(radius - 4px)' },
{ id: 'md', label: 'radius-md', cls: 'rounded-md', px: 'calc(radius - 2px)' },
{ id: 'lg', label: 'radius-lg', cls: 'rounded-lg', px: '0.5rem (default)' },
{ id: 'xl', label: 'radius-xl', cls: 'rounded-xl', px: 'calc(radius + 4px)' },
{ id: 'full', label: 'rounded-full', cls: 'rounded-full', px: '9999px' },
];
const SPACING_SCALE = [
{ id: '1', size: 4 }, { id: '2', size: 8 }, { id: '3', size: 12 },
{ id: '4', size: 16 }, { id: '5', size: 20 }, { id: '6', size: 24 },
{ id: '8', size: 32 }, { id: '10', size: 40 },
];
export function TokenSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-token"
title="테마 · 시맨틱 토큰"
description="모든 색상/여백은 CSS 변수로 관리. 다크/라이트 모드는 값만 바뀌고 이름은 동일."
/>
{/* Surface 토큰 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-4">Surface / </h3>
<div className="ds-grid ds-grid-3">
{SURFACE_TOKENS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className={`h-16 rounded-md border border-slate-700/40 mb-2`}
style={{ background: `var(--${t.id})` }}
/>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
</Trk>
))}
</div>
{/* 텍스트 토큰 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Text / </h3>
<div className="ds-grid ds-grid-3">
{TEXT_TOKENS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className="h-16 rounded-md flex items-center justify-center text-sm font-semibold mb-2"
style={{
background: t.bg ?? 'var(--surface-overlay)',
color: `var(--${t.id})`,
}}
>
{t.example}
</div>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
</Trk>
))}
</div>
{/* 브랜드 색 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Brand / </h3>
<div className="ds-grid ds-grid-4">
{BRAND_COLORS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className="h-12 rounded-md border border-slate-700/40 mb-2"
style={{ background: `var(--${t.id})` }}
/>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
</Trk>
))}
</div>
{/* Chart 색 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Chart / </h3>
<Trk id="TRK-TOKEN-chart-palette" className="ds-sample">
<div className="flex gap-2">
{CHART_COLORS.map((t) => (
<div key={t.id} className="flex-1 text-center">
<div
className="h-12 rounded-md border border-slate-700/40 mb-1"
style={{ background: `var(--${t.id})` }}
/>
<div className="text-[10px] text-hint font-mono">{t.label}</div>
</div>
))}
</div>
<div className="ds-sample-label">TRK-TOKEN-chart-palette</div>
</Trk>
{/* Radius 스케일 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radius / </h3>
<Trk id="TRK-TOKEN-radius-scale" className="ds-sample">
<div className="flex gap-4 items-end">
{RADIUS_SCALE.map((r) => (
<div key={r.id} className="flex-1 text-center">
<div className={`h-16 bg-blue-500 ${r.cls} mb-1`} />
<div className="text-[11px] text-heading">{r.label}</div>
<div className="text-[9px] text-hint font-mono">{r.px}</div>
</div>
))}
</div>
</Trk>
{/* Spacing 스케일 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Spacing / </h3>
<Trk id="TRK-TOKEN-spacing-scale" className="ds-sample">
<div className="space-y-1.5">
{SPACING_SCALE.map((s) => (
<div key={s.id} className="flex items-center gap-3">
<code className="text-[10px] text-hint font-mono w-12">p-{s.id}</code>
<div
className="h-4 bg-blue-500 rounded-sm"
style={{ width: `${s.size}px` }}
/>
<span className="text-[10px] text-hint font-mono">{s.size}px</span>
</div>
))}
</div>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,35 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
const TYPE_SCALE = [
{ id: 'h1', tag: 'h1' as const, cls: 'text-2xl font-bold text-heading', example: '주요 타이틀 (H1 · text-2xl)' },
{ id: 'h2', tag: 'h2' as const, cls: 'text-xl font-bold text-heading', example: '페이지 제목 (H2 · text-xl)' },
{ id: 'h3', tag: 'h3' as const, cls: 'text-lg font-semibold text-heading', example: '섹션 제목 (H3 · text-lg)' },
{ id: 'h4', tag: 'h4' as const, cls: 'text-base font-semibold text-heading', example: '카드 제목 (H4 · text-base)' },
{ id: 'body', tag: 'p' as const, cls: 'text-sm text-label', example: '본문 텍스트 (body · text-sm)' },
{ id: 'body-sm', tag: 'p' as const, cls: 'text-xs text-label', example: '작은 본문 (body-sm · text-xs)' },
{ id: 'label', tag: 'span' as const, cls: 'text-[11px] text-label font-medium', example: '라벨 (label · 11px)' },
{ id: 'hint', tag: 'span' as const, cls: 'text-[10px] text-hint', example: '힌트/캡션 (hint · 10px)' },
];
export function TypographySection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-typography"
title="타이포그래피"
description="8단계 텍스트 스케일 + 시맨틱 색상 토큰 조합"
/>
<div className="space-y-2">
{TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => (
<Trk key={id} id={`TRK-TYPO-${id}`} className="ds-sample">
<div className="flex items-center justify-between gap-4">
<Tag className={cls}>{example}</Tag>
<code className="text-[10px] text-hint font-mono whitespace-nowrap">{cls}</code>
</div>
</Trk>
))}
</div>
</>
);
}

파일 보기

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/index.css';
import { DesignSystemApp } from './design-system/DesignSystemApp';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<DesignSystemApp />
</StrictMode>,
);

파일 보기

@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog,
@ -18,6 +20,9 @@ import {
type AuditStats,
} from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
import { getUserAccountStatusIntent, getUserAccountStatusLabel } from '@shared/constants/userAccountStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { PermissionsPanel } from './PermissionsPanel';
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
@ -31,32 +36,12 @@ import { UserRoleAssignDialog } from './UserRoleAssignDialog';
* 4) -
*/
const ROLE_COLORS: Record<string, string> = {
ADMIN: 'bg-red-500/20 text-red-400',
OPERATOR: 'bg-blue-500/20 text-blue-400',
ANALYST: 'bg-purple-500/20 text-purple-400',
FIELD: 'bg-green-500/20 text-green-400',
VIEWER: 'bg-yellow-500/20 text-yellow-400',
};
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-500/20 text-green-400',
LOCKED: 'bg-red-500/20 text-red-400',
INACTIVE: 'bg-gray-500/20 text-gray-400',
PENDING: 'bg-yellow-500/20 text-yellow-400',
};
const STATUS_LABELS: Record<string, string> = {
ACTIVE: '활성',
LOCKED: '잠금',
INACTIVE: '비활성',
PENDING: '승인대기',
};
type Tab = 'roles' | 'users' | 'audit' | 'policy';
export function AccessControl() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [tab, setTab] = useState<Tab>('roles');
// 공통 상태
@ -135,7 +120,7 @@ export function AccessControl() {
return (
<div className="flex flex-wrap gap-1">
{list.map((r) => (
<Badge key={r} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
<Badge key={r} size="sm" style={getRoleBadgeStyle(r)}>{r}</Badge>
))}
</div>
);
@ -144,7 +129,7 @@ export function AccessControl() {
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
render: (v) => {
const s = v as string;
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</Badge>;
return <Badge intent={getUserAccountStatusIntent(s)} size="sm">{getUserAccountStatusLabel(s, tc, lang)}</Badge>;
},
},
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
@ -191,8 +176,7 @@ export function AccessControl() {
{ key: 'result', label: '결과', width: '70px', sortable: true,
render: (v) => {
const r = v as string;
const c = r === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400';
return <Badge className={`border-0 text-[9px] ${c}`}>{r || '-'}</Badge>;
return <Badge intent={r === 'SUCCESS' ? 'success' : 'critical'} size="xs">{r || '-'}</Badge>;
},
},
{ key: 'failReason', label: '실패 사유',
@ -200,33 +184,36 @@ export function AccessControl() {
], []);
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
{t('accessControl.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('accessControl.desc')}</p>
</div>
<div className="flex items-center gap-2">
{userStats && (
<div className="flex items-center gap-2 text-[10px] text-hint">
<UserCheck className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-400 font-bold">{userStats.active}</span>
<span className="mx-1">|</span>
<span className="text-red-400 font-bold">{userStats.locked}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.total}</span>
</div>
)}
<button type="button"
onClick={() => { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
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>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title={t('accessControl.title')}
description={t('accessControl.desc')}
actions={
<>
{userStats && (
<div className="flex items-center gap-2 text-[10px] text-hint">
<UserCheck className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-400 font-bold">{userStats.active}</span>
<span className="mx-1">|</span>
<span className="text-red-400 font-bold">{userStats.locked}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.total}</span>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
title="새로고침"
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
<span className="sr-only"></span>
</Button>
</>
}
/>
{/* 탭 */}
<div className="flex gap-1">
@ -241,7 +228,7 @@ export function AccessControl() {
type="button"
onClick={() => setTab(tt.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
tab === tt.key ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
>
<tt.icon className="w-3.5 h-3.5" />
@ -371,7 +358,7 @@ export function AccessControl() {
]} />
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,9 +1,12 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Loader2, RefreshCw, Activity } 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes';
/**
* + .
@ -30,22 +33,19 @@ export function AccessLogs() {
useEffect(() => { load(); }, [load]);
const statusColor = (s: number) =>
s >= 500 ? 'bg-red-500/20 text-red-400'
: s >= 400 ? 'bg-orange-500/20 text-orange-400'
: 'bg-green-500/20 text-green-400';
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">AccessLogFilter가 HTTP </p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<PageContainer size="lg">
<PageHeader
icon={Activity}
iconColor="text-cyan-400"
title="접근 이력"
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
actions={
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
</Button>
}
/>
{stats && (
<div className="grid grid-cols-5 gap-3">
@ -113,7 +113,7 @@ export function AccessLogs() {
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
<td className="px-3 py-2 text-center">
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
</td>
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
@ -124,7 +124,7 @@ export function AccessLogs() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Settings, Server, HardDrive, Shield, Clock, Database } from 'lucide-react';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { Settings, Server, Shield, Database } from 'lucide-react';
/*
* , ,
@ -30,17 +33,13 @@ function UsageBar({ value }: { value: number }) {
export function AdminPanel() {
const { t } = useTranslation('admin');
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Settings className="w-5 h-5 text-muted-foreground" />
{t('adminPanel.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Settings}
title={t('adminPanel.title')}
description={t('adminPanel.desc')}
demo
/>
{/* 서버 상태 */}
<div className="grid grid-cols-3 gap-3">
@ -52,9 +51,7 @@ export function AdminPanel() {
<Server className="w-4 h-4 text-hint" />
<span className="text-[11px] font-bold text-heading">{s.name}</span>
</div>
<span className={`text-[9px] font-bold px-2 py-0.5 rounded ${
s.status === '정상' ? 'bg-green-500/20 text-green-400' : s.status === '주의' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'
}`}>{s.status}</span>
<Badge intent={getStatusIntent(s.status)} size="xs">{s.status}</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between"><span className="text-[9px] text-hint">CPU</span><UsageBar value={s.cpu} /></div>
@ -89,6 +86,6 @@ export function AdminPanel() {
</CardContent>
</Card>
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -1,7 +1,9 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Loader2, RefreshCw, FileSearch } 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
@ -31,16 +33,18 @@ export function AuditLogs() {
useEffect(() => { load(); }, [load]);
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">@Auditable AOP가 </p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<PageContainer size="lg">
<PageHeader
icon={FileSearch}
iconColor="text-blue-400"
title="감사 로그"
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
actions={
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 통계 카드 */}
{stats && (
@ -99,7 +103,7 @@ export function AuditLogs() {
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${it.result === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
<Badge intent={it.result === 'SUCCESS' ? 'success' : 'critical'} size="xs">
{it.result || '-'}
</Badge>
</td>
@ -115,7 +119,7 @@ export function AuditLogs() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,8 +2,21 @@ import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { SaveButton } from '@shared/components/common/SaveButton';
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
import type { BadgeIntent } from '@lib/theme/variants';
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
function jobStatusIntent(s: string): BadgeIntent {
if (s === '수행중') return 'success';
if (s === '대기중') return 'warning';
if (s === '장애발생') return 'critical';
return 'muted';
}
import {
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
@ -43,11 +56,7 @@ const SIGNAL_SOURCES: SignalSource[] = [
{ name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() },
];
const SIGNAL_COLORS: Record<SignalStatus, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
};
// SIGNAL_COLORS는 connectionStatuses 카탈로그에서 가져옴 (getConnectionStatusHex)
const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}`);
@ -111,7 +120,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
{ key: 'linkInfo', label: '연계정보', width: '65px' },
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
{ key: 'linkMethod', label: '연계방식', width: '70px', align: 'center',
render: (v) => <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v as string}</Badge>,
render: (v) => <Badge intent="purple" size="sm">{v as string}</Badge>,
},
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
render: (v) => {
@ -129,7 +138,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
const on = v === 'ON';
return (
<div className="flex flex-col items-center gap-0.5">
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-heading' : 'bg-red-500 text-heading'}`}>
<Badge intent={on ? 'info' : 'critical'} size="xs">
{v as string}
</Badge>
{row.lastUpdate && (
@ -163,7 +172,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
<div
key={i}
className="flex-1 h-5 rounded-[1px]"
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
style={{ backgroundColor: getConnectionStatusHex(status), minWidth: '2px' }}
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')}${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
/>
))}
@ -209,18 +218,14 @@ const collectColumns: DataColumn<CollectJob>[] = [
{ key: 'serverType', label: '타입', width: '60px', align: 'center', sortable: true,
render: (v) => {
const t = v as string;
const c = t === 'SQL' ? 'bg-blue-500/20 text-blue-400' : t === 'FILE' ? 'bg-green-500/20 text-green-400' : 'bg-purple-500/20 text-purple-400';
return <Badge className={`border-0 text-[9px] ${c}`}>{t}</Badge>;
const intent: BadgeIntent = t === 'SQL' ? 'info' : t === 'FILE' ? 'success' : 'purple';
return <Badge intent={intent} size="xs">{t}</Badge>;
},
},
{ key: 'serverName', label: '서버명', width: '120px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'serverIp', label: 'IP', width: '120px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '80px', align: 'center', sortable: true,
render: (v) => {
const s = v as JobStatus;
const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground';
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
},
render: (v) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</Badge>,
},
{ key: 'schedule', label: '스케줄', width: '80px' },
{ key: 'lastRun', label: '최종 수행', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
@ -235,12 +240,12 @@ const collectColumns: DataColumn<CollectJob>[] = [
render: (_v, row) => (
<div className="flex items-center gap-0.5">
{row.status === '정지' ? (
<button className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
) : row.status !== '장애발생' ? (
<button className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
) : null}
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
</div>
),
},
@ -274,15 +279,11 @@ const LOAD_JOBS: LoadJob[] = [
const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge className="bg-cyan-500/15 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge intent="cyan" size="sm">{v as string}</Badge> },
{ key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'targetDb', label: 'DB', width: '70px', align: 'center' },
{ key: 'status', label: '상태', width: '80px', align: 'center', sortable: true,
render: (v) => {
const s = v as JobStatus;
const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground';
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
},
render: (v) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</Badge>,
},
{ key: 'schedule', label: '스케줄', width: '80px' },
{ key: 'lastRun', label: '최종 적재', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
@ -290,9 +291,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
render: () => (
<div className="flex items-center gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
</div>
),
},
@ -383,28 +384,19 @@ export function DataHub() {
);
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Database className="w-5 h-5 text-cyan-400" />
{t('dataHub.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('dataHub.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-cyan-400"
title={t('dataHub.title')}
description={t('dataHub.desc')}
demo
actions={
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</button>
</div>
</div>
</Button>
}
/>
{/* KPI */}
<div className="flex gap-2">
@ -427,7 +419,7 @@ export function DataHub() {
</div>
{/* 탭 */}
<div className="flex gap-1">
<TabBar variant="pill">
{[
{ key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' },
{ key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' },
@ -435,18 +427,17 @@ export function DataHub() {
{ key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' },
{ key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' },
].map((t) => (
<button
<TabButton
key={t.key}
variant="pill"
active={tab === t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
icon={<t.icon className="w-3.5 h-3.5" />}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
</button>
</TabButton>
))}
</div>
</TabBar>
{/* ── ① 선박신호 수신 현황 ── */}
{tab === 'signal' && (
@ -458,16 +449,16 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label="수신 현황 기준일"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-heading focus:outline-none focus:border-cyan-500/50"
/>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-slate-700/50 rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</button>
</Button>
</div>
</div>
@ -536,17 +527,15 @@ export function DataHub() {
{/* 상태 필터 */}
<div className="ml-auto flex items-center gap-1">
{(['', 'ON', 'OFF'] as const).map((f) => (
<button
<TabButton
key={f}
variant="pill"
active={statusFilter === f}
onClick={() => setStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${
statusFilter === f
? 'bg-cyan-600 text-heading font-bold'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
className="px-2.5 py-1 text-[10px]"
>
{f || '전체'}
</button>
</TabButton>
))}
</div>
</div>
@ -569,19 +558,21 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint"> :</span>
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
<button key={f} onClick={() => setCollectTypeFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={collectTypeFilter === f} onClick={() => setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setCollectStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={collectStatusFilter === f} onClick={() => setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
<div className="ml-auto">
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
</Button>
</div>
</div>
<DataTable data={filteredCollectJobs} columns={collectColumns} pageSize={10}
searchPlaceholder="작업명, 서버명, IP 검색..." searchKeys={['name', 'serverName', 'serverIp']} exportFilename="수집작업목록" />
@ -594,17 +585,17 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setLoadStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={loadStatusFilter === f} onClick={() => setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<div className="ml-auto flex items-center gap-2">
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<FolderOpen className="w-3 h-3" />
</button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
<Plus className="w-3 h-3" />
</button>
<Button variant="secondary" size="sm" icon={<FolderOpen className="w-3 h-3" />}>
</Button>
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
</Button>
</div>
</div>
<DataTable data={filteredLoadJobs} columns={loadColumns} pageSize={10}
@ -618,25 +609,26 @@ export function DataHub() {
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint">:</span>
{(['', '수집', '적재'] as const).map((f) => (
<button key={f} onClick={() => setAgentRoleFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={agentRoleFilter === f} onClick={() => setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<span className="text-[10px] text-hint ml-3">:</span>
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
<button key={f} onClick={() => setAgentStatusFilter(f)}
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
>{f || '전체'}</button>
<TabButton key={f} variant="pill" active={agentStatusFilter === f} onClick={() => setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
{f || '전체'}
</TabButton>
))}
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
<div className="ml-auto">
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</Button>
</div>
</div>
{/* 연계서버 카드 그리드 */}
<div className="grid grid-cols-2 gap-3">
{filteredAgents.map((agent) => {
const stColor = agent.status === '수행중' ? 'text-green-400 bg-green-500/15' : agent.status === '대기중' ? 'text-yellow-400 bg-yellow-500/15' : agent.status === '장애발생' ? 'text-red-400 bg-red-500/15' : 'text-muted-foreground bg-muted';
return (
<Card key={agent.id} className="bg-surface-raised border-border">
<CardContent className="p-4">
@ -645,7 +637,7 @@ export function DataHub() {
<div className="text-[12px] font-bold text-heading">{agent.name}</div>
<div className="text-[10px] text-hint">{agent.id} · {agent.role}</div>
</div>
<Badge className={`border-0 text-[9px] ${stColor}`}>{agent.status}</Badge>
<Badge intent={jobStatusIntent(agent.status)} size="xs">{agent.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px] mb-3">
<div className="flex justify-between"><span className="text-hint">Hostname</span><span className="text-label font-mono">{agent.hostname}</span></div>
@ -661,9 +653,9 @@ export function DataHub() {
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
<span className="text-[9px] text-hint"> {agent.taskCount} · heartbeat {agent.lastHeartbeat.slice(11)}</span>
<div className="flex gap-0.5">
<button className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
</CardContent>
@ -673,6 +665,6 @@ export function DataHub() {
</div>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,15 +1,22 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Loader2, RefreshCw, LogIn } 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
import { formatDateTime, formatDate } from '@shared/utils/dateFormat';
import { getLoginResultIntent, getLoginResultLabel } from '@shared/constants/loginResultStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* + .
* 권한: admin:login-history (READ)
*/
export function LoginHistoryView() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [items, setItems] = useState<LoginHistory[]>([]);
const [stats, setStats] = useState<LoginStats | null>(null);
const [loading, setLoading] = useState(false);
@ -30,23 +37,19 @@ export function LoginHistoryView() {
useEffect(() => { load(); }, [load]);
const resultColor = (r: string) => {
if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400';
if (r === 'LOCKED') return 'bg-red-500/20 text-red-400';
return 'bg-orange-500/20 text-orange-400';
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">/ (5 )</p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<PageContainer size="lg">
<PageHeader
icon={LogIn}
iconColor="text-green-400"
title="로그인 이력"
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
actions={
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 통계 카드 */}
{stats && (
@ -122,7 +125,7 @@ export function LoginHistoryView() {
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
@ -134,7 +137,7 @@ export function LoginHistoryView() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,9 @@ 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 type { BadgeIntent } from '@lib/theme/variants';
import {
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
Users, Megaphone, AlertTriangle, Info, Search, Filter,
@ -123,10 +126,10 @@ export function NoticeManagement() {
}));
};
const getStatus = (n: SystemNotice) => {
if (n.endDate < now) return { label: '종료', color: 'bg-muted text-muted-foreground' };
if (n.startDate > now) return { label: '예약', color: 'bg-blue-500/20 text-blue-400' };
return { label: '노출 중', color: 'bg-green-500/20 text-green-400' };
const getStatus = (n: SystemNotice): { label: string; intent: BadgeIntent } => {
if (n.endDate < now) return { label: '종료', intent: 'muted' };
if (n.startDate > now) return { label: '예약', intent: 'info' };
return { label: '노출 중', intent: 'success' };
};
// KPI
@ -135,29 +138,19 @@ export function NoticeManagement() {
const urgentCount = notices.filter((n) => n.type === 'urgent' && n.startDate <= now && n.endDate >= now).length;
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Bell className="w-5 h-5 text-yellow-400" />
{t('notices.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('notices.desc')}
</p>
</div>
<button
onClick={openNew}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<PageContainer>
<PageHeader
icon={Bell}
iconColor="text-yellow-400"
title={t('notices.title')}
description={t('notices.desc')}
demo
actions={
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* KPI — 가로 한 줄 */}
<div className="flex gap-2">
@ -211,7 +204,7 @@ export function NoticeManagement() {
return (
<tr key={n.id} className="border-b border-border hover:bg-surface-overlay">
<td className="px-2 py-1.5">
<Badge className={`border-0 text-[9px] ${status.color}`}>{status.label}</Badge>
<Badge intent={status.intent} size="xs">{status.label}</Badge>
</td>
<td className="px-2 py-1.5">
<span className={`inline-flex items-center gap-1 text-[10px] ${typeOpt.color}`}>
@ -244,10 +237,10 @@ export function NoticeManagement() {
</td>
<td className="px-1 py-1.5">
<div className="flex items-center justify-center gap-0.5">
<button onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
<Trash2 className="w-3 h-3" />
</button>
</div>
@ -268,7 +261,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</span>
<button onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -278,6 +271,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="알림 제목"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
@ -289,6 +283,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label="알림 내용"
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -303,7 +298,7 @@ export function NoticeManagement() {
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{TYPE_OPTIONS.map((opt) => (
<button
<button type="button"
key={opt.key}
onClick={() => setForm({ ...form, type: opt.key })}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] transition-colors ${
@ -322,7 +317,7 @@ export function NoticeManagement() {
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"> </label>
<div className="flex gap-1">
{DISPLAY_OPTIONS.map((opt) => (
<button
<button type="button"
key={opt.key}
onClick={() => setForm({ ...form, display: opt.key })}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
@ -344,6 +339,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="시작일"
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -353,6 +349,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="종료일"
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
@ -368,7 +365,7 @@ export function NoticeManagement() {
</label>
<div className="flex gap-1.5">
{ROLE_OPTIONS.map((role) => (
<button
<button type="button"
key={role}
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
@ -408,7 +405,7 @@ export function NoticeManagement() {
{/* 하단 버튼 */}
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2">
<button
<button type="button"
onClick={() => setShowForm(false)}
className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors"
>
@ -423,6 +420,6 @@ export function NoticeManagement() {
</div>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -4,6 +4,7 @@ import {
} from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
@ -13,6 +14,9 @@ import {
type Operation, type TreeNode, type PermRow,
} from '@/lib/permission/permResolver';
import { useAuth } from '@/app/auth/AuthContext';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
/**
* (wing ).
@ -34,14 +38,6 @@ import { useAuth } from '@/app/auth/AuthContext';
* - admin:permission-management (UPDATE):
*/
const ROLE_COLORS: Record<string, string> = {
ADMIN: 'bg-red-500/20 text-red-400 border-red-500/30',
OPERATOR: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
ANALYST: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
FIELD: 'bg-green-500/20 text-green-400 border-green-500/30',
VIEWER: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
};
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
@ -65,6 +61,8 @@ export function PermissionsPanel() {
const [showCreate, setShowCreate] = useState(false);
const [newRoleCd, setNewRoleCd] = useState('');
const [newRoleNm, setNewRoleNm] = useState('');
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
const [editingColor, setEditingColor] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true); setError('');
@ -233,15 +231,26 @@ export function PermissionsPanel() {
const handleCreateRole = async () => {
if (!newRoleCd || !newRoleNm) return;
try {
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm });
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
setShowCreate(false);
setNewRoleCd(''); setNewRoleNm('');
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleUpdateColor = async (roleSn: number, hex: string) => {
try {
await apiUpdateRole(roleSn, { colorHex: hex });
await load();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
@ -364,18 +373,21 @@ export function PermissionsPanel() {
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1">
<input value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" 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 value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<div className="flex gap-1">
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded"></button>
<button type="button" onClick={() => setShowCreate(false)}
className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded"></button>
<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">
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
</Button>
</div>
</div>
)}
@ -383,26 +395,55 @@ export function PermissionsPanel() {
<div className="space-y-1">
{roles.map((r) => {
const selected = r.roleSn === selectedRoleSn;
const isEditingColor = editingColor === String(r.roleSn);
return (
<button
<div
key={r.roleSn}
type="button"
onClick={() => setSelectedRoleSn(r.roleSn)}
className={`w-full text-left px-2 py-1.5 rounded border transition-colors ${
className={`px-2 py-1.5 rounded border transition-colors ${
selected
? 'bg-blue-600/20 border-blue-500/40 text-heading'
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
}`}
>
<div className="flex items-center justify-between">
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border text-[9px]`}>
{r.roleCd}
</Badge>
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
<button
type="button"
onClick={() => setSelectedRoleSn(r.roleSn)}
className="flex items-center gap-1.5 cursor-pointer"
title="역할 선택"
>
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
{r.roleCd}
</Badge>
</button>
<div className="flex items-center gap-1">
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
{canUpdatePerm && (
<button
type="button"
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
className="text-[8px] text-hint hover:text-blue-400"
title="색상 변경"
>
</button>
)}
</div>
</div>
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
<div className="text-[9px] text-hint mt-0.5"> {r.permissions.length}</div>
</button>
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
<div className="text-[9px] text-hint mt-0.5"> {r.permissions.length}</div>
</button>
{isEditingColor && (
<div className="mt-2 p-2 bg-background rounded border border-border">
<ColorPicker
label="배지 색상"
value={r.colorHex}
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
/>
</div>
)}
</div>
);
})}
</div>
@ -426,11 +467,15 @@ export function PermissionsPanel() {
</div>
</div>
{canUpdatePerm && selectedRole && (
<button type="button" onClick={handleSave} disabled={!isDirty || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!isDirty || saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
>
{isDirty && <span className="text-yellow-300"></span>}
</button>
</Button>
)}
</div>

파일 보기

@ -2,6 +2,9 @@ import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import type { BadgeIntent } from '@lib/theme/variants';
import {
Settings, Database, Search, ChevronDown, ChevronRight,
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
@ -143,32 +146,24 @@ export function SystemConfig() {
: [];
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Database className="w-5 h-5 text-cyan-400" />
{t('systemConfig.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('systemConfig.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<Download className="w-3 h-3" />
</button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-cyan-400"
title={t('systemConfig.title')}
description={t('systemConfig.desc')}
demo
actions={
<>
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
</Button>
</>
}
/>
{/* KPI 카드 */}
<div className="grid grid-cols-5 gap-3">
@ -199,11 +194,11 @@ export function SystemConfig() {
{/* 탭 */}
<div className="flex gap-1">
{TAB_ITEMS.map((t) => (
<button
<button type="button"
key={t.key}
onClick={() => changeTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
>
<t.icon className="w-3.5 h-3.5" />
@ -223,6 +218,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
aria-label="코드 검색"
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={
@ -237,6 +233,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
aria-label="대분류 필터"
value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
@ -275,13 +272,10 @@ export function SystemConfig() {
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
<td className="px-4 py-2">
<Badge className={`border-0 text-[9px] ${
a.major === '서해' ? 'bg-blue-500/20 text-blue-400'
: a.major === '남해' ? 'bg-green-500/20 text-green-400'
: a.major === '동해' ? 'bg-purple-500/20 text-purple-400'
: a.major === '제주' ? 'bg-orange-500/20 text-orange-400'
: 'bg-cyan-500/20 text-cyan-400'
}`}>{a.major}</Badge>
{(() => {
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
return <Badge intent={intent} size="xs">{a.major}</Badge>;
})()}
</td>
<td className="px-4 py-2 text-label">{a.mid}</td>
<td className="px-4 py-2 text-heading font-medium">{a.name}</td>
@ -321,7 +315,7 @@ export function SystemConfig() {
>
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
<td className="px-4 py-2">
<Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{s.major}</Badge>
<Badge intent="muted" size="sm">{s.major}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
@ -365,25 +359,16 @@ export function SystemConfig() {
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
<td className="px-4 py-2">
<Badge className={`border-0 text-[9px] ${
f.major === '근해어업' ? 'bg-blue-500/20 text-blue-400'
: f.major === '연안어업' ? 'bg-green-500/20 text-green-400'
: f.major === '양식어업' ? 'bg-cyan-500/20 text-cyan-400'
: f.major === '원양어업' ? 'bg-purple-500/20 text-purple-400'
: f.major === '구획어업' ? 'bg-orange-500/20 text-orange-400'
: f.major === '마을어업' ? 'bg-yellow-500/20 text-yellow-400'
: 'bg-muted text-muted-foreground'
}`}>{f.major}</Badge>
{(() => {
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
return <Badge intent={intent} size="xs">{f.major}</Badge>;
})()}
</td>
<td className="px-4 py-2 text-label">{f.mid}</td>
<td className="px-4 py-2 text-heading font-medium">{f.name}</td>
<td className="px-4 py-2 text-muted-foreground">{f.target}</td>
<td className="px-4 py-2">
<Badge className={`border-0 text-[9px] ${
f.permit === '허가' ? 'bg-blue-500/20 text-blue-400'
: f.permit === '면허' ? 'bg-green-500/20 text-green-400'
: 'bg-muted text-muted-foreground'
}`}>{f.permit}</Badge>
<Badge intent={f.permit === '허가' ? 'info' : f.permit === '면허' ? 'success' : 'muted'} size="xs">{f.permit}</Badge>
</td>
<td className="px-4 py-2 text-hint text-[10px]">{f.law}</td>
</tr>
@ -416,15 +401,10 @@ export function SystemConfig() {
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${
v.major === '어선' ? 'bg-blue-500/20 text-blue-400'
: v.major === '여객선' ? 'bg-green-500/20 text-green-400'
: v.major === '화물선' ? 'bg-orange-500/20 text-orange-400'
: v.major === '유조선' ? 'bg-red-500/20 text-red-400'
: v.major === '관공선' ? 'bg-purple-500/20 text-purple-400'
: v.major === '함정' ? 'bg-cyan-500/20 text-cyan-400'
: 'bg-muted text-muted-foreground'
}`}>{v.major}</Badge>
{(() => {
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
return <Badge intent={intent} size="xs">{v.major}</Badge>;
})()}
</td>
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
@ -451,7 +431,7 @@ export function SystemConfig() {
{/* 페이지네이션 (코드 탭에서만) */}
{tab !== 'settings' && totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
<button type="button"
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@ -461,7 +441,7 @@ export function SystemConfig() {
<span className="text-[11px] text-hint">
{page + 1} / {totalPages} ({totalItems.toLocaleString()})
</span>
<button
<button type="button"
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@ -505,6 +485,6 @@ export function SystemConfig() {
})}
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,14 +2,7 @@ import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react';
import { Badge } from '@shared/components/ui/badge';
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
const ROLE_COLORS: Record<string, string> = {
ADMIN: 'bg-red-500/20 text-red-400',
OPERATOR: 'bg-blue-500/20 text-blue-400',
ANALYST: 'bg-purple-500/20 text-purple-400',
FIELD: 'bg-green-500/20 text-green-400',
VIEWER: 'bg-yellow-500/20 text-yellow-400',
};
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
interface Props {
user: AdminUser;
@ -67,7 +60,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR )
</div>
</div>
<button type="button" onClick={onClose} className="text-hint hover:text-heading">
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -91,7 +84,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
}`}>
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
</div>
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px]`}>
<Badge size="md" style={getRoleBadgeStyle(r.roleCd)}>
{r.roleCd}
</Badge>
<div className="text-left">

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
import { sendChatMessage } from '@/services/chatApi';
@ -75,11 +76,13 @@ export function AIAssistant() {
};
return (
<div className="p-5 h-full flex flex-col">
<div className="mb-4">
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><MessageSquare className="w-5 h-5 text-green-400" />{t('assistant.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('assistant.desc')}</p>
</div>
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
<div className="flex-1 flex gap-3 min-h-0">
{/* 대화 이력 사이드바 */}
<Card className="w-56 shrink-0 bg-surface-raised border-border">
@ -138,18 +141,19 @@ export function AIAssistant() {
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<input
aria-label="AI 어시스턴트 질의"
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"
/>
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-heading rounded-xl transition-colors">
<button type="button" aria-label="전송" 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>
</div>
</PageContainer>
);
}

파일 보기

@ -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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
Brain, Settings, Zap, Activity, TrendingUp, BarChart3,
@ -11,6 +12,10 @@ import {
FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink,
} from 'lucide-react';
import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts';
import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities';
import { getStatusIntent } from '@shared/constants/statusIntent';
import type { BadgeIntent } from '@lib/theme/variants';
import { useSettingsStore } from '@stores/settingsStore';
/*
* SFR-04: AI
@ -56,8 +61,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: (v) => {
const s = v as string;
const c = s === '운영중' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : s === '대기' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-muted text-hint';
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>;
},
},
{ key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => <span className="text-heading font-bold">{v as number}%</span> },
@ -176,8 +180,8 @@ const gearColumns: DataColumn<GearCode>[] = [
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
const r = v as string;
const c = r === '고위험' ? 'bg-red-500/20 text-red-400' : r === '중위험' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-green-500/20 text-green-400';
return <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>;
const intent: BadgeIntent = r === '고위험' ? 'critical' : r === '중위험' ? 'warning' : 'success';
return <Badge intent={intent} size="xs">{r}</Badge>;
},
},
{ key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => <span className="text-label font-mono">{v as string}</span> },
@ -237,6 +241,8 @@ const ALARM_SEVERITY = [
export function AIModelManagement() {
const { t } = useTranslation('ai');
const { t: tcCommon } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [tab, setTab] = useState<Tab>('registry');
const [rules, setRules] = useState(defaultRules);
@ -244,29 +250,21 @@ export function AIModelManagement() {
const currentModel = MODELS.find((m) => m.status === '운영중')!;
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Brain className="w-5 h-5 text-purple-400" />
{t('modelManagement.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('modelManagement.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<PageContainer>
<PageHeader
icon={Brain}
iconColor="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-hint">Accuracy {currentModel.accuracy}%</span>
</div>
</div>
</div>
}
/>
{/* KPI */}
<div className="flex gap-2">
@ -300,8 +298,8 @@ export function AIModelManagement() {
{ key: 'engines' as Tab, icon: Shield, label: '7대 탐지 엔진' },
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
].map((t) => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -319,7 +317,7 @@ export function AIModelManagement() {
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div>
</div>
<button className="bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
</div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
@ -333,14 +331,14 @@ export function AIModelManagement() {
{rules.map((rule, i) => (
<Card key={i} className="bg-surface-raised border-border">
<CardContent className="p-3 flex items-center gap-4">
<button onClick={() => toggleRule(i)}
<button type="button" role="switch" aria-checked={rule.enabled ? 'true' : 'false'} aria-label={rule.name} onClick={() => toggleRule(i)}
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 transition-all shadow-sm" style={{ left: rule.enabled ? '22px' : '2px' }} />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[8px]">{rule.model}</Badge>
<Badge intent="purple" size="xs">{rule.model}</Badge>
</div>
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
</div>
@ -628,8 +626,6 @@ export function AIModelManagement() {
{/* 7대 엔진 카드 */}
<div className="space-y-2">
{DETECTION_ENGINES.map((eng) => {
const sevColor = eng.severity.includes('CRITICAL') ? 'text-red-400 bg-red-500/15' : eng.severity.includes('HIGH') ? 'text-orange-400 bg-orange-500/15' : eng.severity === 'MEDIUM~CRITICAL' ? 'text-yellow-400 bg-yellow-500/15' : 'text-hint bg-muted';
const stColor = eng.status === '운영중' ? 'bg-green-500/20 text-green-400' : eng.status === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground';
return (
<Card key={eng.id} className="bg-surface-raised border-border">
<CardContent className="p-3 flex items-start gap-4">
@ -641,7 +637,7 @@ export function AIModelManagement() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[12px] font-bold text-heading">{eng.name}</span>
<Badge className={`border-0 text-[8px] ${stColor}`}>{eng.status}</Badge>
<Badge intent={getStatusIntent(eng.status)} size="xs">{eng.status}</Badge>
</div>
<div className="text-[10px] text-muted-foreground mb-1.5">{eng.purpose}</div>
<div className="text-[10px] text-hint leading-relaxed">{eng.detail}</div>
@ -654,7 +650,9 @@ export function AIModelManagement() {
</div>
<div>
<div className="text-[9px] text-hint"></div>
<Badge className={`border-0 text-[9px] ${sevColor}`}>{eng.severity}</Badge>
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
</Badge>
</div>
<div>
<div className="text-[9px] text-hint"></div>
@ -852,14 +850,14 @@ export function AIModelManagement() {
].map((api, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
<td className="py-1.5">
<Badge className={`border-0 text-[8px] font-bold ${api.method === 'GET' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}`}>{api.method}</Badge>
<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 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>
<td className="py-1.5 text-center">
<Badge className={`border-0 text-[8px] ${api.status === '운영' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{api.status}</Badge>
<Badge intent={getStatusIntent(api.status)} size="xs">{api.status}</Badge>
</td>
</tr>
))}
@ -882,7 +880,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid
@ -948,7 +946,7 @@ export function AIModelManagement() {
].map((s) => (
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-2 mb-1">
<Badge className="border-0 text-[9px] font-bold" style={{ backgroundColor: `${s.color}20`, color: s.color }}>{s.sfr}</Badge>
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
<span className="text-[11px] font-bold text-heading">{s.name}</span>
</div>
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
@ -989,6 +987,6 @@ export function AIModelManagement() {
</Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,9 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses';
import { getStatusIntent } from '@shared/constants/statusIntent';
import {
Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield,
FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square,
@ -109,23 +112,15 @@ export function MLOpsPage() {
const [selectedTmpl, setSelectedTmpl] = useState(0);
const [selectedLLM, setSelectedLLM] = useState(0);
const stColor = (s: string) => s === 'DEPLOYED' ? 'bg-green-500/20 text-green-400 border-green-500' : s === 'APPROVED' ? 'bg-blue-500/20 text-blue-400 border-blue-500' : s === 'TESTING' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500' : 'bg-muted text-muted-foreground border-slate-600';
const gateColor = (s: string) => s === 'pass' ? 'bg-green-500/20 text-green-400' : s === 'fail' ? 'bg-red-500/20 text-red-400' : s === 'run' ? 'bg-yellow-500/20 text-yellow-400 animate-pulse' : 'bg-switch-background/50 text-hint';
const expColor = (s: string) => s === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : s === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400';
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
</div>
</div>
<PageContainer>
<PageHeader
icon={Cpu}
iconColor="text-purple-400"
title={t('mlops.title')}
description={t('mlops.desc')}
demo
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
@ -138,7 +133,7 @@ export function MLOpsPage() {
{ key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' },
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -161,7 +156,7 @@ export function MLOpsPage() {
<div className="text-[12px] font-bold text-label mb-3"> </div>
<div className="space-y-2">{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEPLOYED</Badge>
<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>
@ -172,7 +167,7 @@ export function MLOpsPage() {
<div className="text-[12px] font-bold text-label mb-3"> </div>
<div className="space-y-2">{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
<div key={e.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px] animate-pulse"></Badge>
<Badge intent="info" size="sm" className="animate-pulse"></Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.name}</span>
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${e.progress}%` }} /></div>
<span className="text-[10px] text-muted-foreground">{e.progress}%</span>
@ -202,14 +197,14 @@ export function MLOpsPage() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-[12px] font-bold text-heading"> </div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
</div>
<div className="space-y-2">
{EXPERIMENTS.map(e => (
<div key={e.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-16">{e.id}</span>
<span className="text-[11px] text-heading font-medium w-40 truncate">{e.name}</span>
<Badge className={`border-0 text-[9px] w-14 text-center ${expColor(e.status)}`}>{e.status}</Badge>
<Badge intent={getExperimentIntent(e.status)} size="sm" className={`w-14 ${EXPERIMENT_STATUSES[e.status as keyof typeof EXPERIMENT_STATUSES]?.pulse ? 'animate-pulse' : ''}`}>{e.status}</Badge>
<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>
@ -228,7 +223,7 @@ export function MLOpsPage() {
<Card key={m.name + m.ver} className="bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div><div className="text-[13px] font-bold text-heading">{m.name}</div><div className="text-[9px] text-hint mt-0.5">{m.ver}</div></div>
<Badge className={`border text-[10px] font-bold ${stColor(m.status)}`}>{m.status}</Badge>
<Badge intent={getModelStatusIntent(m.status)} size="md" className="font-bold">{MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}</Badge>
</div>
{/* 성능 지표 */}
{m.accuracy > 0 && (
@ -245,7 +240,7 @@ export function MLOpsPage() {
<div className="text-[9px] text-hint mb-1.5 font-bold">Quality Gates</div>
<div className="flex gap-1">
{m.gates.map((g, i) => (
<Badge key={g} className={`border-0 text-[8px] ${gateColor(m.gateStatus[i])}`}>{g}</Badge>
<Badge key={g} intent={getQualityGateIntent(m.gateStatus[i])} size="xs" className={QUALITY_GATE_STATUSES[m.gateStatus[i] as keyof typeof QUALITY_GATE_STATUSES]?.pulse ? 'animate-pulse' : ''}>{g}</Badge>
))}
</div>
</CardContent></Card>
@ -272,7 +267,7 @@ export function MLOpsPage() {
<td className="px-3 py-2 text-label">{d.latency}</td>
<td className="px-3 py-2 text-label">{d.falseAlarm}</td>
<td className="px-3 py-2 text-heading font-bold">{d.rps}</td>
<td className="px-3 py-2"><Badge className={`border-0 text-[9px] ${d.status === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{d.status}</Badge></td>
<td className="px-3 py-2"><Badge intent={getStatusIntent(d.status)} size="xs">{d.status}</Badge></td>
<td className="px-3 py-2 text-hint">{d.date}</td>
</tr>
))}</tbody>
@ -283,8 +278,8 @@ export function MLOpsPage() {
<div className="text-[12px] font-bold text-heading mb-2"> / A·B </div>
<div className="text-[10px] text-muted-foreground mb-3"> v2.1.0 (80%) v2.0.3 (20%)</div>
<div className="h-5 bg-background rounded-lg overflow-hidden flex">
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
@ -293,7 +288,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-heading text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
</div>
))}
</div>
@ -307,7 +302,7 @@ export function MLOpsPage() {
<div className="grid grid-cols-2 gap-3" style={{ height: 'calc(100vh - 240px)' }}>
<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">REQUEST BODY (JSON)</div>
<textarea className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
<textarea aria-label="API 요청 본문 JSON" className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
"mmsi": "412345678",
"lat": 37.12,
"lon": 124.63,
@ -318,8 +313,8 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
<div className="flex gap-2 mt-2">
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<button className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
</div>
</CardContent></Card>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
@ -357,7 +352,7 @@ export function MLOpsPage() {
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
<button key={t.key} onClick={() => setLlmSub(t.key)}
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
))}
</div>
@ -386,7 +381,7 @@ export function MLOpsPage() {
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
))}
</div>
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
@ -396,7 +391,7 @@ export function MLOpsPage() {
<div key={j.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-16">{j.id}</span>
<span className="text-[11px] text-heading w-24">{j.model}</span>
<Badge className={`border-0 text-[9px] w-14 text-center ${j.status === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : j.status === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{j.status}</Badge>
<Badge intent={j.status === 'running' ? 'info' : j.status === 'done' ? 'success' : 'critical'} size="xs" className="w-14 text-center">{j.status}</Badge>
<div className="flex-1 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${j.status === 'done' ? 'bg-green-500' : j.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${j.progress}%` }} /></div>
<span className="text-[10px] text-muted-foreground w-16">{j.elapsed}</span>
</div>
@ -422,7 +417,7 @@ export function MLOpsPage() {
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
))}
</div>
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
</CardContent></Card>
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
@ -436,7 +431,7 @@ export function MLOpsPage() {
<td className="py-2 px-2 text-muted-foreground">{t.dropout}</td>
<td className="py-2 px-2 text-muted-foreground">{t.hidden}</td>
<td className="py-2 px-2 text-heading font-bold">{t.f1.toFixed(3)}</td>
<td className="py-2 px-2">{t.best && <Badge className="bg-green-500/20 text-green-400 border-0 text-[8px]">BEST</Badge>}</td>
<td className="py-2 px-2">{t.best && <Badge intent="success" size="xs">BEST</Badge>}</td>
</tr>
))}</tbody>
</table>
@ -503,14 +498,14 @@ export function MLOpsPage() {
<p className="text-[10px] text-muted-foreground">2. ** **: EEZ/NLL 5NM </p>
<p className="text-[10px] text-muted-foreground">3. ** **: MMSI , </p>
<div className="mt-2 pt-2 border-t border-border flex gap-1">
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]"> §5</Badge>
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]"> §6</Badge>
<Badge intent="success" size="xs"> §5</Badge>
<Badge intent="success" size="xs"> §6</Badge>
</div>
</div></div>
</div>
<div className="flex gap-2 shrink-0">
<input className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading rounded-xl"><Send className="w-4 h-4" /></button>
<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="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
</div>
</CardContent></Card>
</div>
@ -565,6 +560,6 @@ export function MLOpsPage() {
</CardContent></Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -142,6 +142,7 @@ export function LoginPage() {
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
aria-label={t('form.userId')}
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
@ -157,6 +158,7 @@ export function LoginPage() {
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
<input
aria-label={t('form.password')}
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
@ -195,7 +197,7 @@ export function LoginPage() {
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
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"
>
{loading ? (
<>

파일 보기

@ -11,6 +11,7 @@ import {
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
@ -22,45 +23,13 @@ import {
type PredictionStatsDaily,
type PredictionStatsHourly,
} from '@/services/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
import { toDateParam, formatDate, formatTime } from '@shared/utils/dateFormat';
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getPatrolStatusIntent, getPatrolStatusLabel } from '@shared/constants/patrolStatuses';
import { getKpiUi } from '@shared/constants/kpiUiMap';
import { useSettingsStore } from '@stores/settingsStore';
// ─── 작전 경보 등급 ─────────────────────
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
const ALERT_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400', border: 'border-red-500/30', dot: 'bg-red-500' },
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/30', dot: 'bg-orange-500' },
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', border: 'border-yellow-500/30', dot: 'bg-yellow-500' },
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' },
};
// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ─────────
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
'다크베셀': { icon: Eye, color: '#f97316' },
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
'추적 중': { icon: Crosshair, color: '#06b6d4' },
'나포/검문': { icon: Shield, color: '#10b981' },
// kpiKey 기반 매핑 (백엔드 API 응답)
realtime_detection: { icon: Radar, color: '#3b82f6' },
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
dark_vessel: { icon: Eye, color: '#f97316' },
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
tracking: { icon: Crosshair, color: '#06b6d4' },
enforcement: { icon: Shield, color: '#10b981' },
};
// 위반 유형/어구 → 차트 색상 매핑
const VESSEL_TYPE_COLORS: Record<string, string> = {
'EEZ 침범': '#ef4444',
'다크베셀': '#f97316',
'불법환적': '#a855f7',
'MMSI변조': '#eab308',
'고속도주': '#06b6d4',
'어구 불법': '#6b7280',
};
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
// TODO: /api/weather 연동 예정
const WEATHER_DATA = {
@ -83,9 +52,10 @@ function PulsingDot({ color }: { color: string }) {
}
function RiskBar({ value, size = 'default' }: { value: number; size?: 'default' | 'sm' }) {
const pct = value * 100;
const color = pct > 90 ? 'bg-red-500' : pct > 80 ? 'bg-orange-500' : pct > 70 ? 'bg-yellow-500' : 'bg-blue-500';
const textColor = pct > 90 ? 'text-red-400' : pct > 80 ? 'text-orange-400' : pct > 70 ? 'text-yellow-400' : 'text-blue-400';
// 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 barW = size === 'sm' ? 'w-16' : 'w-24';
return (
<div className="flex items-center gap-2">
@ -122,22 +92,27 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
interface TimelineEvent { time: string; level: AlertLevel; title: string; detail: string; vessel: string; area: string }
function TimelineItem({ event }: { event: TimelineEvent }) {
const c = ALERT_COLORS[event.level];
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const c = ALERT_LEVELS[event.level].classes;
return (
<div className={`flex gap-3 p-2.5 rounded-lg ${c.bg} border ${c.border} hover:brightness-110 transition-all cursor-pointer group`}>
<div className="flex flex-col items-center gap-1 pt-0.5 shrink-0">
<div className="flex flex-col items-center gap-0.5 pt-0.5 shrink-0 min-w-[68px]">
<PulsingDot color={c.dot} />
<span className="text-[9px] text-hint tabular-nums">{event.time}</span>
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatDate(event.time)}</span>
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatTime(event.time)}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-xs font-bold ${c.text}`}>{event.title}</span>
<Badge className={`${c.bg} ${c.text} text-[8px] px-1 py-0 border-0`}>{event.level}</Badge>
<Badge intent={getAlertLevelIntent(event.level)} size="xs">
{getAlertLevelLabel(event.level, tc, lang)}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed truncate">{event.detail}</p>
<p className="text-[0.75rem] text-label leading-relaxed truncate">{event.detail}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-hint flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
<span className="text-[9px] text-hint flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
</div>
</div>
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
@ -146,14 +121,13 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
}
function PatrolStatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
'추적 중': 'bg-red-500/20 text-red-400 border-red-500/30',
'검문 중': 'bg-orange-500/20 text-orange-400 border-orange-500/30',
'초계 중': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
'귀항 중': 'bg-muted text-muted-foreground border-slate-500/30',
'대기': 'bg-green-500/20 text-green-400 border-green-500/30',
};
return <Badge className={`${styles[status] || 'bg-muted text-muted-foreground'} text-[9px] border px-1.5 py-0`}>{status}</Badge>;
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
return (
<Badge intent={getPatrolStatusIntent(status)} size="xs">
{getPatrolStatusLabel(status, tc, lang)}
</Badge>
);
}
function FuelGauge({ percent }: { percent: number }) {
@ -276,6 +250,8 @@ const MemoSeaAreaMap = memo(SeaAreaMap);
export function Dashboard() {
const { t } = useTranslation('dashboard');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [defconLevel] = useState(2);
const kpiStore = useKpiStore();
@ -309,7 +285,7 @@ export function Dashboard() {
}, []);
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
const ui = getKpiUi(m.id) ?? getKpiUi(m.label);
return {
label: m.label,
value: m.value,
@ -321,7 +297,7 @@ export function Dashboard() {
}), [kpiStore.metrics]);
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
time: e.time,
level: e.level,
title: e.title,
detail: e.detail,
@ -370,12 +346,13 @@ export function Dashboard() {
};
}), [hourlyStats]);
// 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory
// 위반 유형 분포: daily byCategory(위반 enum) 우선, 없으면 byGearType
// 라벨/색상은 공통 카탈로그(violationTypes)에서 일괄 lookup
const VESSEL_TYPE_DATA = useMemo(() => {
if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[];
const totals: Record<string, number> = {};
dailyStats.forEach((d) => {
const src = d.byGearType ?? d.byCategory ?? null;
const src = d.byCategory ?? d.byGearType ?? null;
if (src) {
Object.entries(src).forEach(([k, v]) => {
totals[k] = (totals[k] ?? 0) + (Number(v) || 0);
@ -384,12 +361,12 @@ export function Dashboard() {
});
return Object.entries(totals)
.sort((a, b) => b[1] - a[1])
.map(([name, value], i) => ({
name,
.map(([code, value]) => ({
name: getViolationLabel(code, tc, lang),
value,
color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length],
color: getViolationColor(code),
}));
}, [dailyStats]);
}, [dailyStats, tc, lang]);
// 해역별 위험도: daily byZone → 표 데이터
const AREA_RISK_DATA = useMemo(() => {
@ -417,8 +394,8 @@ export function Dashboard() {
const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5'];
return (
<div className="space-y-4">
{/* ── 상단 헤더 바 ── */}
<PageContainer>
{/* ── 상단 헤더 바 (DEFCON + 라이브 상태) — 커스텀 구조 유지 ── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
@ -511,10 +488,10 @@ export function Dashboard() {
</CardTitle>
<div className="flex items-center gap-1">
<Badge className="bg-red-500/15 text-red-400 text-[8px] border-0 px-1.5 py-0">
<Badge intent="critical" size="xs" className="px-1.5 py-0">
{TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length}
</Badge>
<Badge className="bg-orange-500/15 text-orange-400 text-[8px] border-0 px-1.5 py-0">
<Badge intent="high" size="xs" className="px-1.5 py-0">
{TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
</Badge>
</div>
@ -538,7 +515,7 @@ export function Dashboard() {
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Navigation className="w-3.5 h-3.5 text-cyan-500" />
<Badge className="bg-cyan-500/15 text-cyan-400 text-[8px] border-0 ml-auto px-1.5 py-0">
<Badge intent="cyan" size="xs" className="ml-auto px-1.5 py-0">
{PATROL_SHIPS.length}
</Badge>
</CardTitle>
@ -654,12 +631,12 @@ export function Dashboard() {
<Crosshair className="w-3.5 h-3.5 text-red-500" />
(AI )
</CardTitle>
<Badge className="bg-red-500/15 text-red-400 text-[9px] border-0">{TOP_RISK_VESSELS.length} </Badge>
<Badge intent="critical" size="sm">{TOP_RISK_VESSELS.length} </Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{/* 테이블 헤더 */}
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
<div className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
<span>#</span>
<span>MMSI</span>
<span></span>
@ -672,20 +649,20 @@ export function Dashboard() {
{TOP_RISK_VESSELS.map((vessel, index) => (
<div
key={vessel.id}
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
>
<span className="text-hint text-xs font-bold">#{index + 1}</span>
<div className="flex items-center gap-1.5">
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
<PulsingDot color={vessel.risk > 70 ? 'bg-red-500' : vessel.risk > 50 ? 'bg-orange-500' : 'bg-yellow-500'} />
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
</div>
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
<div className="flex items-center gap-1">
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0"></Badge>}
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0"></Badge>}
{vessel.isDark && <Badge intent="high" size="xs" className="px-1 py-0"></Badge>}
{vessel.isSpoofing && <Badge intent="warning" size="xs" className="px-1 py-0">GPS변조</Badge>}
{vessel.isTransship && <Badge intent="purple" size="xs" className="px-1 py-0"></Badge>}
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
</div>
<RiskBar value={vessel.risk} />
@ -694,6 +671,6 @@ export function Dashboard() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -1,12 +1,17 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import {
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
import { GearIdentification } from './GearIdentification';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { PieChart as EcPieChart } from '@lib/charts';
@ -130,12 +135,8 @@ function CircleGauge({ value, label }: { value: number; label: string }) {
}
function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) {
const colors: Record<VesselStatus, { ring: string; bg: string; text: string }> = {
'의심': { ring: '#f97316', bg: 'bg-orange-500/10', text: 'text-orange-400' },
'양호': { ring: '#10b981', bg: 'bg-green-500/10', text: 'text-green-400' },
'경고': { ring: '#ef4444', bg: 'bg-red-500/10', text: 'text-red-400' },
};
const c = colors[status];
const meta = getVesselRingMeta(status);
const c = { ring: meta.hex, text: `text-${meta.intent === 'critical' ? 'red' : meta.intent === 'high' ? 'orange' : 'green'}-400` };
const circumference = 2 * Math.PI * 18;
const offset = circumference - (riskPct / 100) * circumference;
@ -196,6 +197,8 @@ function TransferView() {
// ─── 메인 페이지 ──────────────────────
export function ChinaFishing() {
const { t: tcCommon } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
@ -285,16 +288,16 @@ export function ChinaFishing() {
];
return (
<div className="space-y-3">
<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">
{modeTabs.map((tab) => (
<button
<button type="button"
key={tab.key}
onClick={() => setMode(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
mode === tab.key
? 'bg-blue-600 text-heading'
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
>
@ -342,7 +345,7 @@ export function ChinaFishing() {
</button>
<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
<input aria-label="해역 또는 해구 번호 검색"
placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/>
@ -425,7 +428,7 @@ export function ChinaFishing() {
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-heading"> </span>
<select className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<option> A</option>
<option> B</option>
</select>
@ -467,7 +470,7 @@ export function ChinaFishing() {
{/* 탭 헤더 */}
<div className="flex border-b border-slate-700/30">
{vesselTabs.map((tab) => (
<button
<button type="button"
key={tab}
onClick={() => setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@ -501,7 +504,7 @@ export function ChinaFishing() {
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{v.name}</span>
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
</div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>{v.country}</span>
@ -524,7 +527,7 @@ export function ChinaFishing() {
{/* 탭 */}
<div className="flex border-b border-slate-700/30">
{statsTabs.map((tab) => (
<button
<button type="button"
key={tab}
onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
@ -567,10 +570,14 @@ export function ChinaFishing() {
</div>
</div>
<div className="space-y-0.5 text-[8px]">
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => (
<div key={lv} className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[lv].classes.dot}`} />
<span className="text-hint">
{getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']}
</span>
</div>
))}
</div>
</div>
</div>
@ -592,7 +599,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
@ -616,7 +623,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<div className="text-center">
@ -639,7 +646,7 @@ export function ChinaFishing() {
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<button className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
@ -657,10 +664,10 @@ export function ChinaFishing() {
))}
</div>
<div className="flex justify-between mt-2">
<button className="text-hint hover:text-heading transition-colors">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button className="text-hint hover:text-heading transition-colors">
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
@ -671,6 +678,6 @@ export function ChinaFishing() {
</div>
</>}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,7 @@ 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
@ -12,6 +13,9 @@ import {
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
@ -62,30 +66,27 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
};
}
const PATTERN_COLORS: Record<string, string> = {
'AIS 완전차단': '#ef4444',
'MMSI 변조 의심': '#f97316',
'장기소실': '#eab308',
'신호 간헐송출': '#a855f7',
};
const cols: DataColumn<Suspect>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '추적중' ? 'bg-red-500/20 text-red-400' : s === '감시중' ? 'bg-yellow-500/20 text-yellow-400' : s === '확인중' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
{ key: 'label', label: '라벨', width: '60px', align: 'center',
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge className={`border-0 text-[8px] ${l === '불법' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'}`}>{l}</Badge>; } },
];
export function DarkVesselDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const cols: DataColumn<Suspect>[] = useMemo(() => [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'flag', label: '국적', width: '50px' },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
{ key: 'label', label: '라벨', width: '60px', align: 'center',
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> </button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
], [tc, lang]);
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
@ -128,7 +129,7 @@ export function DarkVesselDetection() {
lat: d.lat,
lng: d.lng,
radius: 10000,
color: PATTERN_COLORS[d.pattern] || '#ef4444',
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
})),
0.08,
),
@ -137,7 +138,7 @@ export function DarkVesselDetection() {
DATA.map(d => ({
lat: d.lat,
lng: d.lng,
color: PATTERN_COLORS[d.pattern] || '#ef4444',
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
radius: d.risk > 80 ? 1200 : 800,
label: `${d.id} ${d.name}`,
} as MarkerData)),
@ -147,13 +148,13 @@ export function DarkVesselDetection() {
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><EyeOff className="w-5 h-5 text-red-400" />{t('darkVessel.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
</div>
</div>
<PageContainer>
<PageHeader
icon={EyeOff}
iconColor="text-red-400"
title={t('darkVessel.title')}
description={t('darkVessel.desc')}
/>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
@ -193,12 +194,16 @@ export function DarkVesselDetection() {
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="space-y-1">
{Object.entries(PATTERN_COLORS).map(([p, c]) => (
<div key={p} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c }} />
<span className="text-[8px] text-muted-foreground">{p}</span>
</div>
))}
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
const meta = getDarkVesselPatternMeta(p);
if (!meta) return null;
return (
<div key={p} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
@ -212,6 +217,6 @@ export function DarkVesselDetection() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -2,20 +2,25 @@ 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat';
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; };
const RISK_COLORS: Record<string, string> = {
'고위험': '#ef4444',
'중위험': '#eab308',
// 한글 위험도 → AlertLevel hex 매핑
const RISK_HEX: Record<string, string> = {
'고위험': getAlertLevelHex('CRITICAL'),
'중위험': getAlertLevelHex('MEDIUM'),
'안전': '#22c55e',
};
@ -50,22 +55,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
};
}
const cols: DataColumn<Gear>[] = [
{ 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">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
render: v => { const p = v as string; const c = p === '유효' ? 'bg-green-500/20 text-green-400' : p === '무허가' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s.includes('불법') ? 'bg-red-500/20 text-red-400' : s === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
{ key: 'risk', label: '위험도', width: '70px', 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>; } },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
];
export function GearDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const cols: DataColumn<Gear>[] = useMemo(() => [
{ 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">{v as string}</span> },
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'risk', label: '위험도', width: '70px', 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>; } },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
], [tc, lang]);
const [groups, setGroups] = useState<GearGroupItem[]>([]);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [loading, setLoading] = useState(false);
@ -105,7 +113,7 @@ export function GearDetection() {
lat: g.lat,
lng: g.lng,
radius: 6000,
color: RISK_COLORS[g.risk] || '#64748b',
color: RISK_HEX[g.risk] || "#64748b",
})),
0.1,
),
@ -114,7 +122,7 @@ export function GearDetection() {
DATA.map(g => ({
lat: g.lat,
lng: g.lng,
color: RISK_COLORS[g.risk] || '#64748b',
color: RISK_HEX[g.risk] || "#64748b",
radius: g.risk === '고위험' ? 1200 : 800,
label: `${g.id} ${g.type}`,
} as MarkerData)),
@ -124,11 +132,13 @@ export function GearDetection() {
useMapLayers(mapRef, buildLayers, [DATA]);
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Anchor className="w-5 h-5 text-orange-400" />{t('gearDetection.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('gearDetection.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Anchor}
iconColor="text-orange-400"
title={t('gearDetection.title')}
description={t('gearDetection.desc')}
/>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
@ -186,6 +196,6 @@ export function GearDetection() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -436,15 +436,17 @@ function FormField({ label, children, hint }: { label: string; children: React.R
);
}
function InputField({ value, onChange, placeholder, type = 'text', className = '' }: {
function InputField({ value, onChange, placeholder, type = 'text', className = '', label }: {
value: string | number | null;
onChange: (v: string) => void;
placeholder: string;
type?: string;
className?: string;
label?: string;
}) {
return (
<input
aria-label={label ?? placeholder}
type={type}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
@ -470,13 +472,15 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v:
);
}
function SelectField({ value, onChange, options }: {
function SelectField({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading focus:border-blue-500/50 focus:outline-none"
@ -489,30 +493,30 @@ function SelectField({ value, onChange, options }: {
}
function ResultBadge({ origin, confidence }: { origin: Origin; confidence: Confidence }) {
const colors: Record<Origin, string> = {
china: 'bg-red-500/20 text-red-400 border-red-500/40',
korea: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
uncertain: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40',
const intents: Record<Origin, import('@lib/theme/variants').BadgeIntent> = {
china: 'critical',
korea: 'info',
uncertain: 'warning',
};
const labels: Record<Origin, string> = { china: '중국어선 어구', korea: '한국어선 어구', uncertain: '판별 불가' };
const confLabels: Record<Confidence, string> = { high: '높음', medium: '보통', low: '낮음' };
return (
<div className="flex items-center gap-2">
<Badge className={`${colors[origin]} border text-sm px-3 py-1`}>{labels[origin]}</Badge>
<Badge intent={intents[origin]} size="md">{labels[origin]}</Badge>
<span className="text-[10px] text-hint">: {confLabels[confidence]}</span>
</div>
);
}
function AlertBadge({ level }: { level: string }) {
const styles: Record<string, string> = {
CRITICAL: 'bg-red-600/20 text-red-400 border-red-600/40',
HIGH: 'bg-orange-500/20 text-orange-400 border-orange-500/40',
MEDIUM: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40',
LOW: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
const intents: Record<string, import('@lib/theme/variants').BadgeIntent> = {
CRITICAL: 'critical',
HIGH: 'high',
MEDIUM: 'warning',
LOW: 'info',
};
return <Badge className={`${styles[level]} border text-[10px] px-2 py-0.5`}>{level}</Badge>;
return <Badge intent={intents[level] ?? 'muted'} size="xs">{level}</Badge>;
}
// ─── 어구 비교 레퍼런스 테이블 ──────────
@ -648,7 +652,7 @@ export function GearIdentification() {
</p>
</div>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => setShowReference(!showReference)}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
>
@ -692,7 +696,7 @@ export function GearIdentification() {
<CardContent className="p-4 space-y-3">
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
<FormField label="어구 유형 (추정)">
<SelectField value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
<SelectField label="어구 유형 (추정)" value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
{ value: 'unknown', label: '미확인 / 모름' },
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
@ -746,7 +750,7 @@ export function GearIdentification() {
</FormField>
</div>
<FormField label="궤적 패턴">
<SelectField value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
<SelectField label="궤적 패턴" value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
{ value: 'unknown', label: '미확인' },
{ value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' },
{ value: 'stationary', label: '정지/극저속 (Stationary) → 자망' },
@ -779,14 +783,14 @@ export function GearIdentification() {
{/* 판별 버튼 */}
<div className="flex gap-2">
<button
<button type="button"
onClick={runIdentification}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
</button>
<button
<button type="button"
onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
>

파일 보기

@ -3,6 +3,10 @@ import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* iran / .
@ -10,19 +14,9 @@ import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
* - DB의 ParentResolution이
*/
const TYPE_COLORS: Record<string, string> = {
FLEET: 'bg-blue-500/20 text-blue-400',
GEAR_IN_ZONE: 'bg-orange-500/20 text-orange-400',
GEAR_OUT_ZONE: 'bg-purple-500/20 text-purple-400',
};
const STATUS_COLORS: Record<string, string> = {
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
};
export function RealGearGroups() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [items, setItems] = useState<GearGroupItem[]>([]);
const [available, setAvailable] = useState(true);
const [loading, setLoading] = useState(false);
@ -61,14 +55,14 @@ export function RealGearGroups() {
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<MapPin className="w-4 h-4 text-orange-400" /> / (iran )
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]"></Badge>}
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis/groups · DB의 (resolution)
</div>
</div>
<div className="flex items-center gap-2">
<select value={filterType} onChange={(e) => setFilterType(e.target.value)}
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""></option>
<option value="FLEET">FLEET</option>
@ -115,7 +109,7 @@ export function RealGearGroups() {
{filtered.slice(0, 100).map((g) => (
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5">
<Badge className={`${TYPE_COLORS[g.groupType]} border-0 text-[9px]`}>{g.groupType}</Badge>
<Badge intent={getGearGroupTypeIntent(g.groupType)} size="sm">{getGearGroupTypeLabel(g.groupType, tc, lang)}</Badge>
</td>
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
@ -126,8 +120,8 @@ export function RealGearGroups() {
</td>
<td className="px-2 py-1.5">
{g.resolution ? (
<Badge className={`${STATUS_COLORS[g.resolution.status] || ''} border-0 text-[9px]`}>
{g.resolution.status}
<Badge intent={getParentResolutionIntent(g.resolution.status)} size="sm">
{getParentResolutionLabel(g.resolution.status, tc, lang)}
</Badge>
) : <span className="text-hint text-[10px]">-</span>}
</td>

파일 보기

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import {
fetchVesselAnalysis,
type VesselAnalysisItem,
@ -20,12 +21,7 @@ interface Props {
icon?: React.ReactNode;
}
const RISK_COLORS: Record<string, string> = {
CRITICAL: 'bg-red-500/20 text-red-400',
HIGH: 'bg-orange-500/20 text-orange-400',
MEDIUM: 'bg-yellow-500/20 text-yellow-400',
LOW: 'bg-blue-500/20 text-blue-400',
};
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
const ZONE_LABELS: Record<string, string> = {
TERRITORIAL_SEA: '영해',
@ -82,14 +78,14 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
{icon} {title}
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]"></Badge>}
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/vessel-analysis · iran
</div>
</div>
<div className="flex items-center gap-2">
<select value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>
@ -147,7 +143,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
</td>
<td className="px-2 py-1.5 text-center">
<Badge className={`${RISK_COLORS[v.algorithms.riskScore.level] || ''} border-0 text-[9px]`}>
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
{v.algorithms.riskScore.level}
</Badge>
</td>
@ -159,7 +155,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
<td className="px-2 py-1.5 text-center">
{v.algorithms.darkVessel.isDark ? (
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v.algorithms.darkVessel.gapDurationMin}</Badge>
<Badge intent="purple" size="sm">{v.algorithms.darkVessel.gapDurationMin}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-right">
@ -169,7 +165,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
</td>
<td className="px-2 py-1.5 text-center">
{v.algorithms.transship.isSuspect ? (
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{v.algorithms.transship.durationMin}</Badge>
<Badge intent="critical" size="sm">{v.algorithms.transship.durationMin}</Badge>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">

파일 보기

@ -1,9 +1,15 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useEnforcementStore } from '@stores/enforcementStore';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { getEnforcementActionLabel } from '@shared/constants/enforcementActions';
import { getEnforcementResultLabel, getEnforcementResultIntent } from '@shared/constants/enforcementResults';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */
@ -19,86 +25,96 @@ interface Record {
[key: string]: unknown;
}
const cols: DataColumn<Record>[] = [
{
key: 'id',
label: 'ID',
width: '80px',
render: (v) => (
<span className="text-hint font-mono text-[10px]">{v as string}</span>
),
},
{
key: 'date',
label: '일시',
width: '130px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground font-mono text-[10px]">
{v as string}
</span>
),
},
{ key: 'zone', label: '해역', width: '90px', sortable: true },
{
key: 'vessel',
label: '대상 선박',
sortable: true,
render: (v) => (
<span className="text-cyan-400 font-medium">{v as string}</span>
),
},
{
key: 'violation',
label: '위반 내용',
width: '100px',
sortable: true,
render: (v) => (
<Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">
{v as string}
</Badge>
),
},
{ key: 'action', label: '조치', width: '90px' },
{
key: 'aiMatch',
label: 'AI 매칭',
width: '70px',
align: 'center',
render: (v) => {
const m = v as string;
return m === '일치' ? (
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
) : (
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
);
},
},
{
key: 'result',
label: '결과',
width: '80px',
align: 'center',
sortable: true,
render: (v) => {
const r = v as string;
const c =
r.includes('처벌') || r.includes('수사')
? 'bg-red-500/20 text-red-400'
: r.includes('오탐')
? 'bg-muted text-muted-foreground'
: 'bg-yellow-500/20 text-yellow-400';
return (
<Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>
);
},
},
];
export function EnforcementHistory() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { records, loading, error, load } = useEnforcementStore();
const cols: DataColumn<Record>[] = useMemo(() => [
{
key: 'id',
label: 'ID',
width: '80px',
render: (v) => (
<span className="text-hint font-mono text-[10px]">{v as string}</span>
),
},
{
key: 'date',
label: '일시',
sortable: true,
render: (v) => (
<span className="text-muted-foreground font-mono text-[10px]">
{formatDateTime(v as string)}
</span>
),
},
{ key: 'zone', label: '해역', width: '90px', sortable: true },
{
key: 'vessel',
label: '대상 선박',
sortable: true,
render: (v) => (
<span className="text-cyan-400 font-medium">{v as string}</span>
),
},
{
key: 'violation',
label: '위반 내용',
minWidth: '90px',
maxWidth: '160px',
sortable: true,
render: (v) => {
const code = v as string;
return (
<Badge intent={getViolationIntent(code)} size="sm">
{getViolationLabel(code, tc, lang)}
</Badge>
);
},
},
{
key: 'action',
label: '조치',
minWidth: '70px',
maxWidth: '110px',
render: (v) => (
<span className="text-label">{getEnforcementActionLabel(v as string, tc, lang)}</span>
),
},
{
key: 'aiMatch',
label: 'AI 매칭',
width: '70px',
align: 'center',
render: (v) => {
const m = v as string;
return m === '일치' || m === 'MATCH' ? (
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
) : (
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
);
},
},
{
key: 'result',
label: '결과',
minWidth: '80px',
maxWidth: '120px',
align: 'center',
sortable: true,
render: (v) => {
const code = v as string;
return (
<Badge intent={getEnforcementResultIntent(code)} size="xs">
{getEnforcementResultLabel(code, tc, lang)}
</Badge>
);
},
},
], [tc, lang]);
useEffect(() => {
load();
}, [load]);
@ -106,32 +122,31 @@ export function EnforcementHistory() {
const DATA: Record[] = records as Record[];
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-400" />
{t('history.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={FileText}
iconColor="text-blue-400"
title={t('history.title')}
description={t('history.desc')}
/>
{/* KPI 카드 */}
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
<div className="flex gap-2">
{[
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
{
l: '처벌',
v: DATA.filter((d) => d.result.includes('처벌')).length,
l: '처벌·수사',
v: DATA.filter((d) => d.result === 'PUNISHED' || d.result === 'REFERRED').length,
c: 'text-red-400',
},
{
l: 'AI 일치',
v: DATA.filter((d) => d.aiMatch === '일치').length,
v: DATA.filter((d) => d.aiMatch === '일치' || d.aiMatch === 'MATCH').length,
c: 'text-green-400',
},
{
l: '오탐',
v: DATA.filter((d) => d.result.includes('오탐')).length,
v: DATA.filter((d) => d.result === 'FALSE_POSITIVE').length,
c: 'text-yellow-400',
},
].map((k) => (
@ -173,6 +188,6 @@ export function EnforcementHistory() {
exportFilename="단속이력"
/>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { FileUpload } from '@shared/components/common/FileUpload';
import {
@ -8,6 +11,11 @@ import {
Filter, Upload, X, Loader2,
} from 'lucide-react';
import { useEventStore } from '@stores/eventStore';
import { formatDateTime } from '@shared/utils/dateFormat';
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
import { useSettingsStore } from '@stores/settingsStore';
/*
* SFR-02
@ -15,7 +23,7 @@ import { useEventStore } from '@stores/eventStore';
* API
*/
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
type AlertLevel = AlertLevelType;
interface EventRow {
id: string;
@ -33,64 +41,10 @@ interface EventRow {
[key: string]: unknown;
}
const LEVEL_STYLES: Record<AlertLevel, { bg: string; text: string }> = {
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400' },
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400' },
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400' },
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
};
const STATUS_COLORS: Record<string, string> = {
NEW: 'bg-red-500/20 text-red-400',
ACK: 'bg-orange-500/20 text-orange-400',
IN_PROGRESS: 'bg-blue-500/20 text-blue-400',
RESOLVED: 'bg-green-500/20 text-green-400',
FALSE_POSITIVE: 'bg-muted text-muted-foreground',
};
function statusColor(s: string): string {
if (STATUS_COLORS[s]) return STATUS_COLORS[s];
if (s === '완료' || s === '확인 완료' || s === '경고 완료') return 'bg-green-500/20 text-green-400';
if (s.includes('추적') || s.includes('나포')) return 'bg-red-500/20 text-red-400';
if (s.includes('감시') || s.includes('확인')) return 'bg-yellow-500/20 text-yellow-400';
return 'bg-blue-500/20 text-blue-400';
}
const columns: DataColumn<EventRow>[] = [
{
key: 'level', label: '등급', width: '70px', sortable: true,
render: (val) => {
const lv = val as AlertLevel;
const s = LEVEL_STYLES[lv];
return <Badge className={`border-0 text-[9px] ${s?.bg ?? ''} ${s?.text ?? ''}`}>{lv}</Badge>;
},
},
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{val as string}</span>,
},
{ key: 'type', label: '유형', width: '90px', sortable: true,
render: (val) => <span className="text-heading font-medium">{val as string}</span>,
},
{ key: 'vesselName', label: '선박명', sortable: true,
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
},
{ key: 'area', label: '해역', width: '90px', sortable: true },
{ key: 'speed', label: '속력', width: '60px', align: 'right' },
{
key: 'status', label: '처리상태', width: '80px', sortable: true,
render: (val) => {
const s = val as string;
return <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
},
},
{ key: 'assignee', label: '담당', width: '70px' },
];
export function EventList() {
const { t } = useTranslation('enforcement');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const {
events: storeEvents,
stats,
@ -100,6 +54,53 @@ export function EventList() {
loadStats,
} = useEventStore();
const columns: DataColumn<EventRow>[] = useMemo(() => [
{
key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true,
render: (val) => {
const lv = val as AlertLevel;
return (
<Badge intent={getAlertLevelIntent(lv)} size="sm">
{getAlertLevelLabel(lv, tc, lang)}
</Badge>
);
},
},
{ key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true,
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(val as string)}</span>,
},
{ key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true,
render: (val) => {
const code = val as string;
return (
<Badge intent={getViolationIntent(code)} size="sm">
{getViolationLabel(code, tc, lang)}
</Badge>
);
},
},
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
},
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
{
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
render: (val) => {
const s = val as string;
return (
<Badge intent={getEventStatusIntent(s)} size="xs">
{getEventStatusLabel(s, tc, lang)}
</Badge>
);
},
},
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
], [tc, lang]);
const [levelFilter, setLevelFilter] = useState<string>('');
const [showUpload, setShowUpload] = useState(false);
@ -137,45 +138,41 @@ export function EventList() {
const kpiTotal = (stats['TOTAL'] as number | undefined) ?? EVENTS.length;
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Radar className="w-5 h-5 text-blue-400" />
{t('eventList.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('eventList.desc')}
</p>
</div>
<div className="flex items-center gap-2">
{/* 등급 필터 */}
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
title="등급 필터"
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-label focus:outline-none focus:border-blue-500/50"
<PageContainer>
<PageHeader
icon={Radar}
iconColor="text-blue-400"
title={t('eventList.title')}
description={t('eventList.desc')}
actions={
<>
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<Select
size="sm"
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
title="등급 필터"
className="w-32"
>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</Select>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowUpload(!showUpload)}
icon={<Upload className="w-3 h-3" />}
>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</select>
</div>
<button
type="button"
onClick={() => setShowUpload(!showUpload)}
className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors"
>
<Upload className="w-3 h-3" />
</button>
</div>
</div>
</Button>
</>
}
/>
{/* KPI 요약 */}
<div className="grid grid-cols-5 gap-3">
@ -250,6 +247,6 @@ export function EventList() {
exportFilename="이벤트목록"
/>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Send, Loader2, AlertTriangle } from 'lucide-react';
import { getAlerts, type PredictionAlert } from '@/services/event';
@ -51,7 +52,7 @@ const cols: DataColumn<AlertRow>[] = [
width: '80px',
sortable: true,
render: (v) => (
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
<Badge intent="info" size="sm">{v as string}</Badge>
),
},
{
@ -81,14 +82,10 @@ const cols: DataColumn<AlertRow>[] = [
sortable: true,
render: (v) => {
const s = v as string;
const c =
s === 'DELIVERED'
? 'bg-green-500/20 text-green-400'
: s === 'SENT'
? 'bg-blue-500/20 text-blue-400'
: 'bg-red-500/20 text-red-400';
const intent: 'success' | 'info' | 'critical' =
s === 'DELIVERED' ? 'success' : s === 'SENT' ? 'info' : 'critical';
return (
<Badge className={`border-0 text-[9px] ${c}`}>{STATUS_LABEL[s] ?? s}</Badge>
<Badge intent={intent} size="xs">{STATUS_LABEL[s] ?? s}</Badge>
);
},
},
@ -140,34 +137,37 @@ export function AIAlert() {
if (loading) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
<PageContainer>
<div className="flex items-center justify-center gap-2 text-muted-foreground py-8">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
</PageContainer>
);
}
if (error) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-red-400">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
</button>
</div>
<PageContainer>
<div className="flex items-center justify-center gap-2 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>
</div>
</PageContainer>
);
}
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Send className="w-5 h-5 text-yellow-400" />
{t('aiAlert.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('aiAlert.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-400"
title={t('aiAlert.title')}
description={t('aiAlert.desc')}
/>
<div className="flex gap-2">
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
@ -191,6 +191,6 @@ export function AIAlert() {
searchKeys={['channel', 'recipient']}
exportFilename="AI알림이력"
/>
</div>
</PageContainer>
);
}

파일 보기

@ -2,10 +2,12 @@ import { useEffect, 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import { useEventStore } from '@stores/eventStore';
import { formatTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, type AlertLevel } from '@shared/constants/alertLevels';
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
@ -51,11 +53,13 @@ export function MobileService() {
);
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Smartphone}
iconColor="text-blue-400"
title={t('mobileService.title')}
description={t('mobileService.desc')}
/>
<div className="grid grid-cols-3 gap-3">
{/* 모바일 프리뷰 */}
<Card>
@ -98,7 +102,7 @@ export function MobileService() {
<div className="space-y-1">
{ALERTS.slice(0, 2).map((a, i) => (
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
<div className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[a.level as AlertLevel]?.classes.dot ?? 'bg-slate-500'}`} />
<span className="text-[8px] text-label truncate">{a.title}</span>
</div>
))}
@ -142,6 +146,6 @@ export function MobileService() {
</CardContent></Card>
</div>
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react';
import { Monitor } from 'lucide-react';
import { getDeviceStatusIntent, getDeviceStatusLabel } from '@shared/constants/deviceStatuses';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-16: 함정용 단말에서 이용가능한 Agent 개발 */
@ -15,30 +18,40 @@ const DATA: Agent[] = [
{ id: 'AGT-105', ship: '서특단 1정', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:19:55', tasks: 2 },
{ id: 'AGT-106', ship: '1503함', version: '-', status: '미배포', sync: '-', lastSync: '-', tasks: 0 },
];
const cols: DataColumn<Agent>[] = [
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'version', label: '버전', width: '70px' },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '온라인' ? 'bg-green-500/20 text-green-400' : s === '오프라인' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
];
export function ShipAgent() {
const { t } = useTranslation('fieldOps');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const cols: DataColumn<Agent>[] = useMemo(() => [
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
{ key: 'version', label: '버전', width: '70px' },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => {
const s = v as string;
return (
<Badge intent={getDeviceStatusIntent(s)} size="sm">
{getDeviceStatusLabel(s, tc, lang)}
</Badge>
);
},
},
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
], [tc, lang]);
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Monitor}
iconColor="text-cyan-400"
title={t('shipAgent.title')}
description={t('shipAgent.desc')}
demo
/>
<div className="flex gap-2">
{[{ l: '전체 Agent', v: DATA.length, c: 'text-heading' }, { l: '온라인', v: DATA.filter(d => d.status === '온라인').length, c: 'text-green-400' }, { l: '오프라인', v: DATA.filter(d => d.status === '오프라인').length, c: 'text-red-400' }, { l: '미배포', v: DATA.filter(d => d.status === '미배포').length, c: 'text-muted-foreground' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
@ -47,6 +60,6 @@ export function ShipAgent() {
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="함정명, Agent ID 검색..." searchKeys={['ship', 'id']} exportFilename="함정Agent" />
</div>
</PageContainer>
);
}

파일 보기

@ -2,34 +2,28 @@ import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell, Clock, Target, ChevronRight } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
import { getKpiUi } from '@shared/constants/kpiUiMap';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
import { type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { SystemStatusPanel } from './SystemStatusPanel';
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
'다크베셀': { icon: Eye, color: '#f97316' },
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
'추적 중': { icon: Target, color: '#06b6d4' },
'나포/검문': { icon: Shield, color: '#10b981' },
};
// 위반 유형 → 차트 색상 매핑
const PIE_COLOR_MAP: Record<string, string> = {
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
'불법환적': '#a855f7', '어구 불법': '#6b7280',
};
const LV: Record<string, string> = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' };
// KPI_UI_MAP은 shared/constants/kpiUiMap 공통 모듈 사용
export function MonitoringDashboard() {
const { t } = useTranslation('dashboard');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const kpiStore = useKpiStore();
const eventStore = useEventStore();
@ -67,31 +61,33 @@ export function MonitoringDashboard() {
const KPI = kpiStore.metrics.map((m) => ({
label: m.label,
value: m.value,
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
icon: getKpiUi(m.label).icon,
color: getKpiUi(m.label).color,
}));
// PIE: store violationTypes → 차트 데이터 변환
// PIE: store violationTypes → 공통 카탈로그 기반 라벨/색상
const PIE = kpiStore.violationTypes.map((v) => ({
name: v.type,
name: getViolationLabel(v.type, tc, lang),
value: v.pct,
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
color: getViolationColor(v.type),
}));
// 이벤트: store events → 첫 6개, time 포맷 변환
// 이벤트: store events → 첫 6개, time은 KST로 포맷
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
time: formatDateTime(e.time),
level: e.level,
title: e.title,
detail: e.detail,
}));
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-green-400"
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
<SystemStatusPanel />
@ -122,14 +118,16 @@ export function MonitoringDashboard() {
<div className="space-y-2">
{EVENTS.map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono w-10">{e.time}</span>
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</Badge>
<span className="text-[10px] text-hint font-mono whitespace-nowrap shrink-0">{e.time}</span>
<Badge intent={getAlertLevelIntent(e.level)} size="sm" className="min-w-[52px]">
{getAlertLevelLabel(e.level, tc, lang)}
</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
<span className="text-[10px] text-hint">{e.detail}</span>
</div>
))}
</div>
</CardContent></Card>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, Activity, Database, Wifi } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import type { BadgeIntent } from '@lib/theme/variants';
import { fetchVesselAnalysis, type VesselAnalysisStats } from '@/services/vesselAnalysisApi';
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
@ -85,7 +86,7 @@ export function SystemStatusPanel() {
icon={<Database className="w-4 h-4" />}
title="KCG AI Backend"
status="UP"
statusColor="text-green-400"
statusIntent="success"
details={[
['포트', ':8080'],
['프로파일', 'local'],
@ -98,7 +99,7 @@ export function SystemStatusPanel() {
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusColor={stats ? 'text-green-400' : 'text-red-400'}
statusIntent={stats ? 'success' : 'critical'}
details={[
['선박 분석', stats ? `${stats.total.toLocaleString()}` : '-'],
['클러스터', stats ? `${stats.clusterCount}` : '-'],
@ -111,7 +112,7 @@ export function SystemStatusPanel() {
icon={<Activity className="w-4 h-4" />}
title="Prediction Service"
status={health?.status || 'UNKNOWN'}
statusColor={health?.status === 'ok' ? 'text-green-400' : 'text-yellow-400'}
statusIntent={health?.status === 'ok' ? 'success' : 'warning'}
details={[
['SNPDB', health?.snpdb === true ? 'OK' : '-'],
['KCGDB', health?.kcgdb === true ? 'OK' : '-'],
@ -134,11 +135,11 @@ export function SystemStatusPanel() {
);
}
function ServiceCard({ icon, title, status, statusColor, details }: {
function ServiceCard({ icon, title, status, statusIntent, details }: {
icon: React.ReactNode;
title: string;
status: string;
statusColor: string;
statusIntent: BadgeIntent;
details: [string, string][];
}) {
return (
@ -148,7 +149,7 @@ function ServiceCard({ icon, title, status, statusColor, details }: {
<span className="text-cyan-400">{icon}</span>
{title}
</div>
<Badge className={`bg-transparent border ${statusColor.replace('text-', 'border-')} ${statusColor} text-[9px]`}>
<Badge intent={statusIntent} size="xs">
{status}
</Badge>
</div>

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
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 { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchLabelSessions,
@ -10,6 +13,9 @@ import {
type LabelSession as LabelSessionType,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getLabelSessionIntent, getLabelSessionLabel } from '@shared/constants/parentResolutionStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* .
@ -18,13 +24,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
*/
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-500/20 text-green-400',
CANCELLED: 'bg-gray-500/20 text-gray-400',
COMPLETED: 'bg-blue-500/20 text-blue-400',
};
export function LabelSession() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { hasPermission } = useAuth();
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
@ -86,23 +88,32 @@ export function LabelSession() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> prediction </p>
</div>
<div className="flex items-center gap-2">
<select value={filter} onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={Tag}
iconColor="text-cyan-400"
title="학습 세션"
description="정답 라벨링 → prediction 모델 학습 데이터로 활용"
actions={
<>
<Select
size="sm"
aria-label="상태 필터"
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
<Card>
<CardContent className="p-4">
@ -111,11 +122,11 @@ export function LabelSession() {
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
<input aria-label="group_key" 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 type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
<input aria-label="sub_cluster_id" 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 value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
<input aria-label="정답 parent MMSI" 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}
@ -162,7 +173,7 @@ export function LabelSession() {
<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">
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
@ -181,6 +192,6 @@ export function LabelSession() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
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 { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchExclusions,
@ -102,25 +105,31 @@ export function ParentExclusion() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">GROUP/GLOBAL .</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="GROUP">GROUP</option>
<option value="GLOBAL">GLOBAL</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={Ban}
iconColor="text-red-400"
title="모선 후보 제외"
description="GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다."
actions={
<>
<Select
size="sm"
aria-label="스코프 필터"
title="스코프 필터"
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
>
<option value=""> </option>
<option value="GROUP">GROUP</option>
<option value="GLOBAL">GLOBAL</option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
{/* 신규 등록: GROUP */}
<Card>
@ -130,13 +139,13 @@ export function ParentExclusion() {
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
<input aria-label="group_key" 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 type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
<input aria-label="sub_cluster_id" 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 value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
<input aria-label="excluded MMSI" 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 value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
<input aria-label="제외 사유" 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}
@ -156,9 +165,9 @@ export function ParentExclusion() {
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
<input aria-label="excluded MMSI (전역)" 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 value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
<input aria-label="전역 제외 사유" 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}
@ -203,7 +212,7 @@ export function ParentExclusion() {
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${it.scopeType === 'GLOBAL' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'}`}>
<Badge intent={it.scopeType === 'GLOBAL' ? 'critical' : 'high'} size="xs">
{it.scopeType}
</Badge>
</td>
@ -226,6 +235,6 @@ export function ParentExclusion() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,7 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import { CheckCircle, XCircle, RotateCcw, Loader2 } from 'lucide-react';
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 { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchReviewList,
@ -9,6 +12,9 @@ import {
type ParentResolution,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* // .
@ -17,19 +23,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
* - audit_log + review_log에
*/
const STATUS_COLORS: Record<string, string> = {
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
};
const STATUS_LABELS: Record<string, string> = {
UNRESOLVED: '미해결',
MANUAL_CONFIRMED: '확정됨',
REVIEW_REQUIRED: '검토필요',
};
export function ParentReview() {
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const { hasPermission } = useAuth();
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
const [items, setItems] = useState<ParentResolution[]>([]);
@ -104,34 +100,26 @@ export function ParentReview() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> /</h1>
<p className="text-xs text-hint mt-1">
/. 권한: parent-inference-workflow:parent-review (UPDATE)
</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</select>
<button
type="button"
onClick={load}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"
>
</button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={GitMerge}
iconColor="text-purple-400"
title="모선 확정/거부"
description="추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)"
actions={
<>
<Select size="sm" title="상태 필터" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
{/* 신규 등록 폼 (테스트용) */}
{canUpdate && (
@ -140,6 +128,7 @@ export function ParentReview() {
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
type="text"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
@ -147,6 +136,7 @@ export function ParentReview() {
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="sub_cluster_id"
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
@ -154,6 +144,7 @@ export function ParentReview() {
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
aria-label="parent MMSI"
type="text"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}
@ -228,8 +219,8 @@ export function ParentReview() {
<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">
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
{STATUS_LABELS[it.status] || it.status}
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
{getParentResolutionLabel(it.status, tc, lang)}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
@ -274,6 +265,6 @@ export function ParentReview() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } 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 { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react';
import { getStatusIntent } from '@shared/constants/statusIntent';
import { usePatrolStore } from '@stores/patrolStore';
/* SFR-08: AI 경비함정 다함정 협력형 경로 최적화 서비스 */
@ -96,24 +99,31 @@ export function FleetOptimization() {
useMapLayers(mapRef, buildLayers, [ships, COVERAGE, FLEET_ROUTES]);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
</div>
<div className="flex gap-1.5">
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /></button>
<button onClick={() => setApproved(true)} disabled={!simRunning}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-heading text-[10px] font-bold rounded-lg"><CheckCircle className="w-3 h-3" /> </button>
</div>
</div>
<PageContainer>
<PageHeader
icon={Users}
iconColor="text-purple-400"
title={t('fleetOptimization.title')}
description={t('fleetOptimization.desc')}
demo
actions={
<>
<Button variant="primary" size="sm" onClick={() => setSimRunning(true)} icon={<Play className="w-3 h-3" />}>
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setApproved(true)}
disabled={!simRunning}
icon={<CheckCircle className="w-3 h-3" />}
className="bg-green-600 hover:bg-green-500 border-green-700"
>
</Button>
</>
}
/>
{/* KPI */}
<div className="flex gap-2">
@ -141,7 +151,7 @@ export function FleetOptimization() {
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: f.color }} />
<span className="text-[11px] font-bold text-heading">{f.name}</span>
</div>
<Badge className={`border-0 text-[8px] ${f.status === '가용' ? 'bg-green-500/20 text-green-400' : f.status === '출동중' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-muted text-muted-foreground'}`}>{f.status}</Badge>
<Badge intent={f.status === '출동중' ? 'warning' : getStatusIntent(f.status)} size="xs">{f.status}</Badge>
</div>
<div className="flex gap-3 mt-1 text-[9px] text-hint">
<span>: {f.zone}</span><span>: {f.speed}</span><span>: {f.fuel}%</span>
@ -218,6 +228,6 @@ export function FleetOptimization() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -4,6 +4,8 @@ import type maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } 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 { Navigation, Ship, MapPin, Clock, Wind, Anchor, Play, BarChart3, Target, Settings, CheckCircle, Share2 } from 'lucide-react';
import { usePatrolStore } from '@stores/patrolStore';
@ -93,23 +95,24 @@ export function PatrolRoute() {
if (!currentShip || !route) return null;
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
</div>
<div className="flex gap-1.5">
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Share2 className="w-3 h-3" /></button>
</div>
</div>
<PageContainer>
<PageHeader
icon={Navigation}
iconColor="text-cyan-400"
title={t('patrolRoute.title')}
description={t('patrolRoute.desc')}
demo
actions={
<>
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>
</Button>
<Button variant="secondary" size="sm" icon={<Share2 className="w-3 h-3" />}>
</Button>
</>
}
/>
<div className="grid grid-cols-4 gap-3">
{/* 함정 선택 */}
@ -122,7 +125,7 @@ export function PatrolRoute() {
className={`px-3 py-2 rounded-lg cursor-pointer transition-colors ${selectedShip === s.id ? 'bg-cyan-600/20 border border-cyan-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'} ${s.status !== '가용' ? 'opacity-40 cursor-not-allowed' : ''}`}>
<div className="flex justify-between items-center">
<span className="text-[11px] font-bold text-heading">{s.name}</span>
<Badge className={`border-0 text-[8px] ${s.status === '가용' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{s.status}</Badge>
<Badge intent={s.status === '가용' ? 'success' : 'critical'} size="xs">{s.status}</Badge>
</div>
<div className="text-[9px] text-hint mt-0.5">{s.class} · {s.speed} · {s.range}</div>
</div>
@ -215,6 +218,6 @@ export function PatrolRoute() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -2,7 +2,10 @@ import { useState, useEffect, 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
@ -32,14 +35,14 @@ const cols: DataColumn<Plan>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'zone', 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 => { const n = v as number; return <Badge className={`border-0 text-[9px] ${n > 80 ? 'bg-red-500/20 text-red-400' : n > 60 ? 'bg-orange-500/20 text-orange-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{n}</Badge>; } },
render: v => { const n = v as number; return <Badge intent={getRiskIntent(n)} size="xs">{n}</Badge>; } },
{ key: 'period', label: '단속 시간', width: '160px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
{ key: 'alert', label: '경보', width: '80px', align: 'center',
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
];
export function EnforcementPlan() {
@ -118,14 +121,18 @@ export function EnforcementPlan() {
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Shield className="w-5 h-5 text-orange-400" />{t('enforcementPlan.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('enforcementPlan.desc')}</p>
</div>
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" /> </button>
</div>
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-orange-400"
title={t('enforcementPlan.title')}
description={t('enforcementPlan.desc')}
actions={
<Button variant="primary" size="md" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 로딩/에러 상태 */}
{loading && (
@ -153,7 +160,7 @@ export function EnforcementPlan() {
<div className="flex gap-4 text-[10px]">
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{k}</Badge>
<Badge intent="critical" size="sm">{k}</Badge>
<span className="text-muted-foreground">{v}</span>
</div>
))}
@ -185,6 +192,6 @@ export function EnforcementPlan() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,8 @@ import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts';
@ -175,20 +177,24 @@ export function RiskMap() {
useMapLayers(mapRef, buildLayers, [tab]);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Map className="w-5 h-5 text-red-400" />
<PageContainer>
<PageHeader
icon={Map}
iconColor="text-red-400"
title="격자 기반 불법조업 위험도 지도"
description="SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)"
actions={
<>
{COLLECTING_BADGE}
</h2>
<p className="text-[10px] text-hint mt-0.5">SFR-05 | ( ) + MTIS ()</p>
</div>
<div className="flex gap-1.5">
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Printer className="w-3 h-3" /></button>
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Download className="w-3 h-3" /></button>
</div>
</div>
<Button variant="secondary" size="sm" icon={<Printer className="w-3 h-3" />}>
</Button>
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
</>
}
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
@ -200,7 +206,7 @@ export function RiskMap() {
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -489,6 +495,6 @@ export function RiskMap() {
</CardContent></Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock } from 'lucide-react';
import { getStatusIntent } from '@shared/constants/statusIntent';
import type { BadgeIntent } from '@lib/theme/variants';
import { Globe } from 'lucide-react';
/* SFR-14: 외부 서비스(예보·경보) 제공 결과 연계 */
@ -18,29 +20,31 @@ const cols: DataColumn<Service>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'name', label: '서비스명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'target', label: '제공 대상', width: '80px', sortable: true },
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge intent="cyan" size="sm">{v as string}</Badge> },
{ key: 'format', label: '포맷', width: '60px', align: 'center' },
{ key: 'cycle', label: '갱신주기', width: '70px' },
{ key: 'privacy', label: '정보등급', width: '70px', align: 'center',
render: v => { const p = v as string; const c = p === '비공개' ? 'bg-red-500/20 text-red-400' : p === '비식별' ? 'bg-yellow-500/20 text-yellow-400' : p === '익명화' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
render: v => {
const p = v as string;
const intent: BadgeIntent = p === '비공개' ? 'critical' : p === '비식별' ? 'warning' : p === '익명화' ? 'info' : 'success';
return <Badge intent={intent} size="xs">{p}</Badge>;
} },
{ key: 'status', label: '상태', width: '60px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '운영' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
{ key: 'calls', label: '호출 수', width: '70px', align: 'right', render: v => <span className="text-heading font-bold">{v as string}</span> },
];
export function ExternalService() {
const { t } = useTranslation('statistics');
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Globe}
iconColor="text-green-400"
title={t('externalService.title')}
description={t('externalService.desc')}
demo
/>
<div className="flex gap-2">
{[{ l: '운영 서비스', v: DATA.filter(d => d.status === '운영').length, c: 'text-green-400' }, { l: '테스트', v: DATA.filter(d => d.status === '테스트').length, c: 'text-blue-400' }, { l: '총 호출', v: '21,675', c: 'text-heading' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
@ -49,6 +53,6 @@ export function ExternalService() {
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="서비스명, 대상기관 검색..." searchKeys={['name', 'target']} exportFilename="외부서비스연계" />
</div>
</PageContainer>
);
}

파일 보기

@ -2,28 +2,32 @@ import { useState } 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { ExcelExport } from '@shared/components/common/ExcelExport';
import { PrintButton } from '@shared/components/common/PrintButton';
import { FileUpload } from '@shared/components/common/FileUpload';
import { SaveButton } from '@shared/components/common/SaveButton';
import { SearchInput } from '@shared/components/common/SearchInput';
import { Plus, Download, Clock, MapPin, Upload, X } from 'lucide-react';
import { FileText, Plus, Upload, X, Clock, MapPin, Download } from 'lucide-react';
import type { BadgeIntent } from '@lib/theme/variants';
interface Report {
id: string;
name: string;
type: string;
status: string;
statusColor: string;
statusIntent: BadgeIntent;
date: string;
mmsiNote: string;
evidence: number;
}
const reports: Report[] = [
{ id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusColor: 'bg-green-500', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 },
{ id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusColor: 'bg-blue-500', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 },
{ id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusColor: 'bg-yellow-500', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 },
{ id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusIntent: 'success', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 },
{ id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusIntent: 'info', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 },
{ id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusIntent: 'warning', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 },
];
export function ReportManagement() {
@ -37,47 +41,47 @@ export function ReportManagement() {
);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
{t('reports.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
</div>
<div className="flex items-center gap-2">
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
<ExcelExport
data={reports as unknown as Record<string, unknown>[]}
columns={[
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
]}
filename="보고서목록"
/>
<PrintButton />
<button
onClick={() => setShowUpload(!showUpload)}
className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors"
>
<Upload className="w-3 h-3" />
</button>
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-heading px-4 py-2 rounded-lg text-sm transition-colors">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<PageContainer>
<PageHeader
icon={FileText}
iconColor="text-muted-foreground"
title={t('reports.title')}
description={t('reports.desc')}
demo
actions={
<>
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
<ExcelExport
data={reports as unknown as Record<string, unknown>[]}
columns={[
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
]}
filename="보고서목록"
/>
<PrintButton />
<Button
variant="secondary"
size="sm"
onClick={() => setShowUpload(!showUpload)}
icon={<Upload className="w-3 h-3" />}
>
</Button>
<Button variant="destructive" size="md" icon={<Plus className="w-4 h-4" />}>
</Button>
</>
}
/>
{/* 증거 파일 업로드 */}
{showUpload && (
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
@ -104,7 +108,7 @@ export function ReportManagement() {
>
<div className="flex items-center justify-between mb-1">
<span className="text-heading font-medium text-sm">{r.name}</span>
<Badge className={`${r.statusColor} text-heading text-[10px]`}>{r.status}</Badge>
<Badge intent={r.statusIntent} size="xs">{r.status}</Badge>
</div>
<div className="text-[11px] text-hint">{r.id}</div>
<div className="flex items-center gap-2 text-[11px] text-hint mt-0.5">
@ -116,8 +120,8 @@ export function ReportManagement() {
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<button className="bg-blue-600 text-heading text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
</div>
</div>
))}
@ -131,7 +135,7 @@ export function ReportManagement() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-heading px-3 py-1.5 rounded-lg text-xs transition-colors">
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
</div>
@ -186,6 +190,6 @@ export function ReportManagement() {
</Card>
)}
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,8 @@ import { useState, useEffect } 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 { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { BarChart3, Download } from 'lucide-react';
import { BarChart, AreaChart } from '@lib/charts';
@ -15,6 +17,8 @@ import {
} from '@/services/kpi';
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
import { useSettingsStore } from '@stores/settingsStore';
/* SFR-13: 통계·지표·성과 분석 */
@ -60,7 +64,7 @@ const kpiCols: DataColumn<KpiRow>[] = [
width: '60px',
align: 'center',
render: (v) => (
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
<Badge intent="success" size="sm">
{v as string}
</Badge>
),
@ -69,6 +73,8 @@ const kpiCols: DataColumn<KpiRow>[] = [
export function Statistics() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
@ -136,22 +142,18 @@ export function Statistics() {
});
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-purple-400" />
{t('statistics.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('statistics.desc')}
</p>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading">
<Download className="w-3 h-3" />
</button>
</div>
<PageContainer>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title={t('statistics.title')}
description={t('statistics.desc')}
actions={
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
}
/>
{loading && (
<div className="text-center py-10 text-muted-foreground text-sm">
@ -205,20 +207,25 @@ export function Statistics() {
</div>
<div className="flex gap-3">
{BY_TYPE.map((item) => (
<div
key={item.type}
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
>
<div className="text-lg font-bold text-heading">
{item.count}
{BY_TYPE.map((item) => {
const color = getViolationColor(item.type);
const label = getViolationLabel(item.type, tc, lang);
return (
<div
key={item.type}
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg border-l-4"
style={{ borderLeftColor: color }}
>
<div className="text-lg font-bold tabular-nums" style={{ color }}>
{item.count}
</div>
<div className="text-[10px] text-muted-foreground">
{label}
</div>
<div className="text-[9px] text-hint">{item.pct}%</div>
</div>
<div className="text-[10px] text-muted-foreground">
{item.type}
</div>
<div className="text-[9px] text-hint">{item.pct}%</div>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
@ -233,6 +240,6 @@ export function Statistics() {
searchPlaceholder="지표명 검색..."
exportFilename="성과지표"
/>
</div>
</PageContainer>
);
}

파일 보기

@ -4,6 +4,7 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay
import type { MarkerData } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import { AlertTriangle, Ship, Radio, Zap, Activity, Clock, Pin, Loader2, WifiOff } from 'lucide-react';
import {
fetchVesselAnalysis,
@ -14,13 +15,7 @@ import {
type PredictionEvent,
} from '@/services/event';
// ─── 위험도 레벨 → 마커 색상 ─────────────────
const RISK_MARKER_COLOR: Record<string, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
MEDIUM: '#3b82f6',
LOW: '#6b7280',
};
import { getAlertLevelHex } from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
@ -171,7 +166,7 @@ export function LiveMapView() {
'ais-vessels',
vesselMarkers.map((v): MarkerData => {
const level = v.item.algorithms.riskScore.level;
const color = RISK_MARKER_COLOR[level] ?? '#6b7280';
const color = getAlertLevelHex(level);
return {
lat: v.lat,
lng: v.lng,
@ -233,7 +228,7 @@ export function LiveMapView() {
}, [selectedEvent]);
return (
<div className="flex gap-5 h-[calc(100vh-7rem)]">
<PageContainer fullBleed className="flex gap-5 h-[calc(100vh-7rem)]">
{/* 좌측: 이벤트 목록 + 지도 */}
<div className="flex-1 flex gap-4 min-w-0">
{/* 이벤트 카드 목록 */}
@ -294,7 +289,7 @@ export function LiveMapView() {
{/* 지도 영역 */}
<div className="flex-1 relative rounded-xl overflow-hidden">
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" style={{ minHeight: 400 }} onClick={handleMapClick} onMapReady={handleMapReady} />
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" className="min-h-[400px]" onClick={handleMapClick} onMapReady={handleMapReady} />
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1"> </div>
@ -367,7 +362,7 @@ export function LiveMapView() {
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-blue-400" />
<span className="text-sm text-heading font-medium">AI </span>
<Badge className="bg-red-500/20 text-red-400 text-[10px]">신뢰도: High</Badge>
<Badge intent="critical" size="md">신뢰도: High</Badge>
</div>
<div className="space-y-3">
<div className="border-l-2 border-red-500 pl-3">
@ -397,6 +392,6 @@ export function LiveMapView() {
</Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,8 +2,11 @@ import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Map, Shield, Crosshair, AlertTriangle, Eye, Anchor, Ship, Filter, Layers, Target, Clock, MapPin, Bell, Navigation, Info } from 'lucide-react';
import { getTrainingZoneIntent, getTrainingZoneHex, getTrainingZoneMeta } from '@shared/constants/trainingZoneTypes';
import type { BadgeIntent } from '@lib/theme/variants';
/*
* (No.462)
@ -126,40 +129,32 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
render: v => {
const c = v as string;
const color = c.includes('사격') || c.includes('군사') ? 'bg-red-500/20 text-red-400'
: c.includes('기뢰') ? 'bg-orange-500/20 text-orange-400'
: c.includes('오염') ? 'bg-yellow-500/20 text-yellow-400'
: 'bg-blue-500/20 text-blue-400';
return <Badge className={`border-0 text-[9px] ${color}`}>{c}</Badge>;
const intent: BadgeIntent = c.includes('사격') || c.includes('군사') ? 'critical'
: c.includes('기뢰') ? 'high'
: c.includes('오염') ? 'warning'
: 'info';
return <Badge intent={intent} size="xs">{c}</Badge>;
},
},
{ key: 'sea', label: '해역', width: '50px', sortable: true },
{ key: 'title', label: '제목', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'position', label: '위치', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: v => <Badge className={`border-0 text-[9px] ${v === '발령중' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
render: v => <Badge intent={v === '발령중' ? 'critical' : 'muted'} size="xs">{v as string}</Badge> },
];
// ─── 범례 색상 ──────────────────────────
const TYPE_COLORS: Record<string, { bg: string; text: string; label: string; mapColor: string }> = {
'해군': { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: '해군 훈련 구역', mapColor: '#eab308' },
'공군': { bg: 'bg-pink-500/20', text: 'text-pink-400', label: '공군 훈련 구역', mapColor: '#ec4899' },
'육군': { bg: 'bg-green-500/20', text: 'text-green-400', label: '육군 훈련 구역', mapColor: '#22c55e' },
'국과연': { bg: 'bg-blue-500/20', text: 'text-blue-400', label: '국방과학연구소', mapColor: '#3b82f6' },
'해경': { bg: 'bg-purple-500/20', text: 'text-purple-400', label: '해양경찰청 훈련구역', mapColor: '#a855f7' },
};
// 훈련구역 색상은 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: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
render: v => { const t = TYPE_COLORS[v as string]; return <Badge className={`border-0 text-[9px] ${t?.bg} ${t?.text}`}>{v as string}</Badge>; } },
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'sea', label: '해역', width: '60px', sortable: true },
{ key: 'lat', label: '위도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'lng', label: '경도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'radius', label: '반경', width: '60px', align: 'center' },
{ key: 'status', label: '상태', width: '60px', align: 'center', sortable: true,
render: v => <Badge className={`border-0 text-[9px] ${v === '활성' ? 'bg-green-500/20 text-green-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
render: v => <Badge intent={v === '활성' ? 'success' : 'muted'} size="xs">{v as string}</Badge> },
{ key: 'schedule', label: '운용', width: '60px', align: 'center' },
{ key: 'note', label: '비고', render: v => <span className="text-hint">{v as string}</span> },
];
@ -214,7 +209,7 @@ export function MapControl() {
const lat = parseDMS(z.lat);
const lng = parseDMS(z.lng);
if (lat === null || lng === null) return;
const color = TYPE_COLORS[z.type]?.mapColor || '#6b7280';
const color = getTrainingZoneHex(z.type);
const radiusM = parseRadius(z.radius);
const isActive = z.status === '활성';
parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z });
@ -251,18 +246,14 @@ export function MapControl() {
useMapLayers(mapRef, buildLayers, [visibleZones]);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Map className="w-5 h-5 text-cyan-400" />
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5"> No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
</div>
</div>
<PageContainer>
<PageHeader
icon={Map}
iconColor="text-cyan-400"
title="해역 통제"
description="한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원"
demo
/>
{/* KPI */}
<div className="flex gap-2">
@ -285,12 +276,16 @@ export function MapControl() {
{/* 범례 */}
<div className="flex items-center gap-4 px-4 py-2 rounded-xl border border-border bg-card">
<span className="text-[10px] text-hint font-bold">:</span>
{Object.entries(TYPE_COLORS).map(([type, c]) => (
<div key={type} className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: c.mapColor, opacity: 0.6 }} />
<span className="text-[10px] text-muted-foreground">{c.label}</span>
</div>
))}
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
const meta = getTrainingZoneMeta(type);
if (!meta) return null;
return (
<div key={type} className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: meta.hex, opacity: 0.6 }} />
<span className="text-[10px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
{/* 탭 + 해역 필터 */}
@ -305,7 +300,7 @@ export function MapControl() {
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
@ -314,8 +309,8 @@ export function MapControl() {
<div className="flex items-center gap-1.5 ml-auto">
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
<button type="button" key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
</button>
))}
@ -345,8 +340,8 @@ export function MapControl() {
<Filter className="w-3.5 h-3.5 text-hint" />
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
))}
</div>
@ -358,7 +353,7 @@ export function MapControl() {
<div className="space-y-2">
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
<div key={n.no} className="flex items-start gap-3 px-3 py-2.5 bg-red-500/5 border border-red-500/10 rounded-lg">
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px] shrink-0 mt-0.5">{n.category}</Badge>
<Badge intent="critical" size="sm" className="shrink-0 mt-0.5">{n.category}</Badge>
<div className="flex-1 min-w-0">
<div className="text-[11px] text-heading font-medium">{n.title}</div>
<div className="text-[10px] text-hint mt-0.5">{n.detail}</div>
@ -397,12 +392,16 @@ export function MapControl() {
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
<div className="text-[9px] text-muted-foreground font-bold mb-1.5"> </div>
<div className="space-y-1">
{Object.entries(TYPE_COLORS).map(([type, c]) => (
<div key={type} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: c.mapColor, borderColor: c.mapColor, opacity: 0.7 }} />
<span className="text-[9px] text-muted-foreground">{c.label}</span>
</div>
))}
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
const meta = getTrainingZoneMeta(type);
if (!meta) return null;
return (
<div key={type} className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: meta.hex, borderColor: meta.hex, opacity: 0.7 }} />
<span className="text-[9px] text-muted-foreground">{meta.fallback.ko}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/50" /><span className="text-[8px] text-hint">EEZ</span></div>
@ -427,6 +426,6 @@ export function MapControl() {
/>
</>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,13 +1,17 @@
import { Card, CardContent } from '@shared/components/ui/card';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { RefreshCw } from 'lucide-react';
import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
export function TransferDetection() {
return (
<div className="space-y-5">
<div>
<h2 className="text-xl font-bold text-heading">· </h2>
<p className="text-xs text-hint mt-0.5"> </p>
</div>
<PageContainer>
<PageHeader
icon={RefreshCw}
iconColor="text-cyan-400"
title="환적·접촉 탐지"
description="선박 간 근접 접촉 및 환적 의심 행위 분석"
/>
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
<RealTransshipSuspects />
@ -32,6 +36,6 @@ export function TransferDetection() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import {
Search,
Ship, AlertTriangle, Radar, MapPin, Printer,
@ -14,6 +15,9 @@ import {
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event';
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
// ─── 허가 정보 타입 ──────────────────────
interface VesselPermitData {
@ -47,14 +51,6 @@ async function fetchVesselPermit(mmsi: string): Promise<VesselPermitData | null>
}
}
// ─── 위험도 레벨 → 색상 매핑 ──────────────
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' },
HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' },
MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' },
LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' },
};
const RIGHT_TOOLS = [
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
@ -152,13 +148,17 @@ export function VesselDetail() {
useMapLayers(mapRef, buildLayers, []);
// i18n + 카탈로그
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
// 위험도 점수 바
const riskScore = vessel?.algorithms.riskScore.score ?? 0;
const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW';
const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW;
const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel;
const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW;
return (
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
{/* ── 좌측: 선박 정보 패널 ── */}
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
@ -167,20 +167,20 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span>
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</span>
<input value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
<input aria-label="MMSI" value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
placeholder="MMSI 입력"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" />
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<button type="button" className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
<Search className="w-3 h-3" />
</button>
</div>
@ -280,12 +280,12 @@ export function VesselDetail() {
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-hint"></span>
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
{riskConfig.label}
<Badge intent={riskMeta.intent} size="sm">
{getAlertLevelLabel(riskLevel, tc, lang)}
</Badge>
</div>
<div className="flex items-baseline gap-1 mb-1">
<span className={`text-xl font-bold ${riskConfig.color}`}>
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
{Math.round(riskScore * 100)}
</span>
<span className="text-[10px] text-hint">/100</span>
@ -341,15 +341,14 @@ export function VesselDetail() {
) : (
<div className="space-y-1.5">
{events.map((evt) => {
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
return (
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
{evt.level}
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
{getAlertLevelLabel(evt.level, tc, lang)}
</Badge>
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
<Badge intent="muted" size="xs" className="px-1.5 py-0">
{evt.status}
</Badge>
</div>
@ -378,8 +377,8 @@ export function VesselDetail() {
<Ship className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
{vessel && (
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
: {riskConfig.label}
<Badge intent={riskMeta.intent} size="sm">
: {getAlertLevelLabel(riskLevel, tc, lang)}
</Badge>
)}
</div>
@ -415,20 +414,20 @@ export function VesselDetail() {
{/* ── 우측 도구바 ── */}
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
{RIGHT_TOOLS.map((t) => (
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
<button type="button" key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
</button>
))}
<div className="flex-1" />
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
<div className="h-px bg-white/[0.06]" />
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
</div>
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]"></span></button>
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
<button type="button" className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]"></span></button>
<button type="button" className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]"></span></button>
<button type="button" className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -74,7 +74,7 @@ export function FilterBar({ filter, onChange, groupBy, onGroupByChange, meta }:
v{meta.version} · {meta.releaseDate} · {meta.nodeCount} · {meta.edgeCount}
</div>
<div className="sf-spacer" />
<input
<input aria-label="검색 (label, file, symbol, tag)"
type="text"
placeholder="검색 (label, file, symbol, tag)"
value={filter.search}

파일 보기

@ -10,6 +10,7 @@
"patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History",
"realtimeEvent": "Realtime Events",
"eventList": "Event List",
"mobileService": "Mobile",
"shipAgent": "Ship Agent",
@ -48,6 +49,133 @@
"medium": "Medium",
"low": "Low"
},
"violation": {
"eezViolation": "EEZ Violation",
"darkVessel": "Dark Vessel",
"mmsiTampering": "MMSI Tampering",
"illegalTransship": "Illegal Transship",
"illegalGear": "Illegal Gear",
"riskBehavior": "Risk Behavior"
},
"eventStatus": {
"NEW": "New",
"ACK": "Acknowledged",
"IN_PROGRESS": "In Progress",
"RESOLVED": "Resolved",
"FALSE_POSITIVE": "False Positive"
},
"enforcementAction": {
"CAPTURE": "Capture",
"INSPECT": "Inspect",
"WARN": "Warn",
"DISPERSE": "Disperse",
"TRACK": "Track",
"EVIDENCE": "Evidence"
},
"enforcementResult": {
"PUNISHED": "Punished",
"REFERRED": "Referred",
"WARNED": "Warned",
"RELEASED": "Released",
"FALSE_POSITIVE": "False Positive"
},
"patrolStatus": {
"AVAILABLE": "Available",
"ON_PATROL": "On Patrol",
"IN_PURSUIT": "In Pursuit",
"INSPECTING": "Inspecting",
"RETURNING": "Returning",
"STANDBY": "Standby",
"MAINTENANCE": "Maintenance"
},
"engineSeverity": {
"CRITICAL": "Critical",
"HIGH_CRITICAL": "High~Critical",
"HIGH": "High",
"MEDIUM_CRITICAL": "Medium~Critical",
"MEDIUM": "Medium",
"LOW": "Low",
"NONE": "-"
},
"deviceStatus": {
"ONLINE": "Online",
"OFFLINE": "Offline",
"SYNCING": "Syncing",
"NOT_DEPLOYED": "Not Deployed"
},
"parentResolution": {
"UNRESOLVED": "Unresolved",
"REVIEW_REQUIRED": "Review Required",
"MANUAL_CONFIRMED": "Manually Confirmed"
},
"labelSession": {
"ACTIVE": "Active",
"COMPLETED": "Completed",
"CANCELLED": "Cancelled"
},
"modelStatus": {
"DEPLOYED": "Deployed",
"APPROVED": "Approved",
"TESTING": "Testing",
"DRAFT": "Draft"
},
"gearGroupType": {
"FLEET": "Fleet",
"GEAR_IN_ZONE": "In-Zone Gear",
"GEAR_OUT_ZONE": "Out-Zone Gear"
},
"darkPattern": {
"AIS_FULL_BLOCK": "AIS Full Block",
"MMSI_SPOOFING": "MMSI Spoofing",
"LONG_LOSS": "Long Loss",
"INTERMITTENT": "Intermittent",
"SPEED_ANOMALY": "Speed Anomaly"
},
"userAccountStatus": {
"ACTIVE": "Active",
"PENDING": "Pending",
"LOCKED": "Locked",
"INACTIVE": "Inactive"
},
"loginResult": {
"SUCCESS": "Success",
"FAILED": "Failed",
"LOCKED": "Locked"
},
"permitStatus": {
"VALID": "Valid",
"PENDING": "Pending",
"EXPIRED": "Expired",
"UNLICENSED": "Unlicensed"
},
"gearJudgment": {
"NORMAL": "Normal",
"CHECKING": "Checking",
"SUSPECT_ILLEGAL": "Suspect Illegal"
},
"vesselSurveillance": {
"TRACKING": "Tracking",
"WATCHING": "Watching",
"CHECKING": "Checking",
"NORMAL": "Normal"
},
"vesselRing": {
"SAFE": "Safe",
"SUSPECT": "Suspect",
"WARNING": "Warning"
},
"connectionStatus": {
"OK": "OK",
"WARNING": "Warning",
"ERROR": "Error"
},
"trainingZone": {
"NAVY": "Navy Zone",
"AIRFORCE": "Airforce Zone",
"ARMY": "Army Zone",
"ADD": "ADD",
"KCG": "KCG"
},
"action": {
"search": "Search",
"save": "Save",

파일 보기

@ -10,6 +10,7 @@
"patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력",
"realtimeEvent": "실시간 이벤트",
"eventList": "이벤트 목록",
"mobileService": "모바일 서비스",
"shipAgent": "함정 Agent",
@ -48,6 +49,133 @@
"medium": "보통",
"low": "낮음"
},
"violation": {
"eezViolation": "EEZ 침범",
"darkVessel": "다크베셀",
"mmsiTampering": "MMSI 변조",
"illegalTransship": "불법 환적",
"illegalGear": "불법 어구",
"riskBehavior": "위험 행동"
},
"eventStatus": {
"NEW": "신규",
"ACK": "확인",
"IN_PROGRESS": "처리중",
"RESOLVED": "완료",
"FALSE_POSITIVE": "오탐"
},
"enforcementAction": {
"CAPTURE": "나포",
"INSPECT": "검문",
"WARN": "경고",
"DISPERSE": "퇴거",
"TRACK": "추적",
"EVIDENCE": "증거수집"
},
"enforcementResult": {
"PUNISHED": "처벌",
"REFERRED": "수사의뢰",
"WARNED": "경고",
"RELEASED": "훈방",
"FALSE_POSITIVE": "오탐(정상)"
},
"patrolStatus": {
"AVAILABLE": "가용",
"ON_PATROL": "초계중",
"IN_PURSUIT": "추적중",
"INSPECTING": "검문중",
"RETURNING": "귀항중",
"STANDBY": "대기",
"MAINTENANCE": "정비중"
},
"engineSeverity": {
"CRITICAL": "심각",
"HIGH_CRITICAL": "높음~심각",
"HIGH": "높음",
"MEDIUM_CRITICAL": "보통~심각",
"MEDIUM": "보통",
"LOW": "낮음",
"NONE": "-"
},
"deviceStatus": {
"ONLINE": "온라인",
"OFFLINE": "오프라인",
"SYNCING": "동기화중",
"NOT_DEPLOYED": "미배포"
},
"parentResolution": {
"UNRESOLVED": "미해결",
"REVIEW_REQUIRED": "검토 필요",
"MANUAL_CONFIRMED": "수동 확정"
},
"labelSession": {
"ACTIVE": "진행중",
"COMPLETED": "완료",
"CANCELLED": "취소"
},
"modelStatus": {
"DEPLOYED": "배포됨",
"APPROVED": "승인",
"TESTING": "테스트",
"DRAFT": "초안"
},
"gearGroupType": {
"FLEET": "선단",
"GEAR_IN_ZONE": "구역 내 어구",
"GEAR_OUT_ZONE": "구역 외 어구"
},
"darkPattern": {
"AIS_FULL_BLOCK": "AIS 완전차단",
"MMSI_SPOOFING": "MMSI 변조 의심",
"LONG_LOSS": "장기 소실",
"INTERMITTENT": "신호 간헐송출",
"SPEED_ANOMALY": "속도 이상"
},
"userAccountStatus": {
"ACTIVE": "활성",
"PENDING": "승인 대기",
"LOCKED": "잠금",
"INACTIVE": "비활성"
},
"loginResult": {
"SUCCESS": "성공",
"FAILED": "실패",
"LOCKED": "계정 잠금"
},
"permitStatus": {
"VALID": "유효",
"PENDING": "확인 중",
"EXPIRED": "만료",
"UNLICENSED": "무허가"
},
"gearJudgment": {
"NORMAL": "정상",
"CHECKING": "확인 중",
"SUSPECT_ILLEGAL": "불법 의심"
},
"vesselSurveillance": {
"TRACKING": "추적중",
"WATCHING": "감시중",
"CHECKING": "확인중",
"NORMAL": "정상"
},
"vesselRing": {
"SAFE": "양호",
"SUSPECT": "의심",
"WARNING": "경고"
},
"connectionStatus": {
"OK": "정상",
"WARNING": "경고",
"ERROR": "오류"
},
"trainingZone": {
"NAVY": "해군 훈련 구역",
"AIRFORCE": "공군 훈련 구역",
"ARMY": "육군 훈련 구역",
"ADD": "국방과학연구소",
"KCG": "해양경찰청"
},
"action": {
"search": "검색",
"save": "저장",

파일 보기

@ -0,0 +1,128 @@
/**
* variant (Single Source of Truth)
*
* variants.ts는 CVA .
* variant의 **//**
* .
*
* ) BadgeSection의 "intent 의미 가이드" .
* variant .
*/
import type { BadgeIntent, BadgeSize, ButtonVariant, ButtonSize } from './variants';
export interface VariantMeta<K extends string> {
key: K;
titleKo: string;
titleEn: string;
description: string;
}
// ── Badge intent 의미 ──────────────────────────
export const BADGE_INTENT_META: Record<BadgeIntent, VariantMeta<BadgeIntent>> = {
critical: {
key: 'critical',
titleKo: '심각',
titleEn: 'Critical',
description: '심각 · 긴급 · 위험 (빨강 계열)',
},
high: {
key: 'high',
titleKo: '높음',
titleEn: 'High',
description: '높음 · 경고 (주황 계열)',
},
warning: {
key: 'warning',
titleKo: '주의',
titleEn: 'Warning',
description: '주의 · 보류 (노랑 계열)',
},
info: {
key: 'info',
titleKo: '정보',
titleEn: 'Info',
description: '일반 · 정보 (파랑 계열, 기본값)',
},
success: {
key: 'success',
titleKo: '성공',
titleEn: 'Success',
description: '성공 · 완료 · 정상 (초록 계열)',
},
muted: {
key: 'muted',
titleKo: '중립',
titleEn: 'Muted',
description: '비활성 · 중립 · 기타 (회색 계열)',
},
purple: {
key: 'purple',
titleKo: '분석',
titleEn: 'Purple',
description: '분석 · AI · 특수 (보라 계열)',
},
cyan: {
key: 'cyan',
titleKo: '모니터',
titleEn: 'Cyan',
description: '모니터링 · 스트림 (청록 계열)',
},
};
export const BADGE_INTENT_ORDER: BadgeIntent[] = [
'critical',
'high',
'warning',
'info',
'success',
'muted',
'purple',
'cyan',
];
export const BADGE_SIZE_ORDER: BadgeSize[] = ['xs', 'sm', 'md', 'lg'];
// ── Button variant 의미 ────────────────────────
export const BUTTON_VARIANT_META: Record<ButtonVariant, VariantMeta<ButtonVariant>> = {
primary: {
key: 'primary',
titleKo: '주요',
titleEn: 'Primary',
description: '주요 액션 · 기본 CTA (페이지당 1개 권장)',
},
secondary: {
key: 'secondary',
titleKo: '보조',
titleEn: 'Secondary',
description: '보조 액션 · 툴바 버튼 (기본값)',
},
ghost: {
key: 'ghost',
titleKo: '고요',
titleEn: 'Ghost',
description: '고요한 액션 · 리스트 행 내부',
},
outline: {
key: 'outline',
titleKo: '윤곽',
titleEn: 'Outline',
description: '강조 보조 · 필터 활성화 상태',
},
destructive: {
key: 'destructive',
titleKo: '위험',
titleEn: 'Destructive',
description: '삭제 · 비활성화 등 위험 액션',
},
};
export const BUTTON_VARIANT_ORDER: ButtonVariant[] = [
'primary',
'secondary',
'ghost',
'outline',
'destructive',
];
export const BUTTON_SIZE_ORDER: ButtonSize[] = ['sm', 'md', 'lg'];

파일 보기

@ -17,29 +17,47 @@ export const cardVariants = cva('rounded-xl border border-border', {
defaultVariants: { variant: 'default' },
});
/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 */
/** / 150+
*
* ( + classes ):
* - ** **: (-500/20) + (-400) + (-500/30)
* (classes 4 )
* - ** **: (-100) + (-900) + (-300)
* ,
* -
* - 크기: rem
*
* className override는 cn(tailwind-merge) .
*/
export const badgeVariants = cva(
'inline-flex items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors',
'inline-flex items-center justify-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors text-center',
{
variants: {
intent: {
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
high: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
success: 'bg-green-500/20 text-green-400 border-green-500/30',
muted: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
cyan: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
critical:
'bg-red-100 text-red-900 border-red-300 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/30',
high: 'bg-orange-100 text-orange-900 border-orange-300 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30',
warning:
'bg-yellow-100 text-yellow-900 border-yellow-300 dark:bg-yellow-500/20 dark:text-yellow-400 dark:border-yellow-500/30',
info: 'bg-blue-100 text-blue-900 border-blue-300 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30',
success:
'bg-green-100 text-green-900 border-green-300 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30',
muted:
'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30',
purple:
'bg-purple-100 text-purple-900 border-purple-300 dark:bg-purple-500/20 dark:text-purple-400 dark:border-purple-500/30',
cyan: 'bg-cyan-100 text-cyan-900 border-cyan-300 dark:bg-cyan-500/20 dark:text-cyan-400 dark:border-cyan-500/30',
},
// rem 기반 — root font-size 대비 비율, 화면 비율 변경 시 자동 조정
// xs ≈ 11px, sm ≈ 12px, md ≈ 13px, lg ≈ 14px (root 14px 기준)
size: {
xs: 'text-[8px]',
sm: 'text-[9px]',
md: 'text-[10px]',
lg: 'text-[11px]',
xs: 'text-[0.6875rem] leading-tight',
sm: 'text-[0.75rem] leading-tight',
md: 'text-[0.8125rem] leading-tight',
lg: 'text-[0.875rem] leading-tight',
},
},
defaultVariants: { intent: 'info', size: 'md' },
defaultVariants: { intent: 'info', size: 'sm' },
},
);
@ -65,3 +83,89 @@ export type CardVariant = 'default' | 'elevated' | 'inner' | 'transparent';
export type BadgeIntent = 'critical' | 'high' | 'warning' | 'info' | 'success' | 'muted' | 'purple' | 'cyan';
export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg';
export type StatusDotStatus = 'online' | 'warning' | 'danger' | 'offline';
/** Button 5 variant × 3 size = 15
*
* :
* - primary/destructive: 솔리드 + text-on-vivid (, )
* - secondary/ghost/outline: 텍스트 (text-label / text-heading on hover)
*/
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium rounded-md transition-colors ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ' +
'disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 hover:bg-blue-500 text-on-vivid border border-blue-700',
secondary:
'bg-surface-overlay hover:bg-surface-raised text-label hover:text-heading border border-slate-600/40',
ghost: 'bg-transparent hover:bg-surface-overlay text-label hover:text-heading border border-transparent',
outline:
'bg-transparent hover:bg-blue-500/10 text-blue-400 hover:text-blue-300 border border-blue-500/50',
destructive: 'bg-red-600 hover:bg-red-500 text-on-vivid border border-red-700',
},
size: {
sm: 'h-7 px-2.5 text-xs',
md: 'h-8 px-3 text-sm',
lg: 'h-10 px-4 text-base',
},
},
defaultVariants: {
variant: 'secondary',
size: 'md',
},
},
);
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'destructive';
export type ButtonSize = 'sm' | 'md' | 'lg';
/** Input / Select / 유사 폼 요소 공통 변형 */
export const inputVariants = cva(
'w-full bg-surface-overlay border rounded-md text-label placeholder:text-hint ' +
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30 ' +
'transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
{
variants: {
size: {
sm: 'h-7 px-2 text-xs',
md: 'h-8 px-3 text-sm',
lg: 'h-10 px-3.5 text-base',
},
state: {
default: 'border-slate-600/40',
error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
},
},
defaultVariants: {
size: 'md',
state: 'default',
},
},
);
export type InputSize = 'sm' | 'md' | 'lg';
export type InputState = 'default' | 'error' | 'success';
/** PageContainer 변형 — 표준 페이지 루트 padding/spacing */
export const pageContainerVariants = cva('', {
variants: {
size: {
sm: 'p-4 space-y-3',
md: 'p-5 space-y-4',
lg: 'p-6 space-y-4',
},
fullBleed: {
true: 'p-0 space-y-0',
false: '',
},
},
defaultVariants: {
size: 'md',
fullBleed: false,
},
});
export type PageContainerSize = 'sm' | 'md' | 'lg';

파일 보기

@ -0,0 +1,35 @@
/**
* className tailwind-merge로 .
*
* cva() base class와 className prop을 .
* (text color, padding, bg ) .
*
* :
* cn(badgeVariants({ intent }), className)
* className에서 text-red-400 base의 text-on-bright을 override ( )
*
* (text-on-vivid, text-on-bright, text-heading ) tailwind-merge가
* text-color extendTailwindMerge로 .
*/
import { extendTailwindMerge } from 'tailwind-merge';
import { clsx, type ClassValue } from 'clsx';
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
// 프로젝트 시맨틱 텍스트 색상 (theme.css 정의) — text-* 그룹 충돌 해결
'text-color': [
'text-heading',
'text-label',
'text-hint',
'text-on-vivid',
'text-on-bright',
],
},
},
});
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

파일 보기

@ -68,6 +68,8 @@ export interface RoleWithPermissions {
roleCd: string;
roleNm: string;
roleDc: string;
/** UI 표기 색상 (#RRGGBB). NULL이면 프론트 기본 팔레트에서 결정 */
colorHex: string | null;
dfltYn: string;
builtinYn: string;
permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[];
@ -95,8 +97,13 @@ export function fetchPermTree() {
return apiGet<PermTreeNode[]>('/perm-tree');
}
export function fetchRoles() {
return apiGet<RoleWithPermissions[]>('/roles');
import { updateRoleColorCache } from '@shared/constants/userRoles';
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
const roles = await apiGet<RoleWithPermissions[]>('/roles');
// 역할 색상 캐시 즉시 갱신 → 모든 사용처 자동 반영
updateRoleColorCache(roles);
return roles;
}
// ─── 역할 CRUD ───────────────────────────────
@ -104,12 +111,14 @@ export interface RoleCreatePayload {
roleCd: string;
roleNm: string;
roleDc?: string;
colorHex?: string;
dfltYn?: string;
}
export interface RoleUpdatePayload {
roleNm?: string;
roleDc?: string;
colorHex?: string;
dfltYn?: string;
}

파일 보기

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Check } from 'lucide-react';
import { ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
interface ColorPickerProps {
/** 현재 선택된 hex 색상 (#RRGGBB) */
value: string | null | undefined;
/** 색상 변경 콜백 */
onChange: (hex: string) => void;
/** 사용자 지정 팔레트. 미지정 시 ROLE_DEFAULT_PALETTE 사용 */
palette?: string[];
/** 직접 입력 허용 여부 (default: true) */
allowCustom?: boolean;
className?: string;
label?: string;
}
/**
* .
* hex .
*
* 사용처: 역할 /
*/
export function ColorPicker({
value,
onChange,
palette = ROLE_DEFAULT_PALETTE,
allowCustom = true,
className = '',
label,
}: ColorPickerProps) {
const [customHex, setCustomHex] = useState<string>(value ?? '');
const handleCustomChange = (hex: string) => {
setCustomHex(hex);
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
onChange(hex);
}
};
return (
<div className={`space-y-2 ${className}`}>
{label && <div className="text-[10px] text-hint">{label}</div>}
<div className="flex flex-wrap gap-1.5">
{palette.map((color) => {
const selected = value?.toLowerCase() === color.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => {
onChange(color);
setCustomHex(color);
}}
className={`w-6 h-6 rounded-md border-2 transition-all flex items-center justify-center ${
selected ? 'border-white scale-110' : 'border-transparent hover:border-white/40'
}`}
style={{ backgroundColor: color }}
title={color}
>
{selected && <Check className="w-3 h-3 text-slate-900" strokeWidth={3} />}
</button>
);
})}
</div>
{allowCustom && (
<div className="flex items-center gap-2">
<input
type="color"
value={value ?? '#808080'}
onChange={(e) => {
onChange(e.target.value);
setCustomHex(e.target.value);
}}
className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer"
title="색상 직접 선택"
/>
<input
type="text"
value={customHex}
onChange={(e) => handleCustomChange(e.target.value)}
placeholder="#RRGGBB"
maxLength={7}
className="flex-1 px-2 py-1 bg-surface-overlay border border-border rounded text-[11px] text-label font-mono focus:outline-none focus:border-blue-500/50"
/>
</div>
)}
</div>
);
}

파일 보기

@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext';
export interface DataColumn<T> {
key: string;
label: string;
/** 고정 너비 (설정 시 가변 비활성) */
/** 선호 최소 너비. 컨텐츠가 더 길면 자동 확장. */
width?: string;
/** 최소 너비 (가변 모드에서 적용) */
/** 명시 최소 너비 (width와 사실상 동일하나 의미 강조용) */
minWidth?: string;
/** 최대 너비 (가변 모드에서 적용) */
/** 최대 너비 (초과 시 ellipsis 트런케이트) */
maxWidth?: string;
align?: 'left' | 'center' | 'right';
sortable?: boolean;
@ -144,12 +144,14 @@ export function DataTable<T extends Record<string, unknown>>({
<tr className="border-b border-border">
{columns.map((col) => {
const cellStyle: React.CSSProperties = {};
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
// width = 선호 최소 너비. 컨텐츠가 더 길면 자동 확장.
if (col.width) cellStyle.minWidth = col.width;
if (col.minWidth) cellStyle.minWidth = col.minWidth;
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
return (
<th
key={col.key}
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap ${
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap overflow-hidden text-ellipsis ${
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
} ${col.sortable !== false ? 'cursor-pointer hover:text-label select-none' : ''}`}
style={cellStyle}
@ -188,19 +190,23 @@ export function DataTable<T extends Record<string, unknown>>({
>
{columns.map((col) => {
const cellStyle: React.CSSProperties = {};
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
if (col.width) cellStyle.minWidth = col.width;
if (col.minWidth) cellStyle.minWidth = col.minWidth;
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
const rawValue = row[col.key];
const titleText = rawValue != null ? String(rawValue) : '';
return (
<td
key={col.key}
className={`px-4 py-2 whitespace-nowrap ${
className={`px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis ${
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
}`}
style={cellStyle}
title={titleText}
>
{col.render
? col.render(row[col.key], row, page * pageSize + i)
: <span className="text-label">{row[col.key] != null ? String(row[col.key]) : ''}</span>
? col.render(rawValue, row, page * pageSize + i)
: <span className="text-label">{titleText}</span>
}
</td>
);

파일 보기

@ -56,12 +56,13 @@ export function ExcelExport({
return (
<button
type="button"
onClick={handleExport}
disabled={!data.length}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${
exported
? 'bg-green-600/20 text-green-400 border border-green-500/30'
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border'
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-on-vivid hover:border-border'
} ${className}`}
>
{exported ? <Check className="w-3 h-3" /> : <FileSpreadsheet className="w-3 h-3" />}

파일 보기

@ -110,7 +110,7 @@ export function FileUpload({
<span className="text-label truncate flex-1">{f.file.name}</span>
<span className="text-hint text-[10px] shrink-0">{formatSize(f.file.size)}</span>
{f.msg && <span className="text-red-400 text-[10px] shrink-0">{f.msg}</span>}
<button onClick={() => removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0">
<button type="button" aria-label={`${f.file.name} 제거`} onClick={() => removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0">
<X className="w-3 h-3" />
</button>
</div>

파일 보기

@ -79,6 +79,8 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
</span>
{notice.dismissible && (
<button
type="button"
aria-label="알림 닫기"
onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0"
>
@ -145,7 +147,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
<div className="px-5 py-3 border-t border-border flex justify-end">
<button
onClick={close}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
>
</button>

파일 보기

@ -56,7 +56,7 @@ export function Pagination({ page, totalPages, totalItems, pageSize, onPageChang
onClick={() => onPageChange(p)}
className={`min-w-[24px] h-6 px-1 rounded text-[10px] font-medium transition-colors ${
p === page
? 'bg-blue-600 text-heading'
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
}`}
>

파일 보기

@ -41,6 +41,7 @@ export function PrintButton({ targetRef, label = '출력', className = '' }: Pri
return (
<button
type="button"
onClick={handlePrint}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border transition-colors ${className}`}
>

파일 보기

@ -29,12 +29,13 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN
return (
<button
type="button"
onClick={handleClick}
disabled={disabled || state !== 'idle'}
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${
state === 'done'
? 'bg-green-600 text-heading'
: 'bg-blue-600 hover:bg-blue-500 text-heading'
? 'bg-green-600 text-on-vivid'
: 'bg-blue-600 hover:bg-blue-500 text-on-vivid'
} ${className}`}
>
{state === 'saving' ? <Loader2 className="w-3.5 h-3.5 animate-spin" />

파일 보기

@ -17,6 +17,7 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text"
aria-label={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
@ -24,6 +25,8 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => onChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
>

파일 보기

@ -0,0 +1,34 @@
import { type HTMLAttributes, type ReactNode } from 'react';
import { pageContainerVariants, type PageContainerSize } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
export interface PageContainerProps extends HTMLAttributes<HTMLDivElement> {
size?: PageContainerSize;
/** 지도/3단 패널 등 풀화면 페이지: padding 0, space-y 0 */
fullBleed?: boolean;
children: ReactNode;
}
/**
* PageContainer feature
*
* : `p-5 space-y-4` ( )
* - size="sm" p-4 space-y-3
* - size="lg" p-6 space-y-4 (admin )
* - fullBleed padding (LiveMapView, VesselDetail )
*
* ESLint rule로 feature PageContainer ().
*/
export function PageContainer({
className,
size,
fullBleed,
children,
...props
}: PageContainerProps) {
return (
<div className={cn(pageContainerVariants({ size, fullBleed }), className)} {...props}>
{children}
</div>
);
}

파일 보기

@ -0,0 +1,63 @@
import { type ReactNode } from 'react';
import { type LucideIcon } from 'lucide-react';
import { cn } from '@lib/utils/cn';
import { Badge } from '@shared/components/ui/badge';
/**
* PageHeader //
*
* :
* [] + + + ()
* [] (// )
*
* :
* <PageHeader title="대시보드" description="실시간 종합 상황판" />
*
* <PageHeader
* icon={Shield}
* title="권한 관리"
* description="사용자/역할/권한 설정"
* demo
* actions={<Button variant="primary"></Button>}
* />
*/
export interface PageHeaderProps {
title: string;
description?: string;
icon?: LucideIcon;
/** 아이콘 색상 클래스 (text-blue-400 등). 미지정 시 text-muted-foreground */
iconColor?: string;
/** 데모 데이터 배지 노출 */
demo?: boolean;
/** 우측 액션 슬롯 */
actions?: ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
icon: Icon,
iconColor = 'text-muted-foreground',
demo = false,
actions,
className,
}: PageHeaderProps) {
return (
<div className={cn('flex items-start justify-between gap-4', className)}>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
{Icon && <Icon className={cn('w-5 h-5', iconColor)} />}
{title}
{demo && (
<Badge intent="warning" size="xs" className="font-normal">
</Badge>
)}
</h2>
{description && <p className="text-[11px] text-hint mt-0.5">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
);
}

파일 보기

@ -0,0 +1,51 @@
import { type ReactNode } from 'react';
import { type LucideIcon } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import type { CardVariant } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
/**
* Section Card + CardHeader + CardTitle + CardContent
*
* :
* <Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
* {...}
* </Section>
*/
export interface SectionProps {
title?: string;
icon?: LucideIcon;
iconColor?: string;
variant?: CardVariant;
/** 우측 액션 슬롯 (제목 옆 버튼 등) */
actions?: ReactNode;
children: ReactNode;
className?: string;
contentClassName?: string;
}
export function Section({
title,
icon: Icon,
iconColor = 'text-muted-foreground',
variant = 'default',
actions,
children,
className,
contentClassName,
}: SectionProps) {
return (
<Card variant={variant} className={className}>
{title && (
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
{Icon && <Icon className={cn('w-3.5 h-3.5', iconColor)} />}
{title}
</CardTitle>
{actions && <div className="flex items-center gap-1.5">{actions}</div>}
</CardHeader>
)}
<CardContent className={cn(title ? 'pt-0' : 'pt-4', contentClassName)}>{children}</CardContent>
</Card>
);
}

파일 보기

@ -0,0 +1,3 @@
export { PageContainer, type PageContainerProps } from './PageContainer';
export { PageHeader, type PageHeaderProps } from './PageHeader';
export { Section, type SectionProps } from './Section';

파일 보기

@ -1,5 +1,6 @@
import { type HTMLAttributes } from 'react';
import { badgeVariants, type BadgeIntent, type BadgeSize } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
/** 의미 기반 색상 (기존 variant 대체) */
@ -17,11 +18,13 @@ const LEGACY_MAP: Record<string, BadgeIntent> = {
outline: 'muted',
};
export function Badge({ className = '', intent, size, variant, ...props }: BadgeProps) {
export function Badge({ className, intent, size, variant, ...props }: BadgeProps) {
const resolvedIntent = intent ?? (variant ? LEGACY_MAP[variant] : undefined);
// cn() = clsx + tailwind-merge: 같은 그룹(text-color, bg, padding 등) 충돌 시
// 마지막 className이 우선 → base 클래스의 일관 정책을 className에서 명시적으로만 override 가능
return (
<div
className={`${badgeVariants({ intent: resolvedIntent, size })} ${className}`}
className={cn(badgeVariants({ intent: resolvedIntent, size }), className)}
{...props}
/>
);

파일 보기

@ -0,0 +1,35 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { buttonVariants, type ButtonVariant, type ButtonSize } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
trailingIcon?: ReactNode;
}
/**
* Button
* variant: primary / secondary / ghost / outline / destructive
* size: sm / md / lg
* className override는 cn() .
*
* ** **:
* - children이 버튼:
* - (children icon만): aria-label title
* ) <Button variant="ghost" aria-label="닫기" icon={<X/>} />
* - WCAG 2.1 Level A
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {
return (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props}>
{icon}
{children}
{trailingIcon}
</button>
);
},
);
Button.displayName = 'Button';

파일 보기

@ -0,0 +1,30 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
}
/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id, ...props }, ref) => {
const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`;
return (
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
ref={ref}
id={inputId}
type="checkbox"
className={cn(
'w-3.5 h-3.5 rounded border-slate-600/60 bg-surface-overlay',
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
{label && <span className="text-xs text-label">{label}</span>}
</label>
);
},
);
Checkbox.displayName = 'Checkbox';

파일 보기

@ -0,0 +1,23 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
size?: InputSize;
state?: InputState;
}
/** Input — 프로젝트 표준 입력 필드 */
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, size, state, type = 'text', ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(inputVariants({ size, state }), className)}
{...props}
/>
);
},
);
Input.displayName = 'Input';

파일 보기

@ -0,0 +1,29 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ className, label, id, ...props }, ref) => {
const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`;
return (
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
ref={ref}
id={inputId}
type="radio"
className={cn(
'w-3.5 h-3.5 border-slate-600/60 bg-surface-overlay',
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
{label && <span className="text-xs text-label">{label}</span>}
</label>
);
},
);
Radio.displayName = 'Radio';

파일 보기

@ -0,0 +1,44 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
import { cn } from '@lib/utils/cn';
/**
* Select select .
*
* ** ( )**:
* select의 3 :
* - `aria-label`: (: "등급 필터")
* - `aria-labelledby`: ID
* - `title`: ( )
*
* :
* <Select aria-label="상태 필터" value={v} onChange={...}>...</Select>
* <Select title="등급 필터" value={v} onChange={...}>...</Select>
*/
type BaseSelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> & {
size?: InputSize;
state?: InputState;
};
type SelectWithAccessibleName =
| (BaseSelectProps & { 'aria-label': string })
| (BaseSelectProps & { 'aria-labelledby': string })
| (BaseSelectProps & { title: string });
export type SelectProps = SelectWithAccessibleName;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, size, state, children, ...props }, ref) => {
return (
<select
ref={ref}
className={cn(inputVariants({ size, state }), 'cursor-pointer', className)}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = 'Select';

파일 보기

@ -0,0 +1,78 @@
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@lib/utils/cn';
/**
* TabBar
*
* 3 :
* - 'underline' (): border-b (MLOps, RiskMap )
* - 'pill': pill (ChinaFishing )
* - 'segmented': segmented control
*
* :
* <TabBar>
* <TabButton active={tab === 'a'} onClick={() => setTab('a')}> A</TabButton>
* <TabButton active={tab === 'b'} onClick={() => setTab('b')}> B</TabButton>
* </TabBar>
*/
export interface TabBarProps {
variant?: 'underline' | 'pill' | 'segmented';
children: ReactNode;
className?: string;
}
export function TabBar({ variant = 'underline', children, className }: TabBarProps) {
const variantClass = {
underline: 'flex gap-0 border-b border-border',
pill: 'flex items-center gap-1',
segmented: 'flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-border w-fit',
}[variant];
return <div className={cn(variantClass, className)}>{children}</div>;
}
export interface TabButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
variant?: 'underline' | 'pill' | 'segmented';
icon?: ReactNode;
children: ReactNode;
}
/**
* TabButton . active .
*/
export function TabButton({
active = false,
variant = 'underline',
icon,
children,
className,
type = 'button',
...props
}: TabButtonProps) {
const variantClass = {
underline: cn(
'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 transition-colors',
active ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label',
),
pill: cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium transition-colors',
active
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:bg-secondary hover:text-foreground',
),
segmented: cn(
'flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors',
active
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay',
),
}[variant];
return (
<button type={type} className={cn(variantClass, className)} {...props}>
{icon}
{children}
</button>
);
}

파일 보기

@ -0,0 +1,31 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
state?: 'default' | 'error' | 'success';
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, state = 'default', ...props }, ref) => {
const stateClass = {
default: 'border-slate-600/40',
error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
}[state];
return (
<textarea
ref={ref}
className={cn(
'w-full bg-surface-overlay border rounded-md px-3 py-2 text-sm text-label placeholder:text-hint',
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30',
'transition-colors resize-y min-h-[4rem]',
'disabled:opacity-50 disabled:cursor-not-allowed',
stateClass,
className,
)}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';

파일 보기

@ -0,0 +1,125 @@
/**
* /
*
* SSOT: backend `code_master` EVENT_LEVEL (V008 ).
* `GET /api/code-master?groupCode=EVENT_LEVEL` fetch .
*
* ** Badge **: `<Badge intent={getAlertLevelIntent(level)}>` .
* `ALERT_LEVELS.classes` .
*
* 사용처: Dashboard, MonitoringDashboard, EventList, LiveMapView, VesselDetail,
* MobileService, ChinaFishing / /
*/
import type { BadgeIntent } from '@lib/theme/variants';
export type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
export interface AlertLevelMeta {
code: AlertLevel;
i18nKey: string;
fallback: { ko: string; en: string };
/** 공통 Badge 컴포넌트 intent (badgeVariants 와 동기화) */
intent: BadgeIntent;
/** Tailwind / ( )
* `<Badge intent={meta.intent}>` .
* bg/border는 .
*/
classes: {
bg: string;
text: string;
border: string;
dot: string;
/** 진한 배경 (액션 버튼 등) */
bgSolid: string;
};
/** hex 색상 (지도 마커, 차트, 인라인 style 용) */
hex: string;
order: number;
}
export const ALERT_LEVELS: Record<AlertLevel, AlertLevelMeta> = {
CRITICAL: {
code: 'CRITICAL',
i18nKey: 'alert.critical',
fallback: { ko: '심각', en: 'Critical' },
intent: 'critical',
classes: {
bg: 'bg-red-500/10',
text: 'text-red-300',
border: 'border-red-500/30',
dot: 'bg-red-500',
bgSolid: 'bg-red-500',
},
hex: '#ef4444',
order: 1,
},
HIGH: {
code: 'HIGH',
i18nKey: 'alert.high',
fallback: { ko: '높음', en: 'High' },
intent: 'high',
classes: {
bg: 'bg-orange-500/10',
text: 'text-orange-300',
border: 'border-orange-500/30',
dot: 'bg-orange-500',
bgSolid: 'bg-orange-500',
},
hex: '#f97316',
order: 2,
},
MEDIUM: {
code: 'MEDIUM',
i18nKey: 'alert.medium',
fallback: { ko: '보통', en: 'Medium' },
intent: 'warning',
classes: {
bg: 'bg-yellow-500/10',
text: 'text-yellow-300',
border: 'border-yellow-500/30',
dot: 'bg-yellow-500',
bgSolid: 'bg-yellow-500',
},
hex: '#eab308',
order: 3,
},
LOW: {
code: 'LOW',
i18nKey: 'alert.low',
fallback: { ko: '낮음', en: 'Low' },
intent: 'info',
classes: {
bg: 'bg-blue-500/10',
text: 'text-blue-300',
border: 'border-blue-500/30',
dot: 'bg-blue-500',
bgSolid: 'bg-blue-500',
},
hex: '#3b82f6',
order: 4,
},
};
export function getAlertLevelMeta(level: string): AlertLevelMeta | undefined {
return ALERT_LEVELS[level as AlertLevel];
}
export function getAlertLevelLabel(
level: string,
t: (key: string, opts?: { defaultValue?: string }) => string,
lang: 'ko' | 'en' = 'ko',
): string {
const meta = getAlertLevelMeta(level);
if (!meta) return level;
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
}
export function getAlertLevelHex(level: string): string {
return getAlertLevelMeta(level)?.hex ?? '#6b7280';
}
/** 공통 Badge intent 매핑 */
export function getAlertLevelIntent(level: string): BadgeIntent {
return getAlertLevelMeta(level)?.intent ?? 'muted';
}

파일 보기

@ -0,0 +1,267 @@
/**
* (Single Source of Truth)
*
* ** **.
* :
* - (: '심각' '매우 심각') +
* - intent/
* - ( )
*
* `items: Record<Code, Meta>` (Meta는 ).
* heterogeneous CatalogEntry의 items는 Record<string, AnyMeta> .
*/
import { ALERT_LEVELS } from './alertLevels';
import { VIOLATION_TYPES } from './violationTypes';
import { EVENT_STATUSES } from './eventStatuses';
import { ENFORCEMENT_ACTIONS } from './enforcementActions';
import { ENFORCEMENT_RESULTS } from './enforcementResults';
import { PATROL_STATUSES } from './patrolStatuses';
import { ENGINE_SEVERITIES } from './engineSeverities';
import { DEVICE_STATUSES } from './deviceStatuses';
import {
PARENT_RESOLUTION_STATUSES,
LABEL_SESSION_STATUSES,
} from './parentResolutionStatuses';
import {
MODEL_STATUSES,
QUALITY_GATE_STATUSES,
EXPERIMENT_STATUSES,
} from './modelDeploymentStatuses';
import { GEAR_GROUP_TYPES } from './gearGroupTypes';
import { DARK_VESSEL_PATTERNS } from './darkVesselPatterns';
import { USER_ACCOUNT_STATUSES } from './userAccountStatuses';
import { LOGIN_RESULTS } from './loginResultStatuses';
import { PERMIT_STATUSES, GEAR_JUDGMENTS } from './permissionStatuses';
import {
VESSEL_SURVEILLANCE_STATUSES,
VESSEL_RISK_RINGS,
} from './vesselAnalysisStatuses';
import { CONNECTION_STATUSES } from './connectionStatuses';
import { TRAINING_ZONE_TYPES } from './trainingZoneTypes';
/**
* UI
*/
export interface CatalogEntry {
/** 안정적 ID (쇼케이스 추적 ID의 슬러그 부분) */
id: string;
/** 쇼케이스 추적 ID (TRK-CAT-*) */
showcaseId: string;
/** 쇼케이스 섹션 제목 (한글) */
titleKo: string;
/** 쇼케이스 섹션 제목 (영문) */
titleEn: string;
/** 1줄 설명 */
description: string;
/** 출처 (백엔드 enum / code_master 등) */
source?: string;
/** 카탈로그 데이터 — items의 각 meta는 { code, fallback?, intent?, classes?, ... } 구조 */
items: Record<string, unknown>;
}
/**
*
*
* :
* 1. shared/constants/ (items record + )
* 2. CatalogEntry
* 3. +
*/
export const CATALOG_REGISTRY: CatalogEntry[] = [
{
id: 'alert-level',
showcaseId: 'TRK-CAT-alert-level',
titleKo: '위험도',
titleEn: 'Alert Level',
description: 'CRITICAL / HIGH / MEDIUM / LOW — 모든 이벤트/알림 배지',
source: 'backend code_master EVENT_LEVEL',
items: ALERT_LEVELS,
},
{
id: 'violation-type',
showcaseId: 'TRK-CAT-violation-type',
titleKo: '위반 유형',
titleEn: 'Violation Type',
description: '중국불법조업 / 환적의심 / EEZ침범 등',
source: 'backend ViolationType enum',
items: VIOLATION_TYPES,
},
{
id: 'event-status',
showcaseId: 'TRK-CAT-event-status',
titleKo: '이벤트 상태',
titleEn: 'Event Status',
description: 'NEW / ACK / IN_PROGRESS / RESOLVED / FALSE_POSITIVE',
source: 'backend code_master EVENT_STATUS',
items: EVENT_STATUSES,
},
{
id: 'enforcement-action',
showcaseId: 'TRK-CAT-enforcement-action',
titleKo: '단속 조치',
titleEn: 'Enforcement Action',
description: 'CAPTURE / INSPECT / WARN / DISPERSE / TRACK / EVIDENCE',
source: 'backend code_master ENFORCEMENT_ACTION',
items: ENFORCEMENT_ACTIONS,
},
{
id: 'enforcement-result',
showcaseId: 'TRK-CAT-enforcement-result',
titleKo: '단속 결과',
titleEn: 'Enforcement Result',
description: 'PUNISHED / REFERRED / WARNED / RELEASED / FALSE_POSITIVE',
source: 'backend code_master ENFORCEMENT_RESULT',
items: ENFORCEMENT_RESULTS,
},
{
id: 'patrol-status',
showcaseId: 'TRK-CAT-patrol-status',
titleKo: '함정 상태',
titleEn: 'Patrol Status',
description: '출동 / 순찰 / 복귀 / 정박 / 정비 / 대기',
source: 'backend code_master PATROL_STATUS',
items: PATROL_STATUSES,
},
{
id: 'engine-severity',
showcaseId: 'TRK-CAT-engine-severity',
titleKo: '엔진 심각도',
titleEn: 'Engine Severity',
description: 'AI 모델/분석엔진 오류 심각도',
items: ENGINE_SEVERITIES,
},
{
id: 'device-status',
showcaseId: 'TRK-CAT-device-status',
titleKo: '함정 Agent 장치 상태',
titleEn: 'Device Status',
description: 'ONLINE / OFFLINE / SYNCING / NOT_DEPLOYED',
items: DEVICE_STATUSES,
},
{
id: 'parent-resolution',
showcaseId: 'TRK-CAT-parent-resolution',
titleKo: '모선 확정 상태',
titleEn: 'Parent Resolution Status',
description: 'PENDING / CONFIRMED / REJECTED / REVIEWING',
items: PARENT_RESOLUTION_STATUSES,
},
{
id: 'label-session',
showcaseId: 'TRK-CAT-label-session',
titleKo: '라벨 세션',
titleEn: 'Label Session Status',
description: '모선 학습 세션 상태',
items: LABEL_SESSION_STATUSES,
},
{
id: 'model-status',
showcaseId: 'TRK-CAT-model-status',
titleKo: 'AI 모델 상태',
titleEn: 'Model Status',
description: 'DEV / STAGING / CANARY / PROD / ARCHIVED',
items: MODEL_STATUSES,
},
{
id: 'quality-gate',
showcaseId: 'TRK-CAT-quality-gate',
titleKo: '품질 게이트',
titleEn: 'Quality Gate Status',
description: '모델 배포 전 품질 검증',
items: QUALITY_GATE_STATUSES,
},
{
id: 'experiment',
showcaseId: 'TRK-CAT-experiment',
titleKo: 'ML 실험',
titleEn: 'Experiment Status',
description: 'MLOps 실험 상태',
items: EXPERIMENT_STATUSES,
},
{
id: 'gear-group',
showcaseId: 'TRK-CAT-gear-group',
titleKo: '어구 그룹 유형',
titleEn: 'Gear Group Type',
description: 'FLEET / GEAR_IN_ZONE / GEAR_OUT_ZONE',
items: GEAR_GROUP_TYPES,
},
{
id: 'dark-vessel',
showcaseId: 'TRK-CAT-dark-vessel',
titleKo: '다크베셀 패턴',
titleEn: 'Dark Vessel Pattern',
description: 'AIS 끊김/스푸핑 패턴 5종',
items: DARK_VESSEL_PATTERNS,
},
{
id: 'user-account',
showcaseId: 'TRK-CAT-user-account',
titleKo: '사용자 계정 상태',
titleEn: 'User Account Status',
description: 'ACTIVE / LOCKED / INACTIVE / PENDING',
items: USER_ACCOUNT_STATUSES,
},
{
id: 'login-result',
showcaseId: 'TRK-CAT-login-result',
titleKo: '로그인 결과',
titleEn: 'Login Result',
description: 'SUCCESS / FAILED / LOCKED',
items: LOGIN_RESULTS,
},
{
id: 'permit-status',
showcaseId: 'TRK-CAT-permit-status',
titleKo: '허가 상태',
titleEn: 'Permit Status',
description: '선박 허가 유효 / 만료 / 정지',
items: PERMIT_STATUSES,
},
{
id: 'gear-judgment',
showcaseId: 'TRK-CAT-gear-judgment',
titleKo: '어구 판정',
titleEn: 'Gear Judgment',
description: '합법 / 의심 / 불법',
items: GEAR_JUDGMENTS,
},
{
id: 'vessel-surveillance',
showcaseId: 'TRK-CAT-vessel-surveillance',
titleKo: '선박 감시 상태',
titleEn: 'Vessel Surveillance Status',
description: '관심선박 추적 상태',
items: VESSEL_SURVEILLANCE_STATUSES,
},
{
id: 'vessel-risk-ring',
showcaseId: 'TRK-CAT-vessel-risk-ring',
titleKo: '선박 위험 링',
titleEn: 'Vessel Risk Ring',
description: '지도 마커 위험도 링',
items: VESSEL_RISK_RINGS,
},
{
id: 'connection',
showcaseId: 'TRK-CAT-connection',
titleKo: '연결 상태',
titleEn: 'Connection Status',
description: 'OK / WARNING / ERROR',
items: CONNECTION_STATUSES,
},
{
id: 'training-zone',
showcaseId: 'TRK-CAT-training-zone',
titleKo: '훈련 수역',
titleEn: 'Training Zone Type',
description: 'NAVY / AIRFORCE / ARMY / ADD / KCG',
items: TRAINING_ZONE_TYPES,
},
];
/** ID로 특정 카탈로그 조회 */
export function getCatalogById(id: string): CatalogEntry | undefined {
return CATALOG_REGISTRY.find((c) => c.id === id);
}

파일 보기

@ -0,0 +1,52 @@
/**
* /
*
* 사용처: DataHub signal heatmap
*/
import type { BadgeIntent } from '@lib/theme/variants';
export type ConnectionStatus = 'OK' | 'WARNING' | 'ERROR';
export const CONNECTION_STATUSES: Record<ConnectionStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
OK: {
i18nKey: 'connectionStatus.OK',
fallback: { ko: '정상', en: 'OK' },
intent: 'success',
hex: '#22c55e',
},
WARNING: {
i18nKey: 'connectionStatus.WARNING',
fallback: { ko: '경고', en: 'Warning' },
intent: 'warning',
hex: '#eab308',
},
ERROR: {
i18nKey: 'connectionStatus.ERROR',
fallback: { ko: '오류', en: 'Error' },
intent: 'critical',
hex: '#ef4444',
},
};
/** 소문자 호환 (DataHub 'ok' | 'warn' | 'error') */
const LEGACY: Record<string, ConnectionStatus> = {
ok: 'OK',
warn: 'WARNING',
warning: 'WARNING',
error: 'ERROR',
};
export function getConnectionStatusMeta(s: string) {
if (CONNECTION_STATUSES[s as ConnectionStatus]) return CONNECTION_STATUSES[s as ConnectionStatus];
const code = LEGACY[s.toLowerCase()];
return code ? CONNECTION_STATUSES[code] : CONNECTION_STATUSES.OK;
}
export function getConnectionStatusHex(s: string): string {
return getConnectionStatusMeta(s).hex;
}
export function getConnectionStatusIntent(s: string): BadgeIntent {
return getConnectionStatusMeta(s).intent;
}

Some files were not shown because too many files have changed in this diff Show More