chore: 팀 워크플로우 기반 초기 프로젝트 구성
KCG AI 기반 불법조업 탐지·차단 플랫폼 프론트엔드. React 19 + TypeScript 5.9 + Vite 8 + MapLibre + deck.gl + Zustand + Tailwind CSS. SFR 20개 전체 UI 구현 완료, 백엔드 연동 대기. - npm + Nexus 프록시 레지스트리 설정 - 팀 워크플로우 v1.6.1 부트스트랩 파일 배치 - .githooks (commit-msg, post-checkout) - package.json name: kcg-ai-monitoring v0.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
커밋
c0ce01eaf6
50
.claude/settings.json
Normal file
50
.claude/settings.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git log *)",
|
||||||
|
"Bash(git branch *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
|
"Bash(git config *)",
|
||||||
|
"Bash(git rev-parse *)",
|
||||||
|
"Bash(git show *)",
|
||||||
|
"Bash(git tag *)",
|
||||||
|
"Bash(curl -s *)",
|
||||||
|
"Bash(fnm *)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git push -f *)",
|
||||||
|
"Bash(git push origin --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git checkout -- .)",
|
||||||
|
"Bash(git restore .)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf .git*)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Bash(rm -rf node_modules)",
|
||||||
|
"Read(./**/.env)",
|
||||||
|
"Read(./**/.env.*)",
|
||||||
|
"Read(./**/secrets/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
295
.claude/skills/init-project/SKILL.md
Normal file
295
.claude/skills/init-project/SKILL.md
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
---
|
||||||
|
name: init-project
|
||||||
|
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
|
||||||
|
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
|
||||||
|
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
|
||||||
|
|
||||||
|
## 프로젝트 타입 자동 감지
|
||||||
|
|
||||||
|
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
|
||||||
|
1. `pom.xml` 존재 → **java-maven**
|
||||||
|
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
|
||||||
|
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
|
||||||
|
4. 감지 실패 → 사용자에게 타입 선택 요청
|
||||||
|
|
||||||
|
## 수행 단계
|
||||||
|
|
||||||
|
### 1. 프로젝트 분석
|
||||||
|
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
|
||||||
|
- 사용 중인 프레임워크, 라이브러리 감지
|
||||||
|
- 기존 `.claude/` 디렉토리 존재 여부 확인
|
||||||
|
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
|
||||||
|
|
||||||
|
### 2. CLAUDE.md 생성
|
||||||
|
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
|
||||||
|
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
|
||||||
|
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
|
||||||
|
- 테스트 실행 명령어
|
||||||
|
- lint 실행 명령어 (감지된 도구 기반)
|
||||||
|
- 프로젝트 디렉토리 구조 요약
|
||||||
|
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. .claude/ 디렉토리 구성
|
||||||
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
|
||||||
|
|
||||||
|
⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다.
|
||||||
|
|
||||||
|
### 3.5. Gitea 토큰 설정
|
||||||
|
|
||||||
|
**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json`의 `env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요.
|
||||||
|
|
||||||
|
**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 현재 GITEA_TOKEN 설정 여부 확인
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "GITEA_TOKEN 미설정"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시:
|
||||||
|
|
||||||
|
**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?"
|
||||||
|
- 옵션 1: 토큰 생성 안내 보기 (추천)
|
||||||
|
- 옵션 2: 이미 있음 (토큰 입력)
|
||||||
|
- 옵션 3: 나중에 하기
|
||||||
|
|
||||||
|
**토큰 생성 안내 선택 시**, 다음 내용을 표시:
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 Gitea 토큰 생성 방법:
|
||||||
|
|
||||||
|
1. 브라우저에서 접속:
|
||||||
|
https://gitea.gc-si.dev/user/settings/applications
|
||||||
|
|
||||||
|
2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭
|
||||||
|
|
||||||
|
3. 입력:
|
||||||
|
- Token Name: "claude-code" (자유롭게 지정)
|
||||||
|
- Repository and Organization Access: ✅ All (public, private, and limited)
|
||||||
|
|
||||||
|
4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지):
|
||||||
|
|
||||||
|
┌─────────────────┬──────────────────┬──────────────────────────────┐
|
||||||
|
│ 항목 │ 권한 │ 용도 │
|
||||||
|
├─────────────────┼──────────────────┼──────────────────────────────┤
|
||||||
|
│ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │
|
||||||
|
│ organization │ Read │ gc 조직 리포 접근 │
|
||||||
|
│ repository │ Read and Write │ /push, /mr, /release API 호출 │
|
||||||
|
│ user │ Read │ API 사용자 인증 확인 │
|
||||||
|
└─────────────────┴──────────────────┴──────────────────────────────┘
|
||||||
|
|
||||||
|
5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요"
|
||||||
|
- 옵션 1: 토큰 입력 (Other로 입력)
|
||||||
|
- 옵션 2: 나중에 하기
|
||||||
|
|
||||||
|
**토큰 입력 시**:
|
||||||
|
|
||||||
|
1. Gitea API로 유효성 검증:
|
||||||
|
```bash
|
||||||
|
curl -sf "https://gitea.gc-si.dev/api/v1/user" \
|
||||||
|
-H "Authorization: token <입력된 토큰>"
|
||||||
|
```
|
||||||
|
- 성공: `✅ <login> (<full_name>) 인증 확인` 출력
|
||||||
|
- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
|
||||||
|
|
||||||
|
2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"GITEA_TOKEN": "<입력된 토큰>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신.
|
||||||
|
|
||||||
|
**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행:
|
||||||
|
```
|
||||||
|
⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다.
|
||||||
|
나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요:
|
||||||
|
{ "env": { "GITEA_TOKEN": "your-token-here" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Hook 스크립트 설정
|
||||||
|
|
||||||
|
⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다.
|
||||||
|
여기서는 `settings.json`에 hooks 섹션만 설정한다.
|
||||||
|
|
||||||
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Git Hooks 설정
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
`.githooks/` 디렉토리에 실행 권한 부여:
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
**pre-commit 훅 검증**: `.githooks/pre-commit`을 실행하여 빌드 검증이 정상 동작하는지 확인.
|
||||||
|
에러 발생 시 (예: 모노레포가 아닌 특수 구조, 빌드 명령 불일치 등):
|
||||||
|
1. 프로젝트에 맞게 `.githooks/pre-commit`을 커스텀 수정
|
||||||
|
2. `.claude/workflow-version.json`에 `"custom_pre_commit": true` 추가
|
||||||
|
3. 이후 `/sync-team-workflow` 실행 시 pre-commit은 덮어쓰지 않고 보존됨
|
||||||
|
(`commit-msg`, `post-checkout`은 항상 팀 표준으로 동기화)
|
||||||
|
|
||||||
|
### 6. 프로젝트 타입별 추가 설정
|
||||||
|
|
||||||
|
#### java-maven
|
||||||
|
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
|
||||||
|
- `.mvn/settings.xml` Nexus 미러 설정 확인
|
||||||
|
- `mvn compile` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### java-gradle
|
||||||
|
- `.sdkmanrc` 생성
|
||||||
|
- `gradle.properties.example` Nexus 설정 확인
|
||||||
|
- `./gradlew compileJava` 빌드 성공 확인
|
||||||
|
|
||||||
|
#### react-ts
|
||||||
|
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
|
||||||
|
- `.npmrc` Nexus 레지스트리 설정 확인
|
||||||
|
- `npm install && npm run build` 성공 확인
|
||||||
|
|
||||||
|
### 7. .gitignore 확인
|
||||||
|
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
|
||||||
|
```
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/CLAUDE.local.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
|
```
|
||||||
|
|
||||||
|
**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음):
|
||||||
|
```
|
||||||
|
# Team workflow (managed by /sync-team-workflow)
|
||||||
|
.claude/rules/
|
||||||
|
.claude/agents/
|
||||||
|
.claude/skills/push/
|
||||||
|
.claude/skills/mr/
|
||||||
|
.claude/skills/create-mr/
|
||||||
|
.claude/skills/release/
|
||||||
|
.claude/skills/version/
|
||||||
|
.claude/skills/fix-issue/
|
||||||
|
.claude/scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Git exclude 설정
|
||||||
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
|
||||||
|
# Claude Code 워크플로우 (로컬 전용)
|
||||||
|
docs/CHANGELOG.md
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Memory 초기화
|
||||||
|
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
|
||||||
|
|
||||||
|
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
|
||||||
|
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
|
||||||
|
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
|
||||||
|
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
|
||||||
|
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
|
||||||
|
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
|
||||||
|
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
|
||||||
|
|
||||||
|
### 10. Lint 도구 확인
|
||||||
|
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
|
||||||
|
- Java: checkstyle, spotless 등 설정 확인
|
||||||
|
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
|
||||||
|
|
||||||
|
### 11. workflow-version.json 생성
|
||||||
|
Gitea API로 최신 팀 워크플로우 버전을 조회:
|
||||||
|
```bash
|
||||||
|
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
|
```
|
||||||
|
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 파일 생성:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "<조회된 버전>",
|
||||||
|
"applied_date": "<현재날짜>",
|
||||||
|
"project_type": "<감지된타입>",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 팀 워크플로우 최신화
|
||||||
|
|
||||||
|
`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다.
|
||||||
|
|
||||||
|
이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다.
|
||||||
|
(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음)
|
||||||
|
|
||||||
|
### 13. 검증 및 요약
|
||||||
|
- 생성/수정된 파일 목록 출력
|
||||||
|
- `git config core.hooksPath` 확인
|
||||||
|
- 빌드 명령 실행 가능 확인
|
||||||
|
- Hook 스크립트 실행 권한 확인
|
||||||
|
- 다음 단계 안내:
|
||||||
|
- 개발 시작, 첫 커밋 방법
|
||||||
|
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`
|
||||||
165
.claude/skills/sync-team-workflow/SKILL.md
Normal file
165
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
name: sync-team-workflow
|
||||||
|
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||||
|
---
|
||||||
|
|
||||||
|
팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다.
|
||||||
|
호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음).
|
||||||
|
|
||||||
|
## 수행 절차
|
||||||
|
|
||||||
|
### 1. 사전 조건 확인
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 존재 확인:
|
||||||
|
- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료
|
||||||
|
|
||||||
|
설정 읽기:
|
||||||
|
```bash
|
||||||
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
|
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "")
|
||||||
|
```
|
||||||
|
|
||||||
|
프로젝트 타입이 비어있으면 자동 감지:
|
||||||
|
1. `pom.xml` → java-maven
|
||||||
|
2. `build.gradle` / `build.gradle.kts` → java-gradle
|
||||||
|
3. `package.json` + `tsconfig.json` → react-ts
|
||||||
|
4. 감지 실패 → 사용자에게 선택 요청
|
||||||
|
|
||||||
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
|
⚠️ Gitea raw 파일은 반드시 **web raw URL** 사용:
|
||||||
|
```bash
|
||||||
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
|
# 타입별 파일: ${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/<파일경로>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 디렉토리 준비
|
||||||
|
|
||||||
|
필요한 디렉토리가 없으면 생성:
|
||||||
|
```bash
|
||||||
|
mkdir -p .claude/rules .claude/agents .claude/scripts
|
||||||
|
mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr
|
||||||
|
mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue
|
||||||
|
mkdir -p .githooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 서버 파일 다운로드 + 적용
|
||||||
|
|
||||||
|
각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장.
|
||||||
|
다운로드 실패한 파일은 경고 출력 후 건너뜀.
|
||||||
|
|
||||||
|
#### 3-1. template-common 파일 (덮어쓰기)
|
||||||
|
|
||||||
|
**규칙 파일**:
|
||||||
|
```
|
||||||
|
.claude/rules/team-policy.md
|
||||||
|
.claude/rules/git-workflow.md
|
||||||
|
.claude/rules/release-notes-guide.md
|
||||||
|
.claude/rules/subagent-policy.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**에이전트 파일**:
|
||||||
|
```
|
||||||
|
.claude/agents/explorer.md
|
||||||
|
.claude/agents/implementer.md
|
||||||
|
.claude/agents/reviewer.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**스킬 파일 (6종)**:
|
||||||
|
```
|
||||||
|
.claude/skills/push/SKILL.md
|
||||||
|
.claude/skills/mr/SKILL.md
|
||||||
|
.claude/skills/create-mr/SKILL.md
|
||||||
|
.claude/skills/release/SKILL.md
|
||||||
|
.claude/skills/version/SKILL.md
|
||||||
|
.claude/skills/fix-issue/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hook 스크립트**:
|
||||||
|
```
|
||||||
|
.claude/scripts/on-pre-compact.sh
|
||||||
|
.claude/scripts/on-post-compact.sh
|
||||||
|
.claude/scripts/on-commit.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Git Hooks** (commit-msg, post-checkout은 항상 교체):
|
||||||
|
```
|
||||||
|
.githooks/commit-msg
|
||||||
|
.githooks/post-checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
다운로드 예시:
|
||||||
|
```bash
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-2. template-{type} 파일 (타입별 덮어쓰기)
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/rules/code-style.md
|
||||||
|
.claude/rules/naming.md
|
||||||
|
.claude/rules/testing.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**pre-commit hook**:
|
||||||
|
`.claude/workflow-version.json`의 `custom_pre_commit` 플래그 확인:
|
||||||
|
- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
|
||||||
|
- 플래그 없거나 false → `.githooks/pre-commit` 교체
|
||||||
|
|
||||||
|
다운로드 예시:
|
||||||
|
```bash
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-3. 실행 권한 부여
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/* 2>/dev/null
|
||||||
|
chmod +x .claude/scripts/*.sh 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. settings.json 부분 머지
|
||||||
|
|
||||||
|
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
|
||||||
|
```bash
|
||||||
|
SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json")
|
||||||
|
```
|
||||||
|
|
||||||
|
다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신:
|
||||||
|
- `env`: 서버 최신으로 교체
|
||||||
|
- `deny` 목록: 서버 최신으로 교체
|
||||||
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합
|
||||||
|
- `hooks`: 서버 최신으로 교체
|
||||||
|
|
||||||
|
### 5. workflow-version.json 갱신
|
||||||
|
|
||||||
|
서버의 최신 `workflow-version.json` 조회:
|
||||||
|
```bash
|
||||||
|
SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
||||||
|
SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude/workflow-version.json` 업데이트:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied_global_version": "<서버 version>",
|
||||||
|
"applied_date": "<현재날짜>",
|
||||||
|
"project_type": "<프로젝트타입>",
|
||||||
|
"gitea_url": "<GITEA_URL>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
기존 필드(`custom_pre_commit` 등)는 보존.
|
||||||
|
|
||||||
|
### 6. 변경 보고
|
||||||
|
|
||||||
|
- 다운로드/갱신된 파일 목록 출력
|
||||||
|
- 서버 `workflow-version.json`의 `changes` 중 최신 항목 표시
|
||||||
|
- 결과 형태:
|
||||||
|
```
|
||||||
|
✅ 팀 워크플로우 동기화 완료
|
||||||
|
버전: v1.6.0
|
||||||
|
갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3)
|
||||||
|
settings.json: 부분 갱신 (env, deny, hooks)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필요 환경변수
|
||||||
|
|
||||||
|
없음 (Gitea raw URL은 인증 불필요)
|
||||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"applied_global_version": "1.6.1",
|
||||||
|
"applied_date": "2026-04-06",
|
||||||
|
"project_type": "react-ts",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
60
.githooks/commit-msg
Executable file
60
.githooks/commit-msg
Executable file
@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# commit-msg hook
|
||||||
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE="$1"
|
||||||
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
# Merge 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Revert 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conventional Commits 정규식
|
||||||
|
# type(scope): subject
|
||||||
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
|
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
||||||
|
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
||||||
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
||||||
|
|
||||||
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " 올바른 형식: type(scope): subject"
|
||||||
|
echo ""
|
||||||
|
echo " type (필수):"
|
||||||
|
echo " feat — 새로운 기능"
|
||||||
|
echo " fix — 버그 수정"
|
||||||
|
echo " docs — 문서 변경"
|
||||||
|
echo " style — 코드 포맷팅"
|
||||||
|
echo " refactor — 리팩토링"
|
||||||
|
echo " test — 테스트"
|
||||||
|
echo " chore — 빌드/설정 변경"
|
||||||
|
echo " ci — CI/CD 변경"
|
||||||
|
echo " perf — 성능 개선"
|
||||||
|
echo ""
|
||||||
|
echo " scope (선택): 한/영 모두 가능"
|
||||||
|
echo " subject (필수): 1~72자, 한/영 모두 가능"
|
||||||
|
echo ""
|
||||||
|
echo " 예시:"
|
||||||
|
echo " feat(auth): JWT 기반 로그인 구현"
|
||||||
|
echo " fix(배치): 야간 배치 타임아웃 수정"
|
||||||
|
echo " docs: README 업데이트"
|
||||||
|
echo " chore: Gradle 의존성 업데이트"
|
||||||
|
echo ""
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# post-checkout hook
|
||||||
|
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
|
||||||
|
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
|
||||||
|
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
|
||||||
|
BRANCH_FLAG="$3"
|
||||||
|
|
||||||
|
# 파일 체크아웃은 건너뜀
|
||||||
|
if [ "$BRANCH_FLAG" = "0" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .githooks 디렉토리 존재 확인
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
if [ -d "${REPO_ROOT}/.githooks" ]; then
|
||||||
|
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# === Build ===
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# === Dependencies ===
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# === IDE ===
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# === OS ===
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# === Environment ===
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# === Debug ===
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# === Test ===
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# === Cache ===
|
||||||
|
.eslintcache
|
||||||
|
.prettiercache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# === Code Review Graph (로컬 전용) ===
|
||||||
|
.code-review-graph/
|
||||||
|
|
||||||
|
# === 대용량/참고 문서 ===
|
||||||
|
*.hwpx
|
||||||
|
|
||||||
|
# === Claude Code ===
|
||||||
|
!.claude/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/CLAUDE.local.md
|
||||||
|
|
||||||
|
# === Team workflow (managed by /sync-team-workflow) ===
|
||||||
|
.claude/rules/
|
||||||
|
.claude/agents/
|
||||||
|
.claude/skills/push/
|
||||||
|
.claude/skills/mr/
|
||||||
|
.claude/skills/create-mr/
|
||||||
|
.claude/skills/release/
|
||||||
|
.claude/skills/version/
|
||||||
|
.claude/skills/fix-issue/
|
||||||
|
.claude/scripts/
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
20
|
||||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Nexus npm 프록시 레지스트리
|
||||||
|
registry=https://nexus.gc-si.dev/repository/npm-public/
|
||||||
|
|
||||||
|
# Nexus 인증
|
||||||
|
//nexus.gc-si.dev/repository/npm-public/:_auth=YWRtaW46R2NzYyE4OTMy
|
||||||
3
ATTRIBUTIONS.md
Normal file
3
ATTRIBUTIONS.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
이 Figma Make 파일에는 [shadcn/ui](https://ui.shadcn.com/)의 컴포넌트가 포함되어 있으며, [MIT 라이선스](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md)에 따라 사용됩니다.
|
||||||
|
|
||||||
|
이 Figma Make 파일에는 [Unsplash](https://unsplash.com)의 사진이 포함되어 있으며, 이는 [라이선스](https://unsplash.com/license)에 따라 사용되었습니다.
|
||||||
79
README.md
Normal file
79
README.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# AI 기반 불법조업 탐지·차단 플랫폼
|
||||||
|
|
||||||
|
해양경찰청 AIS 신호 기반 불법 조업 선박 탐지, 단속 의사결정 지원 플랫폼.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
| 분류 | 기술 | 버전 |
|
||||||
|
|---|---|---|
|
||||||
|
| 프레임워크 | React + TypeScript | 19.2 / 5.9 |
|
||||||
|
| 번들러 | Vite (Rolldown) | 8.0 |
|
||||||
|
| 지도 | MapLibre GL + deck.gl | 5.22 / 9.2 |
|
||||||
|
| 차트 | ECharts | 6.0 |
|
||||||
|
| 상태관리 | Zustand | 5.0 |
|
||||||
|
| 스타일 | Tailwind CSS + CVA | 4.2 / 0.7 |
|
||||||
|
| 다국어 | react-i18next | ko / en |
|
||||||
|
| 린트 | ESLint (Flat Config) | 10 |
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # 개발 서버
|
||||||
|
npm run build # 프로덕션 빌드 (~480ms)
|
||||||
|
npm run lint # ESLint 검사
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/charts/ ECharts 공통 (BaseChart + 프리셋)
|
||||||
|
├── lib/map/ MapLibre + deck.gl (BaseMap + 레이어 + hooks)
|
||||||
|
├── lib/i18n/ 다국어 (10 네임스페이스, ko/en)
|
||||||
|
├── lib/theme/ 디자인 토큰 + CVA 변형
|
||||||
|
├── data/mock/ 공유 더미 데이터 (7 모듈)
|
||||||
|
├── stores/ Zustand 스토어 (8개)
|
||||||
|
├── services/ API 서비스 샘플
|
||||||
|
├── shared/ 공유 UI 컴포넌트
|
||||||
|
├── features/ 도메인별 페이지 (13그룹, 31페이지)
|
||||||
|
├── app/ 라우터, 인증, 레이아웃
|
||||||
|
└── styles/ CSS (Dark/Light 테마)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 문서
|
||||||
|
|
||||||
|
| 문서 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| [docs/architecture.md](docs/architecture.md) | 아키텍처 현황 (기술스택, 구조, 렌더링 최적화, 테마) |
|
||||||
|
| [docs/sfr-user-guide.md](docs/sfr-user-guide.md) | SFR 사용자 가이드 (메뉴별 기능 설명, 구현/미구현 현황) |
|
||||||
|
| [docs/sfr-traceability.md](docs/sfr-traceability.md) | SFR 요구사항 추적 매트릭스 (개발자용, 소스 경로 포함) |
|
||||||
|
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
|
||||||
|
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
|
||||||
|
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
|
||||||
|
|
||||||
|
## SFR 요구사항 대응 현황
|
||||||
|
|
||||||
|
20개 SFR 전체 UI 구현 완료. 백엔드 연동 대기 중.
|
||||||
|
|
||||||
|
| SFR | 기능 | 화면 | 상태 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SFR-01 | 로그인·권한 관리 | `/login`, `/access-control` | UI 완료 |
|
||||||
|
| SFR-02 | 환경설정·공지·공통 | `/system-config`, `/notices` | UI 완료 |
|
||||||
|
| SFR-03 | 통합 데이터 허브 | `/data-hub` | UI 완료 |
|
||||||
|
| SFR-04 | AI 예측모델 관리 | `/ai-model` | UI 완료 |
|
||||||
|
| SFR-05 | 위험도 지도 | `/risk-map` | UI 완료 |
|
||||||
|
| SFR-06 | 단속 계획·경보 | `/enforcement-plan` | UI 완료 |
|
||||||
|
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
|
||||||
|
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
|
||||||
|
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
|
||||||
|
| SFR-10 | 어구 탐지 | `/gear-detection` | UI 완료 |
|
||||||
|
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
|
||||||
|
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
|
||||||
|
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |
|
||||||
|
| SFR-14 | 외부 서비스 연계 | `/external-service` | UI 완료 |
|
||||||
|
| SFR-15 | 모바일 서비스 | `/mobile-service` | UI 완료 |
|
||||||
|
| SFR-16 | 함정 Agent | `/ship-agent` | UI 완료 |
|
||||||
|
| SFR-17 | AI 알림 발송 | `/ai-alert` | UI 완료 |
|
||||||
|
| SFR-18/19 | MLOps / LLMOps | `/mlops` | UI 완료 |
|
||||||
|
| SFR-20 | AI Q&A 지원 | `/ai-assistant` | UI 완료 |
|
||||||
370
docs/architecture.md
Normal file
370
docs/architecture.md
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
# KCG AI Monitoring - 아키텍처 문서
|
||||||
|
|
||||||
|
> AI 기반 불법조업 탐지 차단 플랫폼 프론트엔드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술스택
|
||||||
|
|
||||||
|
| 분류 | 라이브러리 | 버전 | 역할 |
|
||||||
|
|------|-----------|------|------|
|
||||||
|
| UI 프레임워크 | React | 19.2.4 | 함수형 컴포넌트 + Hooks 기반 SPA |
|
||||||
|
| 언어 | TypeScript | 5.9 | 정적 타입, strict 모드 |
|
||||||
|
| 빌드 도구 | Vite | 8.0.3 | Rolldown 기반 ESM 번들링 (~480ms build), HMR |
|
||||||
|
| CSS 프레임워크 | Tailwind CSS | 4.2.2 | `@tailwindcss/vite` 플러그인 통합 |
|
||||||
|
| 지도 (베이스맵) | MapLibre GL | 5.22 | CartoDB 래스터 타일 (Dark/Light 자동 전환) |
|
||||||
|
| 지도 (벡터) | deck.gl | 9.2 | MapboxOverlay 기반 GPU 벡터 렌더링 (40만척+) |
|
||||||
|
| 차트 | ECharts | 6.0 (core) | `lib/charts` 래퍼를 통한 프리셋 차트 |
|
||||||
|
| 상태관리 | Zustand | 5.0 | 8개 독립 스토어 (vessel, patrol, event, kpi 등) |
|
||||||
|
| 라우팅 | react-router-dom | 7.12.0 | BrowserRouter, 중첩 Route |
|
||||||
|
| 다국어 | react-i18next + i18next | 17.0 + 26.0 | 10 NS, ko/en 네임스페이스 기반 |
|
||||||
|
| 스타일 변형 | class-variance-authority (CVA) | 0.7 | card/badge/statusDot variants |
|
||||||
|
| 아이콘 | lucide-react | 0.487.0 | SVG 아이콘 컴포넌트 |
|
||||||
|
| 린트 | ESLint | 10.2 | Flat Config, typescript-eslint + react-hooks + react-refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib/
|
||||||
|
│ ├── charts/ # BaseChart + 4 프리셋 (Area, Bar, Pie, Line)
|
||||||
|
│ │ ├── BaseChart.tsx # 코어 (init, resize, dispose 자동 관리, 'kcg-dark' 테마)
|
||||||
|
│ │ ├── theme.ts # ECharts 테마 등록
|
||||||
|
│ │ ├── tokens.ts # 차트 색상 토큰
|
||||||
|
│ │ └── presets/ # AreaChart, BarChart, PieChart, LineChart
|
||||||
|
│ ├── map/
|
||||||
|
│ │ ├── BaseMap.tsx # MapLibre + deck.gl (forwardRef, memo, overlay 노출)
|
||||||
|
│ │ ├── hooks/ # useMapLayers, useStoreLayerSync (RAF 기반)
|
||||||
|
│ │ ├── layers/ # markers, polyline, heatmap, zones, static (STATIC_LAYERS)
|
||||||
|
│ │ │ ├── markers.ts # createMarkerLayer (transitions + DataFilterExtension)
|
||||||
|
│ │ │ ├── static.ts # EEZ + NLL 싱글턴 (GPU 1회 업로드)
|
||||||
|
│ │ │ ├── polyline.ts # 경로/트랙 라인
|
||||||
|
│ │ │ ├── heatmap.ts # 히트맵 (위험도 시각화)
|
||||||
|
│ │ │ ├── zones.ts # 구역 원
|
||||||
|
│ │ │ └── boundaries.ts # 레거시 GeoJSON (하위 호환)
|
||||||
|
│ │ ├── controls/ # (예약) 지도 컨트롤 확장
|
||||||
|
│ │ ├── constants.ts # EEZ, NLL, 타일 URL, 기본값
|
||||||
|
│ │ └── types.ts # MapVessel, MapLayerConfig, HeatPoint
|
||||||
|
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
||||||
|
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
||||||
|
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
||||||
|
│ └── theme/ # tokens, colors, variants (CVA)
|
||||||
|
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
||||||
|
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
||||||
|
│ └── variants.ts # cardVariants, badgeVariants, statusDotVariants (CVA)
|
||||||
|
│
|
||||||
|
├── data/
|
||||||
|
│ ├── mock/ # 7 공유 mock 모듈
|
||||||
|
│ │ ├── vessels.ts # 선박 목록 (한국, 중국, 경비함)
|
||||||
|
│ │ ├── events.ts # 탐지/단속 이벤트
|
||||||
|
│ │ ├── transfers.ts # 전재(환적) 데이터
|
||||||
|
│ │ ├── patrols.ts # 순찰 경로/일정
|
||||||
|
│ │ ├── gear.ts # 어구 탐지 데이터
|
||||||
|
│ │ ├── kpi.ts # KPI/통계 데이터
|
||||||
|
│ │ └── enforcement.ts # 단속 이력 데이터
|
||||||
|
│ ├── areasCodes.json # 해역 코드 (52건)
|
||||||
|
│ ├── speciesCodes.json # 어종 코드 (578건)
|
||||||
|
│ ├── fisheryCodes.json # 어업유형 코드 (59건)
|
||||||
|
│ ├── vesselTypeCodes.json # 선박유형 코드 (186건)
|
||||||
|
│ └── commonCodes.ts # 코드 유틸리티
|
||||||
|
│
|
||||||
|
├── stores/ # 8 Zustand 스토어
|
||||||
|
│ ├── vesselStore.ts # 선박 목록, 선택, 필터
|
||||||
|
│ ├── patrolStore.ts # 순찰 경로/함정
|
||||||
|
│ ├── eventStore.ts # 탐지/경보 이벤트
|
||||||
|
│ ├── kpiStore.ts # KPI 메트릭, 추세
|
||||||
|
│ ├── transferStore.ts # 전재(환적) 데이터
|
||||||
|
│ ├── gearStore.ts # 어구 탐지
|
||||||
|
│ ├── enforcementStore.ts # 단속 이력
|
||||||
|
│ └── settingsStore.ts # theme/language + localStorage 동기화
|
||||||
|
│
|
||||||
|
├── services/ # 7 API 서비스 (현재 mock 반환)
|
||||||
|
│ ├── api.ts # fetch 래퍼 (향후 Axios 교체 예정)
|
||||||
|
│ ├── vessel.ts # getVessels, getSuspects, getVesselDetail
|
||||||
|
│ ├── event.ts # getEvents, getAlerts
|
||||||
|
│ ├── patrol.ts # getPatrolShips
|
||||||
|
│ ├── kpi.ts # getKpiMetrics, getMonthlyTrends, getViolationTypes
|
||||||
|
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
||||||
|
│ └── index.ts # 배럴 export
|
||||||
|
│
|
||||||
|
├── shared/components/ # 공유 UI 컴포넌트
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
|
||||||
|
│ │ └── badge.tsx # Badge(CVA intent/size)
|
||||||
|
│ └── common/
|
||||||
|
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
||||||
|
│ ├── Pagination.tsx # 페이지네이션
|
||||||
|
│ ├── SearchInput.tsx # 검색 입력
|
||||||
|
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
||||||
|
│ ├── FileUpload.tsx # 파일 업로드
|
||||||
|
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
||||||
|
│ ├── PrintButton.tsx # 인쇄 버튼
|
||||||
|
│ ├── SaveButton.tsx # 저장 버튼
|
||||||
|
│ └── NotificationBanner.tsx # 알림 배너
|
||||||
|
│
|
||||||
|
├── features/ # 13 도메인 그룹 (31 페이지)
|
||||||
|
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
||||||
|
│ ├── monitoring/ # 실시간 모니터링 (MonitoringDashboard)
|
||||||
|
│ ├── surveillance/ # 감시 (LiveMapView, MapControl)
|
||||||
|
│ ├── detection/ # 탐지 (DarkVessel, Gear, ChinaFishing)
|
||||||
|
│ ├── risk-assessment/ # 위험도 평가 (RiskMap, EnforcementPlan)
|
||||||
|
│ ├── patrol/ # 순찰 (PatrolRoute, FleetOptimization)
|
||||||
|
│ ├── enforcement/ # 단속 (EnforcementHistory, EventList)
|
||||||
|
│ ├── statistics/ # 통계 (Statistics, ExternalService, ReportManagement)
|
||||||
|
│ ├── ai-operations/ # AI 운영 (AIModelManagement, MLOps, AIAssistant)
|
||||||
|
│ ├── field-ops/ # 현장 대응 (MobileService, ShipAgent, AIAlert)
|
||||||
|
│ ├── admin/ # 관리 (AccessControl, SystemConfig, Notice, DataHub, AdminPanel)
|
||||||
|
│ ├── vessel/ # 선박 (VesselDetail, TransferDetection)
|
||||||
|
│ └── auth/ # 인증 (LoginPage)
|
||||||
|
│
|
||||||
|
├── app/ # 애플리케이션 셸
|
||||||
|
│ ├── App.tsx # BrowserRouter + Routes (26 보호 경로 + login)
|
||||||
|
│ ├── auth/AuthContext.tsx # 인증 컨텍스트 (ProtectedRoute)
|
||||||
|
│ └── layout/MainLayout.tsx # 사이드바 + 콘텐츠 + i18n 메뉴
|
||||||
|
│
|
||||||
|
└── styles/ # 전역 스타일
|
||||||
|
├── theme.css # CSS 커스텀 속성 (dark 기본 + .light 오버라이드)
|
||||||
|
├── tailwind.css # Tailwind 기본
|
||||||
|
└── fonts.css # 웹폰트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path Alias
|
||||||
|
|
||||||
|
| Alias | 경로 | 용도 |
|
||||||
|
|-------|------|------|
|
||||||
|
| `@/*` | `src/*` | 프로젝트 전체 절대 임포트 |
|
||||||
|
| `@lib/*` | `src/lib/*` | 공통 라이브러리 (charts, map, i18n, theme) |
|
||||||
|
| `@shared/*` | `src/shared/*` | 공유 UI 컴포넌트 |
|
||||||
|
| `@features/*` | `src/features/*` | 도메인 feature 모듈 |
|
||||||
|
| `@data/*` | `src/data/*` | 기준정보 + mock 데이터 |
|
||||||
|
| `@stores/*` | `src/stores/*` | Zustand 스토어 |
|
||||||
|
|
||||||
|
Vite `resolve.alias`와 TypeScript `compilerOptions.paths`에 동일하게 설정되어 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
|
||||||
|
### 프로덕션 (11개)
|
||||||
|
|
||||||
|
| 패키지 | 버전 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| react | ^19.2.4 | UI 렌더링 |
|
||||||
|
| react-dom | ^19.2.4 | DOM 렌더링 |
|
||||||
|
| react-router-dom | ^7.12.0 | SPA 라우팅 |
|
||||||
|
| maplibre-gl | ^5.22.0 | 래스터 베이스맵 엔진 |
|
||||||
|
| deck.gl | ^9.2.11 | GPU 벡터 렌더링 (ScatterplotLayer, PathLayer 등) |
|
||||||
|
| @deck.gl/mapbox | ^9.2.11 | MapboxOverlay (MapLibre 인터리브) |
|
||||||
|
| echarts | ^6.0.0 | 차트 라이브러리 |
|
||||||
|
| zustand | ^5.0.12 | 경량 상태관리 (8개 스토어) |
|
||||||
|
| class-variance-authority | ^0.7.1 | Tailwind 변형 관리 (CVA) |
|
||||||
|
| react-i18next | ^17.0.2 | React 다국어 바인딩 |
|
||||||
|
| i18next | ^26.0.3 | 다국어 코어 |
|
||||||
|
| lucide-react | 0.487.0 | SVG 아이콘 |
|
||||||
|
|
||||||
|
### 개발 (13개)
|
||||||
|
|
||||||
|
| 패키지 | 버전 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| vite | ^8.0.3 | 빌드/개발 서버 (Rolldown) |
|
||||||
|
| @vitejs/plugin-react | ^6.0.1 | React Fast Refresh |
|
||||||
|
| tailwindcss | ^4.2.2 | 유틸리티 CSS |
|
||||||
|
| @tailwindcss/vite | ^4.2.2 | Tailwind Vite 플러그인 |
|
||||||
|
| typescript | 5.9 | 타입 체크 |
|
||||||
|
| eslint | ^10.2.0 | 린트 (Flat Config) |
|
||||||
|
| @eslint/js | ^10.0.1 | ESLint JS 설정 |
|
||||||
|
| typescript-eslint | ^8.58.0 | TS ESLint 파서/규칙 |
|
||||||
|
| eslint-plugin-react-hooks | ^7.0.1 | Hooks 규칙 |
|
||||||
|
| eslint-plugin-react-refresh | ^0.5.2 | Fast Refresh 규칙 |
|
||||||
|
| globals | ^17.4.0 | 전역 변수 정의 |
|
||||||
|
| @types/react | ^19.2.14 | React 타입 |
|
||||||
|
| @types/react-dom | ^19.2.3 | ReactDOM 타입 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 모듈 API 요약
|
||||||
|
|
||||||
|
### lib/map — BaseMap + deck.gl 최적화
|
||||||
|
|
||||||
|
**BaseMap** — MapLibre + deck.gl 통합 컴포넌트 (`forwardRef`, `memo`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BaseMapProps {
|
||||||
|
center?: [number, number]; // [lat, lng] 기본값 [35.5, 127.0]
|
||||||
|
zoom?: number; // 기본값 7
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
height?: number | string; // 기본값 '100%'
|
||||||
|
layers?: Layer[]; // @deprecated — useMapLayers hook 사용 권장
|
||||||
|
onMapReady?: (map: Map) => void; // 지도 로드 완료 콜백
|
||||||
|
onClick?: (info: unknown) => void;
|
||||||
|
interactive?: boolean; // 기본 true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref를 통해 MapHandle 노출
|
||||||
|
interface MapHandle {
|
||||||
|
overlay: MapboxOverlay | null; // deck.gl overlay 직접 접근
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useImperativeHandle`로 overlay 외부 노출 → hook에서 직접 `setProps()` 호출
|
||||||
|
- CartoDB Dark/Light 래스터 타일 자동 전환 (`settingsStore.theme` 구독)
|
||||||
|
- `interactive=false`로 정적 지도 프리뷰 생성 가능
|
||||||
|
|
||||||
|
**useMapLayers** — RAF 배치 레이어 업데이트 (React 리렌더 0회)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useMapLayers(
|
||||||
|
handleRef: RefObject<MapHandle>, // BaseMap ref
|
||||||
|
buildLayers: () => Layer[], // 레이어 빌드 함수
|
||||||
|
deps: unknown[], // 변경 감지 (shallow 비교)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**useStoreLayerSync** — Zustand store.subscribe + RAF 기반 레이어 동기화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useStoreLayerSync<T>(
|
||||||
|
handleRef: RefObject<MapHandle>,
|
||||||
|
subscribe: (cb: (state: T) => void) => () => void,
|
||||||
|
buildLayers: (state: T) => Layer[],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**STATIC_LAYERS** — EEZ + NLL 싱글턴 (모듈 로드 시 1회 생성, GPU 재전송 없음)
|
||||||
|
|
||||||
|
**createMarkerLayer** — ScatterplotLayer 생성 (transitions 보간 + DataFilterExtension)
|
||||||
|
|
||||||
|
### lib/charts — ECharts 래퍼
|
||||||
|
|
||||||
|
**BaseChart** — ECharts 코어 래퍼 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BaseChartProps {
|
||||||
|
option: EChartsOption;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
height?: number; // 기본값 200
|
||||||
|
notMerge?: boolean;
|
||||||
|
onEvents?: Record<string, (params: unknown) => void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `echarts.init(container, 'kcg-dark')` 프로젝트 다크 테마 자동 적용
|
||||||
|
- `ResizeObserver` 자동 리사이즈, unmount 시 `dispose` 자동 정리
|
||||||
|
|
||||||
|
**프리셋 차트**
|
||||||
|
|
||||||
|
| 컴포넌트 | 주요 Props | 설명 |
|
||||||
|
|----------|-----------|------|
|
||||||
|
| `AreaChart` | `data`, `xKey`, `series`, `yAxisDomain?` | smooth line + 반투명 영역 |
|
||||||
|
| `BarChart` | `data`, `xKey`, `series`, `horizontal?`, `itemColors?` | 수직/수평 막대, 항목별 색상 |
|
||||||
|
| `PieChart` | `data: {name, value, color?}[]`, `innerRadius?`, `outerRadius?` | 파이/도넛 (기본 도넛) |
|
||||||
|
| `LineChart` | `data`, `xKey`, `series` | smooth 라인, 원형 심볼 |
|
||||||
|
|
||||||
|
### lib/theme — CVA 변형 + 디자인 토큰
|
||||||
|
|
||||||
|
| 모듈 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `variants.ts` | `cardVariants` (default/elevated/inner/transparent), `badgeVariants` (8 intent x 4 size), `statusDotVariants` (4 status x 3 size) |
|
||||||
|
| `tokens.ts` | `cssVars` (CSS 변수 참조), `resolvedColors` (ECharts/MapLibre용 하드코딩 값) |
|
||||||
|
| `colors.ts` | `riskColors` (5단계), `alertStyles`, `vesselColors`, `statusColors`, `chartSeriesColors` (8색 팔레트) |
|
||||||
|
|
||||||
|
### lib/i18n — 10 네임스페이스 다국어
|
||||||
|
|
||||||
|
- `i18next` + `react-i18next` 기반
|
||||||
|
- 기본 언어: `ko`, 폴백: `ko`, 지원: `ko` / `en`
|
||||||
|
- 10 네임스페이스: `common`, `dashboard`, `detection`, `patrol`, `enforcement`, `statistics`, `ai`, `fieldOps`, `admin`, `auth`
|
||||||
|
- 사용: `useTranslation('namespace')` → `t('key')`
|
||||||
|
- 언어 전환: `settingsStore.toggleLanguage()` + `localStorage` 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 렌더링 최적화 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
store 변경 → useStoreLayerSync → RAF → overlay.setProps() (React 리렌더 0회)
|
||||||
|
deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌더 0회)
|
||||||
|
정적 레이어 → STATIC_LAYERS 싱글턴 (GPU 1회 업로드, 모든 페이지 공유)
|
||||||
|
동적 레이어 → transitions 보간 + DataFilterExtension (GPU 필터링)
|
||||||
|
시계/타이머 → useRef + DOM textContent 직접 조작 (setState 0회)
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심 원칙: **React render cycle 완전 우회**. deck.gl overlay에 직접 `setProps()`를 호출하여 40만척+ 실시간 렌더링 시에도 React 리렌더가 발생하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테마 시스템
|
||||||
|
|
||||||
|
- **기본값**: `:root` = dark 테마, `.light` 클래스 오버라이드
|
||||||
|
- **시맨틱 CSS 변수**: `surface-raised`, `surface-overlay`, `text-heading`, `text-label`, `text-hint`, `border`
|
||||||
|
- **Tailwind 통합**: `@theme inline`으로 CSS 변수를 Tailwind 유틸리티에 매핑
|
||||||
|
- **settingsStore**: `theme` / `language` + `localStorage` 자동 동기화
|
||||||
|
- **지도 타일**: CartoDB Dark Matter ↔ CartoDB Positron 자동 전환 (`settingsStore.theme` 구독)
|
||||||
|
- **CVA 변형**: `cardVariants`, `badgeVariants`, `statusDotVariants`가 CSS 변수 참조 → 테마 자동 반응
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 라우팅 구조 (26 보호 경로 + login)
|
||||||
|
|
||||||
|
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
|
||||||
|
|
||||||
|
- `/login` — 비보호 라우트 (LoginPage)
|
||||||
|
- `/` — `ProtectedRoute` > `MainLayout` (사이드바 + Outlet)
|
||||||
|
- `/` → `/dashboard` 리다이렉트
|
||||||
|
- `/dashboard` — 종합 대시보드 (SFR-12)
|
||||||
|
- `/monitoring` — 실시간 모니터링
|
||||||
|
- `/risk-map` — 위험도 평가 (SFR-05)
|
||||||
|
- `/enforcement-plan` — 단속계획 (SFR-06)
|
||||||
|
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
|
||||||
|
- `/gear-detection` — 어구 탐지 (SFR-10)
|
||||||
|
- `/china-fishing` — 중국어선 탐지
|
||||||
|
- `/patrol-route` — 순찰경로 (SFR-07)
|
||||||
|
- `/fleet-optimization` — 함대 최적화 (SFR-08)
|
||||||
|
- `/enforcement-history` — 단속 이력 (SFR-11)
|
||||||
|
- `/event-list` — 이벤트 목록
|
||||||
|
- `/mobile-service` — 현장 모바일 (SFR-15)
|
||||||
|
- `/ship-agent` — 함정 에이전트 (SFR-16)
|
||||||
|
- `/ai-alert` — AI 경보 (SFR-17)
|
||||||
|
- `/statistics` — 통계 (SFR-13)
|
||||||
|
- `/external-service` — 외부연계 (SFR-14)
|
||||||
|
- `/reports` — 보고서 관리
|
||||||
|
- `/ai-model` — AI 모델 관리 (SFR-04)
|
||||||
|
- `/mlops` — MLOps (SFR-18~19)
|
||||||
|
- `/ai-assistant` — AI 어시스턴트 (SFR-20)
|
||||||
|
- `/data-hub` — 데이터허브 (SFR-03)
|
||||||
|
- `/system-config` — 환경설정 (SFR-02)
|
||||||
|
- `/notices` — 공지사항
|
||||||
|
- `/access-control` — 접근 권한 (SFR-01)
|
||||||
|
- `/admin` — 시스템 관리
|
||||||
|
- `/events` — 감시 (LiveMapView)
|
||||||
|
- `/map-control` — 지도 컨트롤
|
||||||
|
- `/vessel/:id` — 선박 상세
|
||||||
|
|
||||||
|
인증은 `AuthContext`의 `useAuth().user` 존재 여부로 판단하며, 미인증 시 `/login`으로 리다이렉트한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빌드 설정
|
||||||
|
|
||||||
|
- **TypeScript**: `target: ES2020`, `module: ESNext`, `moduleResolution: bundler`, `strict: true`
|
||||||
|
- **Vite**: `react()` + `tailwindcss()` 플러그인, 6개 path alias (`@`, `@lib`, `@shared`, `@features`, `@data`, `@stores`)
|
||||||
|
- **ESLint 10 Flat Config**: `typescript-eslint` + `react-hooks` + `react-refresh` 규칙
|
||||||
|
- **빌드 속도**: Rolldown 기반 ~480ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 아키텍처 특성
|
||||||
|
|
||||||
|
1. **Zustand 8개 스토어**: vessel, patrol, event, kpi, transfer, gear, enforcement, settings. `settingsStore`는 theme/language + localStorage 동기화 담당.
|
||||||
|
2. **deck.gl GPU 렌더링**: MapLibre(래스터 베이스맵) + deck.gl(벡터). React 리렌더 완전 분리, RAF 기반 `overlay.setProps()` 직접 호출.
|
||||||
|
3. **CVA 스타일 시스템**: `cardVariants`, `badgeVariants`, `statusDotVariants`로 Tailwind 패턴 통합. CSS 변수 기반 테마 반응.
|
||||||
|
4. **mock 기반 서비스 계층**: 7개 API 서비스가 `data/mock/` 모듈에서 데이터 반환. 향후 Axios + 실제 API로 교체 예정.
|
||||||
|
5. **i18n 10 NS 구조 완성**: 리소스 파일 완비, MainLayout 메뉴 + 페이지 제목 + LoginPage 적용 완료. 페이지 내부 텍스트는 대부분 한국어 하드코딩 잔존.
|
||||||
|
6. **Dark/Light 테마**: CSS 변수 기반 양방향 테마. 지도 타일 자동 전환. 일부 alert 색상(`red-500/20` 등) 하드코딩 잔존.
|
||||||
|
7. **단일 번들**: 코드 스플리팅 미적용 (~3.2MB), React.lazy 미사용. 모든 feature가 단일 번들로 빌드.
|
||||||
|
8. **WebSocket 미구현**: `connectWs` 스텁만 존재, STOMP.js + SockJS 미설치.
|
||||||
252
docs/data-sharing-analysis.md
Normal file
252
docs/data-sharing-analysis.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
# Mock 데이터 공유 현황 분석 및 통합 결과
|
||||||
|
|
||||||
|
> 최초 작성일: 2026-04-06
|
||||||
|
> 마지막 업데이트: 2026-04-06
|
||||||
|
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
|
||||||
|
> 상태: **통합 완료**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 선박 데이터 교차참조
|
||||||
|
|
||||||
|
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
|
||||||
|
|
||||||
|
| 선박명 | 등장 파일 수 | 파일 목록 |
|
||||||
|
|---|---|---|
|
||||||
|
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
|
||||||
|
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
|
||||||
|
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
|
||||||
|
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
|
||||||
|
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
|
||||||
|
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
|
||||||
|
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
|
||||||
|
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 위험도 스케일 불일치
|
||||||
|
|
||||||
|
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
|
||||||
|
|
||||||
|
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
|
||||||
|
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
|
||||||
|
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
|
||||||
|
|
||||||
|
### 원인 분석
|
||||||
|
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
|
||||||
|
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
|
||||||
|
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
|
||||||
|
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
|
||||||
|
- EventList는 레벨 문자열 (`AlertLevel`)
|
||||||
|
|
||||||
|
### 통합 방안
|
||||||
|
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. KPI 수치 중복
|
||||||
|
|
||||||
|
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
|
||||||
|
|
||||||
|
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|
||||||
|
|---|---|---|
|
||||||
|
| 실시간 탐지 | 47 | 47 |
|
||||||
|
| EEZ 침범 | 18 | 18 |
|
||||||
|
| 다크베셀 | 12 | 12 |
|
||||||
|
| 불법환적 의심 | 8 | 8 |
|
||||||
|
| 추적 중 | 15 | 15 |
|
||||||
|
| 나포/검문(금일 단속) | 3 | 3 |
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
|
||||||
|
- 수치 변경 시 양쪽 모두 수정해야 함
|
||||||
|
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 이벤트 타임라인 중복
|
||||||
|
|
||||||
|
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
|
||||||
|
|
||||||
|
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
|
||||||
|
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
|
||||||
|
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
|
||||||
|
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
|
||||||
|
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
|
||||||
|
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
|
||||||
|
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
|
||||||
|
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
|
||||||
|
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 환적 데이터 100% 중복
|
||||||
|
|
||||||
|
`TransferDetection.tsx`와 `ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
TransferDetection.tsx:
|
||||||
|
const transferData = [
|
||||||
|
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
||||||
|
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
||||||
|
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
||||||
|
];
|
||||||
|
|
||||||
|
ChinaFishing.tsx:
|
||||||
|
const TRANSFER_DATA = [
|
||||||
|
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
|
||||||
|
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
|
||||||
|
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
|
||||||
|
- 한쪽만 수정하면 다른 쪽과 불일치 발생
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 함정 상태 불일치
|
||||||
|
|
||||||
|
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
|
||||||
|
|
||||||
|
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
|
||||||
|
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
|
||||||
|
| 1503함 | **미배포** | - | - | **정비중** |
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
|
||||||
|
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
|
||||||
|
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 현재 상태: 통합 완료
|
||||||
|
|
||||||
|
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
|
||||||
|
|
||||||
|
### 7.1 완료된 아키텍처: mock -> store -> page
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ src/data/mock/ (7개 공유 모듈) │
|
||||||
|
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
||||||
|
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
|
||||||
|
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
|
||||||
|
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
|
||||||
|
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
|
||||||
|
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
|
||||||
|
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
|
||||||
|
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ src/features/*/ (페이지 컴포넌트) │
|
||||||
|
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
|
||||||
|
|
||||||
|
| 스토어 | 소비 페이지 |
|
||||||
|
|---|---|
|
||||||
|
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
|
||||||
|
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
|
||||||
|
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
|
||||||
|
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
|
||||||
|
| `useTransferStore` | TransferDetection, ChinaFishing |
|
||||||
|
| `useGearStore` | GearDetection |
|
||||||
|
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
|
||||||
|
|
||||||
|
### 7.3 페이지 전용 인라인 데이터 (미통합)
|
||||||
|
|
||||||
|
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
|
||||||
|
|
||||||
|
| 페이지 | 인라인 데이터 | 사유 |
|
||||||
|
|---|---|---|
|
||||||
|
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
|
||||||
|
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
|
||||||
|
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
|
||||||
|
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
|
||||||
|
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
|
||||||
|
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
|
||||||
|
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
|
||||||
|
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
|
||||||
|
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
|
||||||
|
|
||||||
|
### 7.4 설계 원칙 (구현 완료)
|
||||||
|
|
||||||
|
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
|
||||||
|
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
|
||||||
|
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
|
||||||
|
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
|
||||||
|
|
||||||
|
### 7.5 Mock 모듈 상세 (참고용)
|
||||||
|
|
||||||
|
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
|
||||||
|
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
|
||||||
|
|
||||||
|
| # | 모듈 파일 | 스토어 | 내용 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
|
||||||
|
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
|
||||||
|
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
|
||||||
|
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
|
||||||
|
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
|
||||||
|
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
|
||||||
|
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 작업 완료 요약
|
||||||
|
|
||||||
|
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|
||||||
|
|---|---|---|
|
||||||
|
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
|
||||||
|
| `events.ts` | **완료** | 6개 (useEventStore) |
|
||||||
|
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
|
||||||
|
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
|
||||||
|
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
|
||||||
|
| `gear.ts` | **완료** | 1개 (useGearStore) |
|
||||||
|
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
|
||||||
|
|
||||||
|
### 실제 작업 결과
|
||||||
|
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
|
||||||
|
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
|
||||||
|
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
|
||||||
|
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 결론
|
||||||
|
|
||||||
|
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
|
||||||
|
|
||||||
|
달성한 효과:
|
||||||
|
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
|
||||||
|
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
|
||||||
|
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
|
||||||
|
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
|
||||||
|
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
|
||||||
|
|
||||||
|
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.
|
||||||
194
docs/next-refactoring.md
Normal file
194
docs/next-refactoring.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
|
||||||
|
|
||||||
|
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
|
||||||
|
|
||||||
|
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
|
||||||
|
|
||||||
|
- `vesselStore` — 선박 목록, 선택, 필터
|
||||||
|
- `patrolStore` — 순찰 경로/함정
|
||||||
|
- `eventStore` — 탐지/경보 이벤트
|
||||||
|
- `kpiStore` — KPI 메트릭, 추세
|
||||||
|
- `transferStore` — 전재(환적)
|
||||||
|
- `gearStore` — 어구 탐지
|
||||||
|
- `enforcementStore` — 단속 이력
|
||||||
|
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
|
||||||
|
|
||||||
|
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
|
||||||
|
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
|
||||||
|
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
|
||||||
|
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
|
||||||
|
|
||||||
|
### 남은 작업
|
||||||
|
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
|
||||||
|
- [ ] Axios 인터셉터:
|
||||||
|
- Request: Authorization 헤더 자동 주입
|
||||||
|
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
|
||||||
|
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
|
||||||
|
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
|
||||||
|
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
- `services/ws.ts`에 `connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
|
||||||
|
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
|
||||||
|
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
|
||||||
|
|
||||||
|
### 남은 작업
|
||||||
|
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
|
||||||
|
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
|
||||||
|
- [ ] 구독 채널 설계:
|
||||||
|
- `/topic/ais-positions` — 실시간 AIS 위치
|
||||||
|
- `/topic/alerts` — 경보/이벤트
|
||||||
|
- `/topic/detections` — 탐지 결과
|
||||||
|
- `/user/queue/notifications` — 개인 알림
|
||||||
|
- [ ] 재연결 로직 (지수 백오프)
|
||||||
|
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
|
||||||
|
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
|
||||||
|
|
||||||
|
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
|
||||||
|
|
||||||
|
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay`를 `useImperativeHandle`로 외부 노출
|
||||||
|
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
|
||||||
|
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
|
||||||
|
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
|
||||||
|
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
|
||||||
|
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
|
||||||
|
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
|
||||||
|
|
||||||
|
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ✅ 더미 데이터 통합 — COMPLETED
|
||||||
|
|
||||||
|
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
|
||||||
|
|
||||||
|
```
|
||||||
|
data/mock/
|
||||||
|
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
|
||||||
|
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
|
||||||
|
├── transfers.ts # 전재(환적) 데이터
|
||||||
|
├── patrols.ts # PatrolShip — 순찰 경로/함정
|
||||||
|
├── gear.ts # 어구 탐지 데이터
|
||||||
|
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
|
||||||
|
└── enforcement.ts # 단속 이력 데이터
|
||||||
|
```
|
||||||
|
|
||||||
|
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
|
||||||
|
- 인터페이스가 API 응답 타입 계약 역할 수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
|
||||||
|
- ko/en 각 10파일 (총 20 JSON)
|
||||||
|
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
|
||||||
|
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
|
||||||
|
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
|
||||||
|
|
||||||
|
### 남은 작업
|
||||||
|
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
|
||||||
|
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
|
||||||
|
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
|
||||||
|
|
||||||
|
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
|
||||||
|
|
||||||
|
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
|
||||||
|
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
|
||||||
|
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
|
||||||
|
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
|
||||||
|
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 코드 스플리팅 — 미착수
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
|
||||||
|
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
|
||||||
|
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
|
||||||
|
|
||||||
|
### 필요한 이유
|
||||||
|
- 초기 로딩 성능 개선 (FCP, LCP)
|
||||||
|
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
|
||||||
|
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
|
||||||
|
|
||||||
|
### 구현 계획
|
||||||
|
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
|
||||||
|
```typescript
|
||||||
|
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
|
||||||
|
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
|
||||||
|
```
|
||||||
|
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
|
||||||
|
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
|
||||||
|
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
|
||||||
|
```typescript
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
|
||||||
|
'vendor-chart': ['echarts'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] 목표: 초기 번들 < 300KB (gzip), 각 feature chunk < 100KB
|
||||||
|
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Light 테마 하드코딩 정리
|
||||||
|
|
||||||
|
### 현재 상태
|
||||||
|
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
|
||||||
|
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
|
||||||
|
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
|
||||||
|
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
|
||||||
|
|
||||||
|
### 구현 계획
|
||||||
|
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
|
||||||
|
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
|
||||||
|
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 우선순위 및 의존관계
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 완료 ─────────────────────────────────────
|
||||||
|
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
|
||||||
|
|
||||||
|
진행 중 / 남은 작업 ──────────────────────────
|
||||||
|
[6. i18n 내부 텍스트] ──┐
|
||||||
|
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
|
||||||
|
[9. Light 테마 정리] ───┘
|
||||||
|
|
||||||
|
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 권장 진행 순서
|
||||||
|
|
||||||
|
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
|
||||||
|
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
|
||||||
|
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)
|
||||||
436
docs/page-workflow.md
Normal file
436
docs/page-workflow.md
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
# 페이지 역할표 및 업무 파이프라인
|
||||||
|
|
||||||
|
> 최초 작성일: 2026-04-06
|
||||||
|
> 마지막 업데이트: 2026-04-06
|
||||||
|
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 공통 아키텍처
|
||||||
|
|
||||||
|
### 디렉토리 구조
|
||||||
|
|
||||||
|
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/features/
|
||||||
|
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
|
||||||
|
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
|
||||||
|
auth/ LoginPage
|
||||||
|
dashboard/ Dashboard
|
||||||
|
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
|
||||||
|
enforcement/ EnforcementHistory, EventList
|
||||||
|
field-ops/ AIAlert, MobileService, ShipAgent
|
||||||
|
monitoring/ MonitoringDashboard
|
||||||
|
patrol/ FleetOptimization, PatrolRoute
|
||||||
|
risk-assessment/ EnforcementPlan, RiskMap
|
||||||
|
statistics/ ExternalService, ReportManagement, Statistics
|
||||||
|
surveillance/ LiveMapView, MapControl
|
||||||
|
vessel/ TransferDetection, VesselDetail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 흐름
|
||||||
|
|
||||||
|
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
|
||||||
|
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
|
||||||
|
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
|
||||||
|
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
|
||||||
|
|
||||||
|
### 지도 렌더링
|
||||||
|
|
||||||
|
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
|
||||||
|
|
||||||
|
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
|
||||||
|
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
|
||||||
|
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
|
||||||
|
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
|
||||||
|
|
||||||
|
### 다국어 (i18n)
|
||||||
|
|
||||||
|
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
|
||||||
|
- 지원 언어: 한국어 (ko), 영어 (en)
|
||||||
|
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
|
||||||
|
|
||||||
|
### 테마
|
||||||
|
|
||||||
|
- `settingsStore`에서 dark/light 테마 전환 지원
|
||||||
|
- 기본값: dark (해양 감시 시스템 특성상)
|
||||||
|
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 31개 페이지 역할표
|
||||||
|
|
||||||
|
### 1.1 인증/관리 (4개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
|
||||||
|
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
|
||||||
|
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
|
||||||
|
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
|
||||||
|
|
||||||
|
### 1.2 데이터 수집/연계 (1개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
|
||||||
|
|
||||||
|
### 1.3 AI 모델/운영 (3개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
|
||||||
|
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
|
||||||
|
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
|
||||||
|
|
||||||
|
### 1.4 탐지 (4개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
|
||||||
|
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
|
||||||
|
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
|
||||||
|
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
|
||||||
|
|
||||||
|
### 1.5 환적 탐지 (1개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
|
||||||
|
|
||||||
|
### 1.6 위험도 평가/계획 (2개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
|
||||||
|
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
|
||||||
|
|
||||||
|
### 1.7 순찰/함대 (2개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
|
||||||
|
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
|
||||||
|
|
||||||
|
### 1.8 감시/지도 (2개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
|
||||||
|
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
|
||||||
|
|
||||||
|
### 1.9 대시보드/모니터링 (2개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
|
||||||
|
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
|
||||||
|
|
||||||
|
### 1.10 이벤트/이력 (2개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
|
||||||
|
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
|
||||||
|
|
||||||
|
### 1.11 현장 대응 (3개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
|
||||||
|
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
|
||||||
|
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
|
||||||
|
|
||||||
|
### 1.12 통계/외부연계/보고 (3개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
|
||||||
|
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
|
||||||
|
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
|
||||||
|
|
||||||
|
### 1.13 선박 상세 (1개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
|
||||||
|
|
||||||
|
### 1.14 시스템 관리 (1개)
|
||||||
|
|
||||||
|
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 업무 파이프라인 (4개)
|
||||||
|
|
||||||
|
### 2.1 탐지 파이프라인
|
||||||
|
|
||||||
|
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
|
||||||
|
|
||||||
|
```
|
||||||
|
AIS/레이더/위성 신호
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐
|
||||||
|
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
|
||||||
|
└────┬────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ AI 탐지 엔진 (AIModelManagement 관리) │
|
||||||
|
│ │
|
||||||
|
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
|
||||||
|
│ GearDetection ─────── 불법 어구 탐지 │
|
||||||
|
│ ChinaFishing ──────── 중국어선 통합 감시 │
|
||||||
|
│ TransferDetection ─── 환적 행위 탐지 │
|
||||||
|
│ GearIdentification ── 어구 국적 판별 │
|
||||||
|
└──────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────┐ ┌───────────────────┐
|
||||||
|
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
|
||||||
|
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
|
||||||
|
│ └───────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ EnforcementPlan │ ← 단속 우선지역 예보
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌───────────────────┐
|
||||||
|
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
|
||||||
|
└──────┬───────┘ └─────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐
|
||||||
|
│ AIAlert │ ← 함정/관제 자동 알림 발송
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
현장 작전 (MobileService, ShipAgent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 대응 파이프라인
|
||||||
|
|
||||||
|
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
│ AIAlert │ ← AI 탐지 알림 자동 발송
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ 현장 대응 │
|
||||||
|
│ │
|
||||||
|
│ MobileService ── 모바일 경보 수신│
|
||||||
|
│ ShipAgent ────── 함정 Agent 연동 │
|
||||||
|
└──────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
현장 단속 수행
|
||||||
|
(정선/검문/나포/퇴거)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ ReportManagement │ ← 증거 패키징, 보고서 생성
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
검찰/외부기관 (ExternalService 통해 연계)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 분석 파이프라인
|
||||||
|
|
||||||
|
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────┐
|
||||||
|
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
전략 수립 (순찰 패턴, 탐지 규칙 조정)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 관리 파이프라인
|
||||||
|
|
||||||
|
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐
|
||||||
|
│ AccessControl │ ← RBAC 역할/권한 설정
|
||||||
|
└───────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────┐
|
||||||
|
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
|
||||||
|
└──────┬─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 시스템 설정/관리 │
|
||||||
|
│ │
|
||||||
|
│ SystemConfig ──── 공통코드/환경설정 │
|
||||||
|
│ NoticeManagement ── 공지/배너/팝업 │
|
||||||
|
│ DataHub ────────── 데이터 수집 관리 │
|
||||||
|
│ AdminPanel ────── 서버/인프라 모니터 │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사용자 역할별 페이지 접근 매트릭스
|
||||||
|
|
||||||
|
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
|
||||||
|
|
||||||
|
### 3.1 역할 정의
|
||||||
|
|
||||||
|
| 역할 | 코드 | 설명 | 인원(시뮬) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
|
||||||
|
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
|
||||||
|
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
|
||||||
|
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
|
||||||
|
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
|
||||||
|
|
||||||
|
### 3.2 접근 매트릭스
|
||||||
|
|
||||||
|
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **인증/관리** | | | | | |
|
||||||
|
| LoginPage | O | O | O | O | O |
|
||||||
|
| AccessControl | O | - | - | - | - |
|
||||||
|
| SystemConfig | O | - | - | - | - |
|
||||||
|
| NoticeManagement | O | - | - | - | - |
|
||||||
|
| AdminPanel | O | - | - | - | - |
|
||||||
|
| **데이터/AI** | | | | | |
|
||||||
|
| DataHub | O | - | - | - | - |
|
||||||
|
| AIModelManagement | O | - | O | - | - |
|
||||||
|
| MLOpsPage | O | - | O | - | - |
|
||||||
|
| AIAssistant | O | O | O | - | - |
|
||||||
|
| **탐지** | | | | | |
|
||||||
|
| DarkVesselDetection | O | - | O | - | - |
|
||||||
|
| GearDetection | O | - | O | - | - |
|
||||||
|
| ChinaFishing | O | O | O | - | - |
|
||||||
|
| TransferDetection | O | - | O | - | - |
|
||||||
|
| **위험도/계획** | | | | | |
|
||||||
|
| RiskMap | O | O | O | - | - |
|
||||||
|
| EnforcementPlan | O | O | - | - | - |
|
||||||
|
| **순찰** | | | | | |
|
||||||
|
| PatrolRoute | O | O | - | - | - |
|
||||||
|
| FleetOptimization | O | O | - | - | - |
|
||||||
|
| **감시/지도** | | | | | |
|
||||||
|
| LiveMapView | O | O | O | - | - |
|
||||||
|
| MapControl | O | O | - | - | - |
|
||||||
|
| **대시보드** | | | | | |
|
||||||
|
| Dashboard | O | O | O | O | O |
|
||||||
|
| MonitoringDashboard | O | O | - | - | - |
|
||||||
|
| **이벤트/이력** | | | | | |
|
||||||
|
| EventList | O | O | O | O | - |
|
||||||
|
| EnforcementHistory | O | - | O | - | - |
|
||||||
|
| **현장 대응** | | | | | |
|
||||||
|
| MobileService | O | - | - | O | - |
|
||||||
|
| ShipAgent | O | - | - | O | - |
|
||||||
|
| AIAlert | O | O | - | O | - |
|
||||||
|
| **통계/보고** | | | | | |
|
||||||
|
| Statistics | O | O | O | - | - |
|
||||||
|
| ExternalService | O | - | - | - | O |
|
||||||
|
| ReportManagement | O | O | O | - | - |
|
||||||
|
| **선박 상세** | | | | | |
|
||||||
|
| VesselDetail | O | O | O | - | - |
|
||||||
|
|
||||||
|
### 3.3 역할별 요약
|
||||||
|
|
||||||
|
| 역할 | 접근 가능 페이지 | 페이지 수 |
|
||||||
|
|---|---|---|
|
||||||
|
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
|
||||||
|
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
|
||||||
|
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
|
||||||
|
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
|
||||||
|
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 페이지 간 데이터 흐름 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ LoginPage │
|
||||||
|
│ (인증 게이트) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────┬┴──────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
|
||||||
|
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ AccessControl│ │ DataHub │ │ MobileSvc │
|
||||||
|
│ SystemConfig │ │ ↓ │ │ ShipAgent │
|
||||||
|
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
|
||||||
|
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
|
||||||
|
│ AdminPanel │ │ ↓ │ │
|
||||||
|
└──────────────┘ │ RiskMap │ │
|
||||||
|
│ ↓ │ ▼
|
||||||
|
│ EnforcementPlan │ ┌──────────────┐
|
||||||
|
│ ↓ │ │ 대응 파이프라인│
|
||||||
|
│ PatrolRoute │ │ │
|
||||||
|
│ FleetOptim │ │ Enforcement │
|
||||||
|
│ ↓ │ │ History │
|
||||||
|
│ LiveMapView │ │ ReportManage │
|
||||||
|
│ Monitoring │ │ ExternalSvc │
|
||||||
|
└────────┬────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 분석 파이프라인 │
|
||||||
|
│ │
|
||||||
|
│ Statistics │
|
||||||
|
│ VesselDetail │
|
||||||
|
│ AIAssistant │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 미할당 SFR 참고
|
||||||
|
|
||||||
|
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
|
||||||
|
|
||||||
|
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
|
||||||
|
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
|
||||||
|
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
|
||||||
|
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
|
||||||
|
- **VesselDetail**: SFR 번호 미부여, 선박 상세
|
||||||
|
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
|
||||||
|
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
|
||||||
|
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트
|
||||||
905
docs/sfr-traceability.md
Normal file
905
docs/sfr-traceability.md
Normal file
@ -0,0 +1,905 @@
|
|||||||
|
# SFR 요구사항 추적 매트릭스 (Requirements Traceability Matrix)
|
||||||
|
|
||||||
|
**프로젝트:** AI 기반 불법조업 감시 시스템
|
||||||
|
**문서 버전:** 2.0
|
||||||
|
**최종 업데이트:** 2026-04-06
|
||||||
|
**근거 문서:** 제안요청서 (RFP) 소프트웨어 기능 요구사항 (SFR)
|
||||||
|
|
||||||
|
### 기술 스택 및 아키텍처 현황
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 기술 스택 | React 19, Vite 8, deck.gl 9.2, Zustand 5.0, ECharts, MapLibre GL |
|
||||||
|
| 데이터 흐름 | `data/mock` → Zustand store → 페이지 렌더 |
|
||||||
|
| 렌더링 | deck.gl 제로 리렌더 아키텍처 (`useMapLayers` + RAF) |
|
||||||
|
| i18n | 10 네임스페이스, MainLayout + 24페이지 + LoginPage 적용 |
|
||||||
|
| 테마 | Dark/Light 전환 지원 (CSS 변수 기반) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
| 구분 | 건수 |
|
||||||
|
|------|------|
|
||||||
|
| 전체 SFR | 20건 |
|
||||||
|
| UI 완료 | 20건 (100%) |
|
||||||
|
| 기능 일부 구현 (프론트엔드 시뮬레이션) | 20건 |
|
||||||
|
| 백엔드 연동 완료 | 0건 (0%) |
|
||||||
|
|
||||||
|
> 현재 전체 SFR에 대해 화면(UI) 프로토타입이 완성되었으며(31페이지), 시뮬레이션 데이터 기반으로 동작합니다.
|
||||||
|
> 데이터는 `data/mock` JSON에서 8개의 Zustand store(`kpiStore`, `vesselStore`, `eventStore`, `enforcementStore`, `patrolStore`, `gearStore`, `transferStore`, `settingsStore`)를 거쳐 페이지에 전달됩니다.
|
||||||
|
> 지도 페이지는 deck.gl 레이어 렌더링을 사용하며, 모든 페이지에 i18n 제목/설명 및 Dark/Light 테마가 적용되어 있습니다.
|
||||||
|
> 실제 백엔드 API, AI 모델, 외부 시스템 연동은 2차 개발 단계에서 수행됩니다.
|
||||||
|
|
||||||
|
### 구현 진척 요약
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| UI 프로토타입 | ✅ 완료 (31페이지) |
|
||||||
|
| 기술스택 전환 | ✅ 완료 (React 19, Vite 8, deck.gl, ECharts) |
|
||||||
|
| 데이터 통합 | ✅ 완료 (7 mock → 8 store) |
|
||||||
|
| i18n 구조 | ✅ 완료 (제목/메뉴, 내부 텍스트 미완) |
|
||||||
|
| 테마 시스템 | ✅ 완료 (dark/light, 시맨틱 CSS 변수) |
|
||||||
|
| API 서비스 | 🔲 샘플 구조만 (mock 반환) |
|
||||||
|
| 실시간 인프라 | 🔲 스캐폴드만 (STOMP.js 미설치) |
|
||||||
|
| 코드 스플리팅 | 🔲 미적용 (3.2MB 단일 번들) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR 목록
|
||||||
|
|
||||||
|
| SFR | 요구사항 명칭 | 구현 화면 | 구현 상태 |
|
||||||
|
|-----|-------------|----------|----------|
|
||||||
|
| SFR-01 | 시스템 로그인 및 권한 관리 | LoginPage, AccessControl | UI 완료 |
|
||||||
|
| SFR-02 | 시스템 기본 환경설정 및 공통 기능 | SystemConfig, NoticeManagement, 공통 컴포넌트 | UI 완료 |
|
||||||
|
| SFR-03 | 통합 데이터 허브 수집·연계 관리 | DataHub | UI 완료 |
|
||||||
|
| SFR-04 | AI 불법조업 예측모델 관리 | AIModelManagement | UI 완료 |
|
||||||
|
| SFR-05 | 격자 기반 불법조업 위험도 지도 생성·시각화 | RiskMap | UI 완료 |
|
||||||
|
| SFR-06 | 단속 계획·경보 연계 | EnforcementPlan | UI 완료 |
|
||||||
|
| SFR-07 | AI 경비함정 단일 함정 순찰·경로 추천 | PatrolRoute | UI 완료 |
|
||||||
|
| SFR-08 | AI 경비함정 다함정 협력형 경로 최적화 | FleetOptimization | UI 완료 |
|
||||||
|
| SFR-09 | 불법 어선 패턴 탐지 | DarkVesselDetection | UI 완료 |
|
||||||
|
| SFR-10 | 불법 어망·어구 탐지 및 관리 | GearDetection, GearIdentification | UI 완료 |
|
||||||
|
| SFR-11 | 단속·탐지 이력 관리 | EnforcementHistory, EventList | UI 완료 |
|
||||||
|
| SFR-12 | 모니터링 및 경보 현황판(대시보드) | Dashboard, MonitoringDashboard | UI 완료 |
|
||||||
|
| SFR-13 | 통계·지표·성과 분석 | Statistics | UI 완료 |
|
||||||
|
| SFR-14 | 외부 서비스 제공 결과 연계 | ExternalService | UI 완료 |
|
||||||
|
| SFR-15 | 단속요원 이용 모바일 대응 서비스 | MobileService | UI 완료 |
|
||||||
|
| SFR-16 | 함정용 단말 Agent 개발 | ShipAgent | UI 완료 |
|
||||||
|
| SFR-17 | 현장 함정 즉각 대응 AI 알림 메시지 발송 | AIAlert | UI 완료 |
|
||||||
|
| SFR-18 | 기계학습 운영 기능 | MLOpsPage | UI 완료 |
|
||||||
|
| SFR-19 | 대규모 언어모델(LLM) 운영 기능 | MLOpsPage (LLMOps 탭) | UI 완료 |
|
||||||
|
| SFR-20 | 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 | AIAssistant | UI 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 추적 내역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-01: 시스템 로그인 및 권한 관리
|
||||||
|
|
||||||
|
**제안요청서 정의:** 사용자 유형별 안전한 인증 및 역할 기반 권한 관리 체계를 구축하여, 시스템 접근 보안을 확보하고 사용자 활동에 대한 감사 추적을 가능하게 한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 해양경찰 SSO·공무원증·GPKI 등 기존 인증체계 로그인 연동
|
||||||
|
- 역할 기반 접근 제어(RBAC) 구현
|
||||||
|
- 감사 로그(Audit Log) 기록 및 조회
|
||||||
|
- 비밀번호 정책 적용 (9자 이상, 영문+숫자+특수문자)
|
||||||
|
- 5회 연속 인증 실패 시 계정 잠금 (30분)
|
||||||
|
|
||||||
|
**구현 화면:** 로그인 페이지 (`src/features/auth/LoginPage.tsx`), 접근 권한 관리 (`src/features/admin/AccessControl.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 로그인 폼: ID/PW 입력, GPKI 인증서 로그인, SSO 연동 버튼 (3가지 인증 방식 탭)
|
||||||
|
- 역할별 데모 계정 5종: 관리자(ADMIN), 운용자(OPERATOR), 분석관(ANALYST), 현장요원(FIELD), 열람자(VIEWER)
|
||||||
|
- 비밀번호 정책 검증 UI (길이·복잡도 실시간 표시)
|
||||||
|
- 계정 잠금 카운터 및 잠금 상태 표시
|
||||||
|
- 접근 권한 관리 테이블 (역할별 메뉴/기능 접근 매트릭스)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 로그인 페이지 제목/설명, 접근 권한 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- Zustand: `settingsStore`를 통한 테마/언어 설정 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 실제 SSO(해양경찰 통합인증) 연동
|
||||||
|
- GPKI(정부 공인인증서) 인증 모듈 연동
|
||||||
|
- 공무원증 기반 인증 연동
|
||||||
|
- 백엔드 세션 관리 및 JWT 토큰 발급
|
||||||
|
- 감사 로그 DB 저장 및 조회 API
|
||||||
|
- 인사 시스템 연동을 통한 역할 자동 부여
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-02: 시스템 기본 환경설정 및 공통 기능
|
||||||
|
|
||||||
|
**제안요청서 정의:** 시스템 운영에 필요한 공통 코드, 기준정보, 알림, 공통 UI 컴포넌트를 관리하며, GIS 지도 기반 웹서비스 및 범용 데이터 처리 기능을 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 공통 코드 등록·수정·폐기 관리
|
||||||
|
- 알림 관리 (팝업, 배너, 공지사항)
|
||||||
|
- GIS 지도 기반 웹서비스 제공
|
||||||
|
- 파일 업로드/다운로드
|
||||||
|
- 검색·페이징·정렬
|
||||||
|
- 엑셀 내보내기
|
||||||
|
|
||||||
|
**구현 화면:** 시스템 설정 (`src/features/admin/SystemConfig.tsx`), 공지사항 관리 (`src/features/admin/NoticeManagement.tsx`)
|
||||||
|
|
||||||
|
**공통 컴포넌트:**
|
||||||
|
- `src/shared/components/common/DataTable.tsx` - 데이터 테이블 (정렬, 필터링)
|
||||||
|
- `src/shared/components/common/ExcelExport.tsx` - 엑셀 내보내기
|
||||||
|
- `src/shared/components/common/Pagination.tsx` - 페이징 처리
|
||||||
|
- `src/shared/components/common/SearchInput.tsx` - 검색 입력
|
||||||
|
- `src/shared/components/common/FileUpload.tsx` - 파일 업로드
|
||||||
|
- `src/shared/components/common/PrintButton.tsx` - 인쇄 버튼
|
||||||
|
- `src/shared/components/common/SaveButton.tsx` - 저장 버튼
|
||||||
|
- `src/shared/components/common/NotificationBanner.tsx` - 알림 배너
|
||||||
|
- `src/shared/components/common/PageToolbar.tsx` - 페이지 도구 모음
|
||||||
|
- `src/shared/components/ui/card.tsx` - 카드 컴포넌트
|
||||||
|
- `src/shared/components/ui/badge.tsx` - 뱃지 컴포넌트
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 공통 코드 관리 테이블 (875건 기준정보 JSON)
|
||||||
|
- 공지사항 등록·수정·삭제 폼
|
||||||
|
- 알림 설정 (팝업/배너/공지 유형 선택)
|
||||||
|
- GIS 지도 컴포넌트 (MapLibre 기반)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 시스템 설정/공지사항 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원 (시맨틱 CSS 변수 기반)
|
||||||
|
- Zustand: `settingsStore`를 통한 시스템 설정 상태 관리
|
||||||
|
- GIS 지도: MapLibre GL 기반 공통 지도 컴포넌트
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 공통 코드 CRUD 백엔드 API 연동
|
||||||
|
- 기준정보 DB 저장 (현재 JSON 파일 기반)
|
||||||
|
- 파일 업로드/다운로드 서버 연동
|
||||||
|
- 알림 발송 엔진 (푸시, 이메일)
|
||||||
|
- 엑셀 내보내기 서버사이드 렌더링 (대용량)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-03: 통합 데이터 허브 수집·연계 관리
|
||||||
|
|
||||||
|
**제안요청서 정의:** AIS, V-PASS, 위성, 해양환경 등 이기종 데이터를 통합 수집하는 데이터 허브를 구축하고, 수집 파이프라인의 실시간 모니터링 및 이상 감지 기능을 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 다중 데이터 소스 수집 파이프라인 구축 (AIS, V-PASS, 위성, 해양환경, 기상)
|
||||||
|
- 실시간 스트리밍 및 배치 수집 지원
|
||||||
|
- 수집 상태 모니터링 대시보드
|
||||||
|
- 이상 감지 시 자동 알림
|
||||||
|
- 수집 이력 조회 화면
|
||||||
|
|
||||||
|
**구현 화면:** 데이터 허브 (`src/features/admin/DataHub.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 선박신호 수신 현황: 5개 신호원(지상 AIS, 위성 AIS, V-PASS, LRIT, 해상VHF) 24시간 타임라인 히트맵
|
||||||
|
- 선박위치정보 모니터링: 22개 연계 채널 상태 테이블 (수신율, 지연시간, 최종수신)
|
||||||
|
- 채널별 상태 표시 (정상/경고/오류)
|
||||||
|
- 수집 파이프라인 등록·시작·중지 관리 버튼
|
||||||
|
- 수집 이력 로그 테이블
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 데이터 허브 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- Zustand: mock 데이터 기반 수집 현황 표시
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 실제 AIS 수신기 연동 (지상·위성)
|
||||||
|
- V-PASS 시스템 API 연동
|
||||||
|
- LRIT 데이터센터 연동
|
||||||
|
- 해양환경·기상 데이터 수집 배치 구현
|
||||||
|
- 실시간 스트리밍 파이프라인 (Kafka 등)
|
||||||
|
- 수집 이상 감지 알림 엔진
|
||||||
|
- 수집 이력 DB 저장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-04: AI 불법조업 예측모델 관리
|
||||||
|
|
||||||
|
**제안요청서 정의:** 해역별 불법조업 위험도를 예측하는 AI 모델의 개발·훈련·배포·모니터링 전주기를 관리하는 체계를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 학습 데이터셋 관리 (수집·정제·라벨링)
|
||||||
|
- 모델 구조 설계 및 하이퍼파라미터 관리
|
||||||
|
- 과적합 방지 (교차검증, 정규화)
|
||||||
|
- 재학습 파이프라인 (자동/수동)
|
||||||
|
- 예측 결과 API 제공
|
||||||
|
|
||||||
|
**구현 화면:** AI 모델 관리 (`src/features/ai-operations/AIModelManagement.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 모델 레지스트리: 5개 버전 관리 (v1.0~v2.1), 배포 이력, 성능 지표 비교 테이블
|
||||||
|
- 탐지 규칙 관리: 6개 규칙 (EEZ 침범, AIS 차단, 속력 이상, 선단 밀집, 환적 의심, MMSI 변조) ON/OFF 토글, 가중치 슬라이더
|
||||||
|
- 피처 엔지니어링: 20개 피처 (Kinematic/Geometric/Temporal/Behavioral/Contextual 5개 카테고리)
|
||||||
|
- 학습 파이프라인: 6단계 시각화 (데이터 수집 → 전처리 → 피처추출 → 모델학습 → 평가 → 배포)
|
||||||
|
- 성능 모니터링: 정확도·Recall·F1·오탐률·리드타임 추이 차트
|
||||||
|
- 어구 탐지 모델: GB/T 5147 어구 분류 체계
|
||||||
|
- 7대 탐지 엔진: 불법조업 감시 알고리즘 v4.0 (906척 허가어선 기준)
|
||||||
|
- API 문서: 예측 결과 API 사양 표시
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: AI 모델 관리 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- ECharts: 성능 모니터링 추이 차트 렌더링
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 실제 ML 모델 학습 및 추론 엔진 연동
|
||||||
|
- 학습 데이터셋 관리 (데이터 수집·정제·라벨링 파이프라인)
|
||||||
|
- 모델 버전 관리 저장소 (MLflow 등)
|
||||||
|
- 재학습 자동화 (스케줄러, 트리거 기반)
|
||||||
|
- 예측 결과 REST API 서버 구현
|
||||||
|
- 과적합 감지 및 모델 드리프트 모니터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-05: 격자 기반 불법조업 위험도 지도 생성·시각화
|
||||||
|
|
||||||
|
**제안요청서 정의:** AI 예측 결과를 격자 및 해역 단위 위험도 지도로 시각화하여, 해역별 위험 수준을 직관적으로 파악할 수 있는 지리정보 기반 인터페이스를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 격자체계 정의 (해역 분할 기준)
|
||||||
|
- 5단계 등급화 위험도 지도 (매우높음/높음/보통/낮음/안전)
|
||||||
|
- 조건별 필터링 (기간, 해역, 위험등급)
|
||||||
|
- 격자 선택 시 상세 이력 조회
|
||||||
|
- 지도 출력(인쇄) 기능
|
||||||
|
- MTIS 해양사고 통계 연계
|
||||||
|
|
||||||
|
**구현 화면:** 위험도 지도 (`src/features/risk-assessment/RiskMap.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 10x18 격자 히트맵: 위험도 5단계 색상 매핑
|
||||||
|
- 6개 탭: 위험도 히트맵 / 년도별 통계 / 선박 특성별 / 사고종류별 / 시간적 특성별 / 사고율
|
||||||
|
- 해역별 요약 테이블: 6개 구역 위험도·추세·선박수
|
||||||
|
- 위험등급 분포 범례 (건수, 비율)
|
||||||
|
- GIS 지도 오버레이 (EEZ, NLL 경계선 표시)
|
||||||
|
- MTIS 해양사고 통계 차트 (중앙해양안전심판원 데이터 기반)
|
||||||
|
- 년도별 사고 추이, 선박 유형별·톤수별·선령별 분포, 사고종류별 분석
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 위험도 지도 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: 격자 히트맵 레이어 렌더링 (`useMapLayers` + RAF 제로 리렌더)
|
||||||
|
- ECharts: MTIS 해양사고 통계 차트, 년도별 추이 차트
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- AI 예측 모델 연동을 통한 실시간 위험도 산출
|
||||||
|
- 격자 선택 시 실제 이력 데이터 조회 (DB 연동)
|
||||||
|
- MTIS 실시간 데이터 API 연계
|
||||||
|
- 위험도 지도 자동 갱신 (주기적 배치)
|
||||||
|
- 격자 해상도 동적 변경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-06: 단속 계획·경보 연계
|
||||||
|
|
||||||
|
**제안요청서 정의:** AI 위험도 분석 결과를 기반으로 단속 우선지역을 자동 도출하고, 임계값 초과 시 경보를 발령하여 선제적 대응 체계를 구축한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 위험도 기반 단속 우선지역 자동 추천
|
||||||
|
- 적정 단속 계획 수립 (인력·함정 배치)
|
||||||
|
- 임계값 초과 시 자동 경보 발령
|
||||||
|
- 단속 계획 이력 관리
|
||||||
|
|
||||||
|
**구현 화면:** 단속 계획 (`src/features/risk-assessment/EnforcementPlan.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 단속 계획 목록: 5개 계획 (계획명, 대상 해역, 기간, 투입 함정, 상태)
|
||||||
|
- 지도 기반 계획 시각화 (단속 구역·함정 배치 표시)
|
||||||
|
- 단속 계획 상세 테이블 (우선순위, 예상 위험도, 투입 자원)
|
||||||
|
- 경보 현황 패널
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 단속 계획 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: 단속 구역/함정 배치 지도 레이어 렌더링
|
||||||
|
- Zustand: `enforcementStore`를 통한 단속 계획 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 위험도 기반 단속 우선지역 자동 추천 알고리즘
|
||||||
|
- 경보 발령 엔진 (임계값 설정 및 자동 발송)
|
||||||
|
- 단속 계획 승인 워크플로우
|
||||||
|
- 함정·인력 자원 관리 시스템 연동
|
||||||
|
- 단속 계획 이력 DB 저장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-07: AI 경비함정 단일 함정 순찰·경로 추천
|
||||||
|
|
||||||
|
**제안요청서 정의:** 개별 경비함정의 최적 순찰 경로를 AI가 산출하여 추천하며, 함정 성능·기상·위험도를 종합적으로 반영한 시나리오별 경로를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 함정 성능(속력, 항속거리), 기상 조건, 해역 위험도를 반영한 경로 산출
|
||||||
|
- 시나리오별 가중치 조정 (위험도 중시 / 커버리지 중시 / 연료 효율)
|
||||||
|
- 경유점 기반 시뮬레이션
|
||||||
|
- Human-in-the-loop (운용자 수정 → 재계산)
|
||||||
|
|
||||||
|
**구현 화면:** 순찰 경로 (`src/features/patrol/PatrolRoute.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 함정 선택: 4척 (3001함/3005함/3009함/5001함), 함급·속력·항속거리·상태 표시
|
||||||
|
- 추천 경로 지도: 경유점(Waypoint) 마커, 경로 폴리라인, EEZ/NLL 경계
|
||||||
|
- 3가지 시나리오: 위험도 중시 / 커버리지 중시 / 연료 효율
|
||||||
|
- 경로 요약: 거리, 소요시간, 연료 소모, 감시 격자 수
|
||||||
|
- 경유점 상세: ID, 명칭, 좌표, 예상도착시간(ETA), 설명
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 순찰 경로 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: 경유점 마커, 경로 폴리라인, EEZ/NLL 경계 레이어 렌더링
|
||||||
|
- Zustand: `patrolStore`를 통한 함정/경로 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- AI 경로 최적화 엔진 (유전 알고리즘, TSP 기반)
|
||||||
|
- 실시간 기상 데이터 반영
|
||||||
|
- 해류·조류 정보 연동
|
||||||
|
- Human-in-the-loop: 운용자 경유점 수정 시 실시간 재계산
|
||||||
|
- 경로 확정 및 함정 단말 전송
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-08: AI 경비함정 다함정 협력형 경로 최적화
|
||||||
|
|
||||||
|
**제안요청서 정의:** 다수 경비함정의 협력 운용을 통해 해역 커버리지를 최대화하고, 함정 간 중복을 최소화하는 최적 배치 계획을 산출한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 다함정 동시 배치 계획 수립
|
||||||
|
- 해역 커버리지 최적화 (전체 감시 면적 최대화)
|
||||||
|
- 함정 간 순찰 구역 중복 최소화
|
||||||
|
- 최적화 전후 비교 시뮬레이션
|
||||||
|
|
||||||
|
**구현 화면:** 함대 최적화 (`src/features/patrol/FleetOptimization.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 함정 현황: 5척 투입 함정 목록 (함명, 함급, 상태, 담당 구역)
|
||||||
|
- 커버리지 구역: 6개 구역 배치 현황
|
||||||
|
- 최적화 전후 비교: 커버리지율, 중복율, 응답시간 개선 지표
|
||||||
|
- 지도 기반 배치 시각화
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 함대 최적화 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: 함정 배치, 커버리지 구역 지도 레이어 렌더링
|
||||||
|
- Zustand: `patrolStore`를 통한 함정 현황 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 다함정 협력 최적화 AI 엔진 (다목적 최적화)
|
||||||
|
- 실시간 함정 위치 추적 연동
|
||||||
|
- 커버리지 계산 알고리즘 (보로노이 분할 등)
|
||||||
|
- 시뮬레이션 실행 엔진
|
||||||
|
- 최적화 결과 함정 단말 자동 배포
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-09: 불법 어선 패턴 탐지
|
||||||
|
|
||||||
|
**제안요청서 정의:** AIS 조작·위장·Dark Vessel(AIS 미송출 선박) 등 불법조업 관련 이상 패턴을 AI로 탐지하여 의심 선박을 식별한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- AIS 송출 차단(Dark Vessel) 탐지
|
||||||
|
- MMSI 변조 감지 (동일 선박 다중 MMSI 사용)
|
||||||
|
- 속력 변화 이상 패턴 탐지
|
||||||
|
- 국적·선명 위장 감지
|
||||||
|
- 위험도 스코어링 및 라벨링
|
||||||
|
|
||||||
|
**구현 화면:** 다크베셀 탐지 (`src/features/detection/DarkVesselDetection.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 의심 선박 목록: 7척 (선박명, 위험도 스코어, 탐지 유형, 좌표, 패턴)
|
||||||
|
- 5가지 탐지 패턴: AIS 차단, MMSI 변조, 속력 이상, 국적 위장, 선단 밀집
|
||||||
|
- 위험도 스코어링: 0~1.0 수치 기반 위험 등급 표시
|
||||||
|
- 라벨링 기능: 의심 선박에 대한 분류 태그 부여
|
||||||
|
- 지도 기반 의심 선박 위치 표시
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 다크베셀 탐지 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: 의심 선박 위치 마커, 이동 경로 레이어 렌더링
|
||||||
|
- Zustand: `vesselStore`를 통한 의심 선박 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- AI 패턴 탐지 엔진 (AIS 시계열 분석 모델)
|
||||||
|
- 실시간 AIS 데이터 스트림 연동
|
||||||
|
- 위성 영상 기반 Dark Vessel 교차 검증
|
||||||
|
- MMSI 변조 이력 DB 축적 및 분석
|
||||||
|
- 탐지 결과 자동 경보 발령
|
||||||
|
- 라벨링 결과 재학습 파이프라인 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-10: 불법 어망·어구 탐지 및 관리
|
||||||
|
|
||||||
|
**제안요청서 정의:** 불법 어구 설치 현황을 탐지하고, AIS 조작 패턴과 연계하여 불법 어구 사용 선박을 식별하는 분석 체계를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- AIS 조작 패턴 기반 불법 어구 사용 감지
|
||||||
|
- 불법 어망·어구 분석 및 식별
|
||||||
|
- 어구 유형별 분류 및 판정
|
||||||
|
|
||||||
|
**구현 화면:** 어구 탐지 (`src/features/detection/GearDetection.tsx`), 어구 식별 (`src/features/detection/GearIdentification.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 어구 탐지 현황: 6개 어구 유형별 탐지 목록
|
||||||
|
- 어구 식별 결정 트리: 어구 유형 판정 로직 시각화
|
||||||
|
- 판정 요인 분석: 선박 이동 패턴, 정박 시간, 조업 형태 등 요인별 기여도
|
||||||
|
- 중국어선 어구 탐지 (`src/features/detection/ChinaFishing.tsx` 내 연계)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 어구 탐지/어구 식별 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- Zustand: `gearStore`를 통한 어구 탐지 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- AI 어구 탐지 모델 (위성 영상 + AIS 패턴 융합)
|
||||||
|
- 어구 유형 자동 분류 모델 (GB/T 5147 기반)
|
||||||
|
- 실시간 AIS 패턴 분석을 통한 어구 사용 추정
|
||||||
|
- 탐지 결과 DB 저장 및 이력 관리
|
||||||
|
- 불법 어구 적발 통계 자동 집계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-11: 단속·탐지 이력 관리
|
||||||
|
|
||||||
|
**제안요청서 정의:** 단속 활동 및 AI 탐지 결과에 대한 이력을 체계적으로 관리하여, 과거 사례 조회 및 AI 매칭 검증이 가능한 이력 관리 체계를 구축한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 단속·탐지 이력 통합 조회
|
||||||
|
- AI 탐지 결과와 실제 단속 결과 매칭 검증
|
||||||
|
- 이력 기반 통계 분석
|
||||||
|
|
||||||
|
**구현 화면:** 단속 이력 (`src/features/enforcement/EnforcementHistory.tsx`), 이벤트 목록 (`src/features/enforcement/EventList.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 단속 이력 테이블: 6건 단속 기록 (일시, 해역, 선박, 위반 유형, 조치, 결과)
|
||||||
|
- 이벤트 목록: 15건 탐지 이벤트 (DataTable 기반 정렬·필터·페이징)
|
||||||
|
- 이력 상세 조회 패널
|
||||||
|
- 검색·필터링 (기간, 해역, 위반 유형)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 단속 이력/이벤트 목록 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- Zustand: `enforcementStore`, `eventStore`를 통한 이력 데이터 관리
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 단속·탐지 이력 DB 연동 (CRUD API)
|
||||||
|
- AI 탐지 결과 ↔ 실제 단속 결과 자동 매칭 로직
|
||||||
|
- 이력 기반 통계 분석 백엔드
|
||||||
|
- 단속 보고서 자동 생성
|
||||||
|
- 첨부파일(증거 사진·영상) 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-12: 모니터링 및 경보 현황판(대시보드)
|
||||||
|
|
||||||
|
**제안요청서 정의:** 해양 불법조업 감시 현황을 실시간으로 종합 모니터링하고, 경보 상태를 직관적으로 파악할 수 있는 통합 대시보드를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 핵심 KPI 실시간 표시
|
||||||
|
- 실시간 상황 모니터링 (선박 위치, 탐지 현황)
|
||||||
|
- 경보 현황 표시 및 이력 타임라인
|
||||||
|
|
||||||
|
**구현 화면:** 메인 대시보드 (`src/features/dashboard/Dashboard.tsx`), 모니터링 대시보드 (`src/features/monitoring/MonitoringDashboard.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- KPI 카드 6종: 실시간 탐지(47건), EEZ 침범(18건), 다크베셀(12건), 불법환적 의심(8건), 추적 중(15건), 나포/검문(3건) — 전일 대비 증감 표시
|
||||||
|
- 작전 경보 타임라인: 10건 시간순 이벤트 (CRITICAL/HIGH/MEDIUM/LOW 4단계)
|
||||||
|
- 위험 선박 TOP 8: 위험도 스코어 순위, 선박명·국적·위치·패턴 표시
|
||||||
|
- 경비함정 현황: 6척 상태 (추적/검문/초계/귀항/대기)
|
||||||
|
- 해역별 위험도: 7개 구역 위험 수준·추세
|
||||||
|
- 시간대별 탐지 추이 차트
|
||||||
|
- GIS 지도: EEZ/NLL 경계선, 선박 위치 마커, 히트맵 레이어
|
||||||
|
- 기상 정보 표시 (풍향, 풍속, 수온, 파고)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 대시보드/모니터링 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- deck.gl: GIS 지도에 선박 위치 마커, 히트맵 레이어 렌더링
|
||||||
|
- Zustand: `kpiStore`, `vesselStore`, `eventStore`를 통한 대시보드 데이터 관리
|
||||||
|
- ECharts: 시간대별 탐지 추이 차트, 해역별 위험도 차트
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 실시간 데이터 스트리밍 연동 (WebSocket)
|
||||||
|
- 실시간 AIS 선박 위치 표시
|
||||||
|
- 경보 자동 발령 및 알림 연동
|
||||||
|
- KPI 실시간 갱신 (DB 기반 집계)
|
||||||
|
- 기상청 API 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-13: 통계·지표·성과 분석
|
||||||
|
|
||||||
|
**제안요청서 정의:** 단속 건수, 위반 유형, 해역별·계절별 패턴, AI 적중률 등 핵심 성과 지표를 체계적으로 분석하고 시각화한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 월별 단속 추이 분석
|
||||||
|
- 위반 유형별 분포 통계
|
||||||
|
- KPI 지표: 정확도, 오탐률, 리드타임, 성공률, 응답시간
|
||||||
|
|
||||||
|
**구현 화면:** 통계 (`src/features/statistics/Statistics.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 월별 단속 추이 차트 (AreaChart)
|
||||||
|
- 위반 유형별 분포 차트 (PieChart/BarChart)
|
||||||
|
- KPI 지표 테이블: 정확도, 오탐률, 리드타임, 성공률, 응답시간
|
||||||
|
- 해역별·계절별 크로스탭 분석
|
||||||
|
- 통계 기간 설정 필터
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 통계 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- ECharts: 월별 단속 추이(AreaChart), 위반 유형별 분포(PieChart/BarChart) 렌더링
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 통계 데이터 집계 백엔드 (DB 기반)
|
||||||
|
- AI 적중률 자동 산출 (탐지 ↔ 단속 결과 매칭)
|
||||||
|
- 보고서 자동 생성 및 출력
|
||||||
|
- 기간별·해역별 다차원 분석 엔진
|
||||||
|
- 성과 지표 목표 대비 달성률 추적
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-14: 외부 서비스 제공 결과 연계
|
||||||
|
|
||||||
|
**제안요청서 정의:** 유관기관(해수부, 수협, 어업관리단 등) 간 데이터를 API 기반으로 상호 조회·공유하며, 개인정보 비식별화 처리를 통해 보안을 확보한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- API 기반 데이터 제공 체계 구축
|
||||||
|
- 개인정보 비식별화 처리
|
||||||
|
- 유관기관별 접근 권한 관리
|
||||||
|
|
||||||
|
**구현 화면:** 외부 서비스 (`src/features/statistics/ExternalService.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 외부 서비스 목록: 5개 연계 서비스 (서비스명, 대상기관, 데이터 유형, 상태)
|
||||||
|
- 프라이버시 등급 표시 (개인정보 비식별화 수준)
|
||||||
|
- API 사용 현황 모니터링
|
||||||
|
- 서비스별 접근 권한 설정
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 외부 서비스 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 외부 기관 API Gateway 구축
|
||||||
|
- 개인정보 비식별화 처리 모듈 (k-익명성, 차등 프라이버시)
|
||||||
|
- 유관기관 연계 프로토콜 합의 및 구현
|
||||||
|
- API 인증·인가 (OAuth 2.0 / API Key)
|
||||||
|
- 데이터 제공 이력 감사 로그
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-15: 단속요원 이용 모바일 대응 서비스
|
||||||
|
|
||||||
|
**제안요청서 정의:** 현장 단속요원이 모바일 기기를 통해 AI 예측 정보, 경로 추천, 지도 조회 등을 활용할 수 있는 모바일 대응 서비스를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- AI 예측 정보 수신 (위험도, 의심선박)
|
||||||
|
- 최적 경로 추천 수신
|
||||||
|
- 모바일 지도 조회
|
||||||
|
- 오프라인 모드 지원
|
||||||
|
|
||||||
|
**구현 화면:** 모바일 서비스 (`src/features/field-ops/MobileService.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 모바일 화면 프리뷰: 반응형 레이아웃 미리보기
|
||||||
|
- 푸시 알림 설정: 알림 유형별 수신 ON/OFF
|
||||||
|
- 미니맵: 축소 지도에서 주요 이벤트 표시
|
||||||
|
- 모바일 기능 목록 (예측 조회, 경로 수신, 사진 보고 등)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 모바일 서비스 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 네이티브 모바일 앱 개발 (현재 웹 시뮬레이션만 존재)
|
||||||
|
- 푸시 알림 서버 (FCM/APNs)
|
||||||
|
- 오프라인 데이터 캐싱 (Service Worker / SQLite)
|
||||||
|
- 모바일 전용 경량 지도 엔진
|
||||||
|
- 현장 사진·영상 업로드 기능
|
||||||
|
- 단속 보고서 모바일 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-16: 함정용 단말 Agent 개발
|
||||||
|
|
||||||
|
**제안요청서 정의:** 경비함정 터미널에서 AI 기반 예측·경로분석·신속 정보 조회가 가능한 전용 Agent 소프트웨어를 개발한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- AI 예측 결과 실시간 표시
|
||||||
|
- 경로 분석 및 최적화 제안
|
||||||
|
- 신속 정보 제공 (선박 DB, 위험 이력)
|
||||||
|
- 지도 기반 상황 질의 인터페이스
|
||||||
|
|
||||||
|
**구현 화면:** 함정 Agent (`src/features/field-ops/ShipAgent.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- Agent 상태 테이블: 6개 함정별 Agent 설치 현황 (함명, 버전, 상태, 최종접속)
|
||||||
|
- Agent 기능 목록 (예측 조회, 경로 수신, DB 조회, 상황 보고)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: 함정 Agent 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 함정 단말용 Agent 소프트웨어 개발 (네이티브 or Electron)
|
||||||
|
- AI 예측 결과 실시간 수신 모듈
|
||||||
|
- 함정 단말 ↔ 본부 서버 통신 프로토콜
|
||||||
|
- 오프라인 동작 모드 (위성 통신 불안정 대비)
|
||||||
|
- 지도 기반 자연어 질의 인터페이스
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송
|
||||||
|
|
||||||
|
**제안요청서 정의:** AI 탐지 결과를 현장 함정에 즉각 알림 메시지로 자동 발송하고, 수신 확인 및 미수신 시 재발송 기능을 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- AI 탐지 결과 자동 전송 (함정 단말 + 모바일)
|
||||||
|
- 수신 확인 관리
|
||||||
|
- 미수신 시 자동 재발송
|
||||||
|
|
||||||
|
**구현 화면:** AI 알림 (`src/features/field-ops/AIAlert.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 알림 목록: 5건 발송 내역 (알림 유형, 대상 함정, 발송시간, 내용 요약)
|
||||||
|
- 전송 상태 표시: 발송 완료 / 수신 확인 / 미수신
|
||||||
|
- 재발송 버튼
|
||||||
|
- 알림 유형 필터 (긴급/일반/정보)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: AI 알림 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- 알림 발송 서버 (메시지 큐 기반)
|
||||||
|
- 함정 단말 수신 확인 프로토콜
|
||||||
|
- 미수신 자동 재발송 로직 (재시도 정책)
|
||||||
|
- 위성 통신 기반 발송 채널
|
||||||
|
- 알림 발송 이력 DB 저장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-18: 기계학습 운영 기능
|
||||||
|
|
||||||
|
**제안요청서 정의:** ML 모델의 학습·배포·모니터링 전주기를 관리하는 MLOps 체계를 구축하여, 모델 품질 유지 및 지속적 개선을 지원한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- 실험(Experiment) 관리 및 비교
|
||||||
|
- 모델 레지스트리 (버전 관리, 승인 워크플로우)
|
||||||
|
- 배포 파이프라인 (Canary/Blue-Green)
|
||||||
|
- 모델 성능 모니터링 (드리프트 감지)
|
||||||
|
|
||||||
|
**구현 화면:** WING AI 플랫폼 (`src/features/ai-operations/MLOpsPage.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 7개 탭 구성:
|
||||||
|
1. **운영 대시보드:** KPI 4종 (고위험 탐지, 중위험 탐지, 배포 중 모델, 진행 중 실험), 리소스 사용량
|
||||||
|
2. **Experiment Studio:** 실험 목록, 하이퍼파라미터, 메트릭 비교
|
||||||
|
3. **Model Registry:** 등록 모델, 버전 이력, 승인 상태
|
||||||
|
4. **Deploy Center:** 배포 파이프라인, Canary/Blue-Green 전략
|
||||||
|
5. **API Playground:** 모델 API 테스트 인터페이스
|
||||||
|
6. **LLMOps:** 대규모 언어모델 운영 (SFR-19와 공유)
|
||||||
|
7. **플랫폼 관리:** 리소스·사용자·권한 관리
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: WING AI 플랫폼 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
- ECharts: 리소스 사용량, 실험 메트릭 비교 차트
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- MLOps 플랫폼 백엔드 (Kubeflow / MLflow 등)
|
||||||
|
- GPU 클러스터 연동 및 리소스 관리
|
||||||
|
- 실험 추적 및 메트릭 저장 (MLflow Tracking)
|
||||||
|
- 모델 레지스트리 저장소 (S3/MinIO)
|
||||||
|
- CI/CD 기반 배포 파이프라인
|
||||||
|
- 모델 드리프트 자동 감지 및 재학습 트리거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-19: 대규모 언어모델(LLM) 운영 기능
|
||||||
|
|
||||||
|
**제안요청서 정의:** 대규모 언어모델(LLM)의 선택·학습·추론·튜닝 기능을 제공하여, 자연어 기반 AI 서비스의 운영 기반을 구축한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- LLM 모델 선택 및 관리
|
||||||
|
- Fine-tuning 학습 파이프라인
|
||||||
|
- 추론 서빙 및 로그 관리
|
||||||
|
- 하이퍼파라미터 튜닝(HPS)
|
||||||
|
|
||||||
|
**구현 화면:** WING AI 플랫폼 LLMOps 탭 (`src/features/ai-operations/MLOpsPage.tsx` — LLMOps 탭)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- LLMOps 5개 서브탭:
|
||||||
|
1. **학습(Train):** Fine-tuning Job 6건, 데이터셋·에포크·학습률 설정
|
||||||
|
2. **HPS:** 하이퍼파라미터 서치 설정 및 결과
|
||||||
|
3. **로그(Log):** 추론 로그, 토큰 사용량, 응답 시간
|
||||||
|
4. **워커(Worker):** GPU 워커 상태 모니터링
|
||||||
|
5. **LLM 테스트:** 모델별 프롬프트 테스트 인터페이스
|
||||||
|
- LLM 모델 목록: 6개 모델 (모델명, 파라미터 크기, 상태, 용도)
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: LLMOps 탭 제목/설명 적용 (MLOpsPage 공유)
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- LLM 서빙 인프라 (vLLM / TGI 등)
|
||||||
|
- Fine-tuning 학습 파이프라인 (LoRA/QLoRA)
|
||||||
|
- 하이퍼파라미터 자동 탐색 (Optuna 등)
|
||||||
|
- GPU 클러스터 연동 및 워커 관리
|
||||||
|
- 추론 로그 수집 및 분석
|
||||||
|
- LLM 모델 평가 벤치마크
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스
|
||||||
|
|
||||||
|
**제안요청서 정의:** 자연어 질의를 통해 법령·사례·AI 예측결과를 통합 검색하고, RAG 기반 대화형 의사결정 지원 서비스를 제공한다.
|
||||||
|
|
||||||
|
**세부 요구사항 요약:**
|
||||||
|
- RAG(검색 증강 생성) 기반 대화형 Q&A
|
||||||
|
- 법령 DB 연결 (수산업법, 해양경비법 등)
|
||||||
|
- 부적절 답변 필터링 (가드레일)
|
||||||
|
- 대화 이력 관리
|
||||||
|
|
||||||
|
**구현 화면:** AI 어시스턴트 (`src/features/ai-operations/AIAssistant.tsx`)
|
||||||
|
|
||||||
|
**화면 구성 요소:**
|
||||||
|
- 채팅 UI: 대화형 인터페이스 (사용자 질문 → AI 답변)
|
||||||
|
- 샘플 대화: 불법조업 관련 법령·절차·사례 질의응답 시연
|
||||||
|
- 대화 이력 사이드바: 과거 대화 세션 목록
|
||||||
|
- 법령 참조 뱃지: 답변에 인용된 법령·규정 출처 표시
|
||||||
|
- 관련 문서 링크
|
||||||
|
|
||||||
|
**구현 상태:** UI 완료
|
||||||
|
|
||||||
|
**통합 현황:**
|
||||||
|
- i18n: AI 어시스턴트 페이지 제목/설명 적용
|
||||||
|
- 테마: Dark/Light 전환 지원
|
||||||
|
|
||||||
|
**미구현 항목:**
|
||||||
|
- LLM 백엔드 서빙 (GPT-4 / LLaMA 등)
|
||||||
|
- RAG 파이프라인 (Vector DB + Embedding + Retriever)
|
||||||
|
- 법령 DB 구축 및 인덱싱 (수산업법, 해양경비법, EEZ법 등)
|
||||||
|
- 답변 품질 가드레일 (Hallucination 방지, 부적절 답변 필터)
|
||||||
|
- 대화 이력 DB 저장
|
||||||
|
- 사용자 피드백 수집 및 모델 개선 루프
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 파일 경로 매핑 요약
|
||||||
|
|
||||||
|
| SFR | 주요 파일 경로 |
|
||||||
|
|-----|--------------|
|
||||||
|
| SFR-01 | `src/features/auth/LoginPage.tsx`, `src/features/admin/AccessControl.tsx` |
|
||||||
|
| SFR-02 | `src/features/admin/SystemConfig.tsx`, `src/features/admin/NoticeManagement.tsx`, `src/shared/components/common/*` |
|
||||||
|
| SFR-03 | `src/features/admin/DataHub.tsx` |
|
||||||
|
| SFR-04 | `src/features/ai-operations/AIModelManagement.tsx` |
|
||||||
|
| SFR-05 | `src/features/risk-assessment/RiskMap.tsx` |
|
||||||
|
| SFR-06 | `src/features/risk-assessment/EnforcementPlan.tsx` |
|
||||||
|
| SFR-07 | `src/features/patrol/PatrolRoute.tsx` |
|
||||||
|
| SFR-08 | `src/features/patrol/FleetOptimization.tsx` |
|
||||||
|
| SFR-09 | `src/features/detection/DarkVesselDetection.tsx` |
|
||||||
|
| SFR-10 | `src/features/detection/GearDetection.tsx`, `src/features/detection/GearIdentification.tsx` |
|
||||||
|
| SFR-11 | `src/features/enforcement/EnforcementHistory.tsx`, `src/features/enforcement/EventList.tsx` |
|
||||||
|
| SFR-12 | `src/features/dashboard/Dashboard.tsx`, `src/features/monitoring/MonitoringDashboard.tsx` |
|
||||||
|
| SFR-13 | `src/features/statistics/Statistics.tsx` |
|
||||||
|
| SFR-14 | `src/features/statistics/ExternalService.tsx` |
|
||||||
|
| SFR-15 | `src/features/field-ops/MobileService.tsx` |
|
||||||
|
| SFR-16 | `src/features/field-ops/ShipAgent.tsx` |
|
||||||
|
| SFR-17 | `src/features/field-ops/AIAlert.tsx` |
|
||||||
|
| SFR-18 | `src/features/ai-operations/MLOpsPage.tsx` |
|
||||||
|
| SFR-19 | `src/features/ai-operations/MLOpsPage.tsx` (LLMOps 탭) |
|
||||||
|
| SFR-20 | `src/features/ai-operations/AIAssistant.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 공통 컴포넌트 매핑
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 경로 | 관련 SFR |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| DataTable | `src/shared/components/common/DataTable.tsx` | SFR-02, SFR-11, SFR-13 |
|
||||||
|
| ExcelExport | `src/shared/components/common/ExcelExport.tsx` | SFR-02 |
|
||||||
|
| Pagination | `src/shared/components/common/Pagination.tsx` | SFR-02 |
|
||||||
|
| SearchInput | `src/shared/components/common/SearchInput.tsx` | SFR-02 |
|
||||||
|
| FileUpload | `src/shared/components/common/FileUpload.tsx` | SFR-02 |
|
||||||
|
| PrintButton | `src/shared/components/common/PrintButton.tsx` | SFR-02, SFR-05 |
|
||||||
|
| SaveButton | `src/shared/components/common/SaveButton.tsx` | SFR-02 |
|
||||||
|
| NotificationBanner | `src/shared/components/common/NotificationBanner.tsx` | SFR-02 |
|
||||||
|
| PageToolbar | `src/shared/components/common/PageToolbar.tsx` | SFR-02 |
|
||||||
|
| Card / Badge | `src/shared/components/ui/card.tsx`, `badge.tsx` | 전체 SFR 공통 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: Zustand Store 매핑
|
||||||
|
|
||||||
|
| Store | 파일 경로 | 관련 SFR |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| kpiStore | `src/stores/kpiStore.ts` | SFR-12 |
|
||||||
|
| vesselStore | `src/stores/vesselStore.ts` | SFR-09, SFR-12 |
|
||||||
|
| eventStore | `src/stores/eventStore.ts` | SFR-11, SFR-12 |
|
||||||
|
| enforcementStore | `src/stores/enforcementStore.ts` | SFR-06, SFR-11 |
|
||||||
|
| patrolStore | `src/stores/patrolStore.ts` | SFR-07, SFR-08 |
|
||||||
|
| gearStore | `src/stores/gearStore.ts` | SFR-10 |
|
||||||
|
| transferStore | `src/stores/transferStore.ts` | SFR-09 |
|
||||||
|
| settingsStore | `src/stores/settingsStore.ts` | SFR-01, SFR-02 (테마/언어 설정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 2차 개발 단계 우선순위 제안
|
||||||
|
|
||||||
|
아래는 백엔드 연동 및 실제 기능 구현 시 권장하는 우선순위이다.
|
||||||
|
|
||||||
|
### 1순위 (핵심 인프라)
|
||||||
|
| SFR | 항목 | 사유 |
|
||||||
|
|-----|------|------|
|
||||||
|
| SFR-01 | SSO/GPKI 인증 연동 | 시스템 접근 보안의 기본 요건 |
|
||||||
|
| SFR-03 | 데이터 수집 파이프라인 | 전체 시스템의 데이터 기반 |
|
||||||
|
| SFR-02 | 공통 기능 백엔드 | CRUD, 파일관리 등 기반 기능 |
|
||||||
|
|
||||||
|
### 2순위 (AI 핵심 기능)
|
||||||
|
| SFR | 항목 | 사유 |
|
||||||
|
|-----|------|------|
|
||||||
|
| SFR-04 | AI 예측 모델 연동 | 시스템 핵심 가치 |
|
||||||
|
| SFR-09 | 패턴 탐지 엔진 | 불법조업 감시 핵심 |
|
||||||
|
| SFR-05 | 위험도 지도 실시간화 | 예측 결과 시각화 |
|
||||||
|
|
||||||
|
### 3순위 (운용 기능)
|
||||||
|
| SFR | 항목 | 사유 |
|
||||||
|
|-----|------|------|
|
||||||
|
| SFR-07 | 단일 함정 경로 최적화 | 현장 운용 직결 |
|
||||||
|
| SFR-08 | 다함정 협력 최적화 | 함대 운용 효율화 |
|
||||||
|
| SFR-06 | 단속 계획·경보 | 예측→대응 연계 |
|
||||||
|
| SFR-12 | 대시보드 실시간화 | 상황 인식 핵심 |
|
||||||
|
|
||||||
|
### 4순위 (확장 기능)
|
||||||
|
| SFR | 항목 | 사유 |
|
||||||
|
|-----|------|------|
|
||||||
|
| SFR-18 | MLOps 플랫폼 | AI 모델 운영 자동화 |
|
||||||
|
| SFR-19 | LLMOps | LLM 서비스 운영 |
|
||||||
|
| SFR-20 | AI Q&A 서비스 | 의사결정 지원 |
|
||||||
|
| SFR-10 | 어구 탐지 | 전문 탐지 모델 |
|
||||||
|
| SFR-11 | 이력 관리 DB | 데이터 축적 |
|
||||||
|
| SFR-13 | 통계 백엔드 | 성과 분석 |
|
||||||
|
| SFR-14 | 외부 연계 API | 유관기관 협업 |
|
||||||
|
| SFR-15 | 모바일 앱 | 현장 대응 |
|
||||||
|
| SFR-16 | 함정 Agent | 함정 단말 |
|
||||||
|
| SFR-17 | AI 알림 발송 | 즉각 대응 |
|
||||||
871
docs/sfr-user-guide.md
Normal file
871
docs/sfr-user-guide.md
Normal file
@ -0,0 +1,871 @@
|
|||||||
|
# SFR 요구사항별 화면 사용 가이드
|
||||||
|
|
||||||
|
> **문서 작성일:** 2026-04-06
|
||||||
|
> **시스템 버전:** v0.1.0 (프로토타입)
|
||||||
|
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
||||||
|
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 안내
|
||||||
|
|
||||||
|
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
||||||
|
|
||||||
|
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
- [SFR-01: 사용자 인증 및 접근 제어](#sfr-01-사용자-인증-및-접근-제어)
|
||||||
|
- [SFR-02: 시스템 공통기능 및 환경설정](#sfr-02-시스템-공통기능-및-환경설정)
|
||||||
|
- [SFR-03: 데이터 수집 허브](#sfr-03-데이터-수집-허브)
|
||||||
|
- [SFR-04: AI 모델 관리](#sfr-04-ai-모델-관리)
|
||||||
|
- [SFR-05: 위험도 분석 지도](#sfr-05-위험도-분석-지도)
|
||||||
|
- [SFR-06: 단속 계획 및 경보](#sfr-06-단속-계획-및-경보)
|
||||||
|
- [SFR-07: 단일함정 순찰경로 최적화](#sfr-07-단일함정-순찰경로-최적화)
|
||||||
|
- [SFR-08: 다함정 경로 최적화](#sfr-08-다함정-경로-최적화)
|
||||||
|
- [SFR-09: Dark Vessel 탐지](#sfr-09-dark-vessel-탐지)
|
||||||
|
- [SFR-10: 어망/어구 탐지](#sfr-10-어망어구-탐지)
|
||||||
|
- [SFR-11: 단속/탐지 이력 관리](#sfr-11-단속탐지-이력-관리)
|
||||||
|
- [SFR-12: 종합 상황판 및 경보 현황판](#sfr-12-종합-상황판-및-경보-현황판)
|
||||||
|
- [SFR-13: 통계 및 성과 분석](#sfr-13-통계-및-성과-분석)
|
||||||
|
- [SFR-14: 외부 서비스 연계](#sfr-14-외부-서비스-연계)
|
||||||
|
- [SFR-15: 모바일 서비스](#sfr-15-모바일-서비스)
|
||||||
|
- [SFR-16: 함정 Agent](#sfr-16-함정-agent)
|
||||||
|
- [SFR-17: AI 알림 발송](#sfr-17-ai-알림-발송)
|
||||||
|
- [SFR-18/19: MLOps 플랫폼](#sfr-1819-mlops-플랫폼)
|
||||||
|
- [SFR-20: AI 의사결정 지원](#sfr-20-ai-의사결정-지원)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-01: 사용자 인증 및 접근 제어
|
||||||
|
|
||||||
|
### 로그인
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 접속 시 최초 화면
|
||||||
|
**URL:** `/login`
|
||||||
|
**접근 권한:** 모든 사용자
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템에 접속하기 위한 로그인 화면입니다. 사용자 ID와 비밀번호를 입력하여 로그인할 수 있으며, 역할별로 5개의 데모 계정이 제공됩니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 사용자 ID/비밀번호 입력을 통한 로그인
|
||||||
|
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
||||||
|
- 로그인 후 역할에 따른 메뉴 접근 제어
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
|
||||||
|
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 SSO(Single Sign-On) 연동
|
||||||
|
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
||||||
|
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 권한 관리
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 권한 관리
|
||||||
|
**URL:** `/access-control`
|
||||||
|
**접근 권한:** ADMIN
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템 사용자의 역할(Role)과 권한을 관리하는 화면입니다. RBAC(역할 기반 접근 제어) 방식으로 5가지 역할을 정의하고 각 역할별 메뉴 접근 권한을 설정할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 5가지 역할(ADMIN, OPERATOR, ANALYST, FIELD, VIEWER) 조회
|
||||||
|
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
||||||
|
- 사용자 목록 조회 및 역할 할당
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
|
||||||
|
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
|
||||||
|
- 🔲 감사 로그(권한 변경 이력) 기록
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-02: 시스템 공통기능 및 환경설정
|
||||||
|
|
||||||
|
### 환경설정
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 환경설정
|
||||||
|
**URL:** `/system-config`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템 운영에 필요한 기준정보를 조회하고 관리하는 화면입니다. 875건의 기준정보 항목이 표시되며, 검색 및 필터링이 가능합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 기준정보 875건 목록 조회 및 검색
|
||||||
|
- 카테고리별 필터링
|
||||||
|
- 설정값 수정 UI
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 875건 기준정보 목록 표시 및 검색/필터 UI
|
||||||
|
- ✅ 공통 컴포넌트 적용 (DataTable, 엑셀 내보내기, 인쇄 기능)
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 기준정보 DB 연동 (조회/수정/저장)
|
||||||
|
- 🔲 설정 변경 이력 관리
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 표시 데이터는 샘플이며, DB 연동 후 실제 운영 데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 공지사항
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 공지사항
|
||||||
|
**URL:** `/notices`
|
||||||
|
**접근 권한:** ADMIN (작성/수정/삭제), 전체 역할 (조회)
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템 운영 관련 공지사항을 등록하고 조회하는 게시판입니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 공지사항 목록 조회
|
||||||
|
- 공지사항 작성, 수정, 삭제 (CRUD)
|
||||||
|
- 공지사항 상세 조회
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 공지사항 CRUD UI 완성
|
||||||
|
- ✅ 목록/상세/작성/수정 화면 전환
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 공지사항 DB 연동
|
||||||
|
- 🔲 첨부파일 업로드/다운로드
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 작성한 공지사항은 새로고침 시 초기화됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 이벤트 목록
|
||||||
|
|
||||||
|
**메뉴 위치:** 단속/이력 > 이벤트 목록
|
||||||
|
**URL:** `/event-list`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템에서 발생한 각종 이벤트(탐지, 경보, 알림 등)를 통합 목록으로 조회하는 화면입니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 이벤트 유형별 필터링 및 검색
|
||||||
|
- 이벤트 상세 정보 조회
|
||||||
|
- 이벤트 목록 엑셀 내보내기
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 이벤트 통합 목록 UI 및 필터/검색
|
||||||
|
- ✅ DataTable 공통 컴포넌트 적용 (정렬, 페이징, 엑셀, 인쇄)
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실시간 이벤트 수신 및 목록 자동 갱신
|
||||||
|
- 🔲 이벤트 DB 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 목록은 샘플 데이터이며, 실시간 연동 후 자동 업데이트 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-03: 데이터 수집 허브
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 데이터 허브
|
||||||
|
**URL:** `/data-hub`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
외부 데이터 소스(AIS, LRIT, SAR, CCTV, VMS 등 5개 신호원)로부터 데이터를 수집하는 현황을 모니터링하는 화면입니다. 22개 수집 채널의 상태를 실시간으로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 5개 신호원별 수집 상태 대시보드
|
||||||
|
- 22개 수집 채널별 연결 상태, 수신량, 오류율 조회
|
||||||
|
- 채널별 상세 수집 이력 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 5개 신호원 + 22개 수집 채널 현황 대시보드 UI
|
||||||
|
- ✅ 채널별 상태(정상/경고/오류) 시각화
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 데이터 수집 엔진 연동
|
||||||
|
- 🔲 수집 채널 실시간 상태 모니터링
|
||||||
|
- 🔲 수집 데이터 품질 검증 기능
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 모든 수집 현황은 샘플 데이터이며, 실제 신호원 연결 후 실데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-04: AI 모델 관리
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > AI 모델관리
|
||||||
|
**URL:** `/ai-model`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
AI 탐지 모델의 버전, 탐지 규칙, 입력 피처(Feature), 학습 파이프라인을 관리하는 화면입니다. 모델의 성능을 확인하고 운영 중인 모델을 교체하거나 새 버전을 등록할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- AI 모델 5개 버전 목록 및 상세 정보 조회
|
||||||
|
- 6개 탐지 규칙 관리 (임계값, 조건 설정)
|
||||||
|
- 20개 입력 피처 목록 및 중요도 확인
|
||||||
|
- 학습 파이프라인 실행 현황 조회
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 모델 버전 관리 UI (5버전 목록, 성능지표 비교)
|
||||||
|
- ✅ 탐지 규칙 6건 설정 화면
|
||||||
|
- ✅ 피처 20건 목록 및 중요도 시각화
|
||||||
|
- ✅ 파이프라인 현황 표시
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 AI 모델 서버 연동 (모델 등록/배포/롤백)
|
||||||
|
- 🔲 모델 학습 파이프라인 실행 연동
|
||||||
|
- 🔲 모델 성능 모니터링 실시간 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 모델 성능 지표는 샘플 데이터이며, 실제 모델 연동 후 정확한 지표로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-05: 위험도 분석 지도
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 위험도 지도
|
||||||
|
**URL:** `/risk-map`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
해역별 불법조업 위험도를 히트맵(열지도) 형태로 시각화한 화면입니다. 10x18 격자로 분할된 관할 해역의 위험도를 색상으로 표현하며, MTIS 해양사고 통계를 6개 탭으로 분류하여 제공합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 10x18 격자 기반 위험도 히트맵 지도 표시
|
||||||
|
- 격자별 상세 위험도 정보 팝업
|
||||||
|
- MTIS 해양사고 통계 6탭 (사고유형별, 해역별, 월별 등)
|
||||||
|
- 기간별 위험도 변화 추이 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 위험도 히트맵 10x18 격자 시각화
|
||||||
|
- ✅ MTIS 해양사고 통계 6탭 UI
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 위험도 예측 모델 연동
|
||||||
|
- 🔲 실시간 AIS/VMS 데이터 기반 동적 위험도 갱신
|
||||||
|
- 🔲 과거 데이터 기반 위험도 예측 기능
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 히트맵 데이터는 샘플이며, 예측 모델 연동 후 정확한 위험도로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-06: 단속 계획 및 경보
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 단속 계획/경보
|
||||||
|
**URL:** `/enforcement-plan`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
불법조업 단속 계획을 수립하고, 경보 발생 조건(임계값)을 설정하는 화면입니다. 현재 등록된 5건의 단속 계획과 경보 조건을 확인하고 관리할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 단속 계획 5건 목록 조회 및 상세 확인
|
||||||
|
- 신규 단속 계획 작성 및 수정
|
||||||
|
- 경보 임계값(위험도 기준, 선박 수 기준 등) 설정
|
||||||
|
- 단속 계획별 투입 함정/인력 배치 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 단속 계획 5건 목록/상세 UI
|
||||||
|
- ✅ 경보 임계값 설정 화면
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 기반 단속 계획 자동 추천
|
||||||
|
- 🔲 경보 임계값 도달 시 자동 경보 발생
|
||||||
|
- 🔲 단속 계획 DB 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 단속 계획 데이터는 샘플이며, 자동 추천 및 경보 기능은 AI 모델 연동 후 구현 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-07: 단일함정 순찰경로 최적화
|
||||||
|
|
||||||
|
**메뉴 위치:** 현장 대응 > 단일함정 순찰경로
|
||||||
|
**URL:** `/patrol-route`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, FIELD
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
개별 함정(경비함)의 순찰 경로를 지도 위에 표시하고, AI가 최적화한 경로를 제안하는 화면입니다. 4척의 함정별 경로와 3가지 시나리오(최단거리, 위험지역 우선, 연료절감)를 비교할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 4척 함정별 순찰 경로 지도 표시
|
||||||
|
- 3가지 시나리오별 경로 비교
|
||||||
|
- 경유지 추가/제거를 통한 수동 경로 조정
|
||||||
|
- 예상 소요시간, 연료소모량, 해역 커버리지 정보 표시
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 함정 4척 경로 지도 시각화
|
||||||
|
- ✅ 3가지 시나리오 비교 UI
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 경로 최적화 엔진 연동
|
||||||
|
- 🔲 실시간 함정 위치 반영
|
||||||
|
- 🔲 기상/해황 정보 반영 경로 재계산
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 경로는 샘플 데이터이며, AI 최적화 엔진 연동 후 실제 최적 경로 제공 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-08: 다함정 경로 최적화
|
||||||
|
|
||||||
|
**메뉴 위치:** 현장 대응 > 다함정 경로최적화
|
||||||
|
**URL:** `/fleet-optimization`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
여러 함정을 동시에 운용할 때 전체 해역 커버리지를 최대화하는 최적 경로를 계산하는 화면입니다. 5척의 함정이 6개 구역을 효율적으로 분담 순찰하도록 배치합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 5척 함정의 담당 구역 배정 및 지도 시각화
|
||||||
|
- 6개 구역별 커버리지율 표시
|
||||||
|
- 전체 해역 커버리지 최적화 결과 확인
|
||||||
|
- 함정별 순찰 스케줄 표시
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 5척 함정 배치 및 6구역 커버리지 시각화
|
||||||
|
- ✅ 구역별 커버리지율 표시 UI
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 다함정 경로 최적화 알고리즘 연동
|
||||||
|
- 🔲 실시간 함정 위치 기반 동적 재배치
|
||||||
|
- 🔲 기상/해황 조건 반영 최적화
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 배치 결과는 샘플 데이터이며, AI 최적화 알고리즘 연동 후 실제 최적 배치로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-09: Dark Vessel 탐지
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > Dark Vessel 탐지
|
||||||
|
**URL:** `/dark-vessel`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark Vessel)을 탐지하는 화면입니다. 7척의 의심 선박과 5가지 행동 패턴 분석 결과를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- Dark Vessel 의심 선박 7척 목록 및 지도 표시
|
||||||
|
- 5가지 의심 패턴(AIS 소실, 속도 급변, 해역 이탈 등) 분석 결과
|
||||||
|
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
||||||
|
- 위험도 등급별 분류 표시
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 의심 선박 7척 목록/지도 시각화
|
||||||
|
- ✅ 5가지 행동 패턴 분석 결과 UI
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI Dark Vessel 탐지 엔진 연동
|
||||||
|
- 🔲 실시간 AIS 데이터 분석 연동
|
||||||
|
- 🔲 SAR(위성영상) 기반 탐지 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 중국어선 분석
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 중국어선 분석
|
||||||
|
**URL:** `/china-fishing`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
중국 어선의 불법조업 활동을 집중 분석하는 화면입니다. Dark Vessel 탐지(SFR-09) 및 어구 탐지(SFR-10) 결과를 중국 어선에 특화하여 종합 분석합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 중국 어선 활동 현황 지도 표시
|
||||||
|
- 불법조업 의심 어선 목록 조회
|
||||||
|
- 해역별 중국 어선 밀집도 분석
|
||||||
|
- 시계열 활동 패턴 분석
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 중국 어선 분석 종합 대시보드 UI
|
||||||
|
- ✅ 지도 기반 활동 현황 시각화
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
|
||||||
|
- 🔲 실시간 데이터 기반 분석 갱신
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-10: 어망/어구 탐지
|
||||||
|
|
||||||
|
**메뉴 위치:** 탐지/분석 > 어망/어구 탐지
|
||||||
|
**URL:** `/gear-detection`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
불법 어망 및 어구를 탐지하고 분류하는 화면입니다. 6건의 탐지 결과와 어구 종류를 식별하는 결정트리(Decision Tree) 분석 결과를 제공합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 어구 탐지 6건 목록 및 지도 표시
|
||||||
|
- 어구 종류별 식별 결정트리 시각화
|
||||||
|
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
||||||
|
- 탐지 이미지 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 어구 6건 탐지 결과 목록/지도 UI
|
||||||
|
- ✅ 어구 식별 결정트리 시각화
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
|
||||||
|
- 🔲 실시간 CCTV/SAR 영상 분석 연동
|
||||||
|
- 🔲 탐지 결과 자동 분류 및 알림
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-11: 단속/탐지 이력 관리
|
||||||
|
|
||||||
|
**메뉴 위치:** 단속/이력 > 단속/탐지 이력
|
||||||
|
**URL:** `/enforcement-history`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
과거 단속 및 탐지 활동의 이력을 조회하고 관리하는 화면입니다. 6건의 단속 이력과 AI 매칭 검증 결과를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 단속 이력 6건 목록 조회 (날짜, 위치, 대상 선박, 결과)
|
||||||
|
- AI 매칭 검증 — 탐지 결과와 실제 단속 결과 비교 분석
|
||||||
|
- 이력 상세 정보 조회 및 검색/필터
|
||||||
|
- 이력 데이터 엑셀 내보내기
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 단속 이력 6건 목록/상세 UI
|
||||||
|
- ✅ AI 매칭 검증 결과 표시
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
|
||||||
|
- 🔲 AI 매칭 검증 엔진 연동
|
||||||
|
- 🔲 탐지-단속 연계 자동 분석
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-12: 종합 상황판 및 경보 현황판
|
||||||
|
|
||||||
|
### 종합 상황판
|
||||||
|
|
||||||
|
**메뉴 위치:** 모니터링 > 종합 상황판
|
||||||
|
**URL:** `/dashboard`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템의 전체 운영 현황을 한눈에 파악할 수 있는 종합 대시보드입니다. KPI(핵심 성과 지표), 해역 위험도 히트맵, 이벤트 타임라인, 함정 배치 현황 등이 통합 표시됩니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- KPI 카드 (탐지 건수, 정확도, 대응 시간 등) 표시
|
||||||
|
- 해역 위험도 히트맵 축소판
|
||||||
|
- 최근 이벤트 타임라인
|
||||||
|
- 함정 배치 현황 요약
|
||||||
|
- 실시간 경보 알림 표시
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
|
||||||
|
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실시간 데이터 연동 (WebSocket 등)
|
||||||
|
- 🔲 KPI 수치 실시간 갱신
|
||||||
|
- 🔲 히트맵/타임라인 실시간 업데이트
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 경보 현황판
|
||||||
|
|
||||||
|
**메뉴 위치:** 모니터링 > 경보 현황판
|
||||||
|
**URL:** `/monitoring`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
현재 발생 중인 경보를 중심으로 긴급 상황을 모니터링하는 화면입니다. 경보 등급별 분류, 미처리 경보 목록, 경보 상세 정보를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 경보 등급별(긴급/경고/주의/정보) 현황 표시
|
||||||
|
- 미처리 경보 목록 및 상세 조회
|
||||||
|
- 경보 처리(확인/대응/종결) 워크플로우
|
||||||
|
- 경보 발생 이력 조회
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 경보 등급별 현황판 UI
|
||||||
|
- ✅ 경보 목록/상세 조회 화면
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실시간 경보 수신 연동
|
||||||
|
- 🔲 경보 처리 워크플로우 DB 연동
|
||||||
|
- 🔲 경보 자동 에스컬레이션
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 실시간 감시
|
||||||
|
|
||||||
|
**메뉴 위치:** 모니터링 > 실시간 감시
|
||||||
|
**URL:** `/events`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
실시간 지도(LiveMap)를 통해 해역 상황을 감시하는 화면입니다. 선박 위치, 이벤트 발생 지점, 함정 위치 등을 실시간으로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 실시간 지도 기반 선박/함정 위치 표시
|
||||||
|
- 이벤트 발생 시 지도 상 알림 표시
|
||||||
|
- 선박/이벤트 클릭 시 상세 정보 팝업
|
||||||
|
- 지도 확대/축소 및 해역 필터링
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ LiveMap 기반 실시간 감시 지도 UI
|
||||||
|
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실시간 AIS/VMS 데이터 연동
|
||||||
|
- 🔲 WebSocket 기반 실시간 위치 업데이트
|
||||||
|
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 해역 통제
|
||||||
|
|
||||||
|
**메뉴 위치:** 모니터링 > 해역 통제
|
||||||
|
**URL:** `/map-control`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
특정 해역에 대한 통제 구역을 설정하고 관리하는 지도 기반 화면입니다. 통제 구역 진입 선박을 감시할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 통제 구역 지도 상 설정 및 표시
|
||||||
|
- 통제 구역 진입/이탈 선박 모니터링
|
||||||
|
- 통제 구역 관리 (생성/수정/삭제)
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 지도 기반 해역 통제 구역 표시 UI
|
||||||
|
- ✅ 통제 구역 관리 인터페이스
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실시간 선박 위치 기반 진입 감시
|
||||||
|
- 🔲 통제 구역 위반 자동 경보
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 통제 구역 데이터는 샘플이며, 실시간 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-13: 통계 및 성과 분석
|
||||||
|
|
||||||
|
**메뉴 위치:** 통계/보고 > 통계/성과 분석
|
||||||
|
**URL:** `/statistics`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템의 운영 성과를 통계적으로 분석하는 화면입니다. 월별 추이 그래프와 5개 KPI(탐지 정확도 93.2%, 오탐율 7.8% 등)를 제공합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 월별 탐지/단속 추이 그래프
|
||||||
|
- KPI 5개 지표 대시보드 (탐지 정확도, 오탐율, 대응 시간, 커버리지, 가동률)
|
||||||
|
- 기간별/해역별/유형별 필터링
|
||||||
|
- 통계 데이터 엑셀 내보내기 및 인쇄
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
|
||||||
|
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 통계 데이터 DB 연동
|
||||||
|
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
|
||||||
|
- 🔲 맞춤형 보고서 생성 기능
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 보고서 관리
|
||||||
|
|
||||||
|
**메뉴 위치:** 통계/보고 > 보고서 관리
|
||||||
|
**URL:** `/reports`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템 운영 보고서를 생성하고 관리하는 화면입니다. 정기/비정기 보고서를 작성하고 조회할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 보고서 목록 조회 및 검색
|
||||||
|
- 보고서 작성/수정/삭제
|
||||||
|
- 보고서 템플릿 관리
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 보고서 관리 UI 및 목록/상세 화면
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 보고서 DB 연동
|
||||||
|
- 🔲 통계 데이터 자동 삽입
|
||||||
|
- 🔲 PDF 보고서 생성 및 다운로드
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 보고서 데이터는 샘플이며, DB 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-14: 외부 서비스 연계
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 외부 서비스 연계
|
||||||
|
**URL:** `/external-service`
|
||||||
|
**접근 권한:** ADMIN
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
외부 시스템과의 API 연계 현황을 관리하는 화면입니다. 5개 외부 서비스(해양경찰청 통합시스템, GICOMS, 해양교통관제 등)의 API 연결 상태를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 외부 서비스 5건 연계 현황 대시보드
|
||||||
|
- 서비스별 API 연결 상태(정상/오류) 확인
|
||||||
|
- API 호출 이력 및 오류 로그 조회
|
||||||
|
- 연계 설정 관리
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 외부 서비스 5건 연계 현황 UI
|
||||||
|
- ✅ API 상태 표시 대시보드
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 외부 API 연동 구현
|
||||||
|
- 🔲 API 헬스체크 자동화
|
||||||
|
- 🔲 장애 시 자동 알림 및 재연결
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 API 상태는 샘플 데이터이며, 실제 API 연동 후 실시간 상태 표시 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-15: 모바일 서비스
|
||||||
|
|
||||||
|
**메뉴 위치:** 현장 대응 > 모바일 서비스
|
||||||
|
**URL:** `/mobile-service`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, FIELD
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
현장 요원이 사용할 모바일 앱의 화면을 웹에서 미리보기(프리뷰)하는 시뮬레이션 화면입니다. 실제 모바일 앱이 아닌 웹 기반 시뮬레이션으로, 모바일 앱의 기능과 화면 구성을 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 모바일 앱 화면 프리뷰 (웹 시뮬레이션)
|
||||||
|
- 주요 기능 화면 미리보기 (경보 수신, 위치 공유, 보고서 작성 등)
|
||||||
|
- 모바일 레이아웃 시뮬레이션
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 모바일 앱 프리뷰 화면 (웹 시뮬레이션)
|
||||||
|
- ✅ 주요 기능별 화면 미리보기
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 모바일 앱(iOS/Android) 개발
|
||||||
|
- 🔲 Push 알림 연동
|
||||||
|
- 🔲 오프라인 모드 지원
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재는 웹 시뮬레이션으로, 실제 모바일 앱과 UI/UX가 다를 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-16: 함정 Agent
|
||||||
|
|
||||||
|
**메뉴 위치:** 현장 대응 > 함정 Agent
|
||||||
|
**URL:** `/ship-agent`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
각 경비함에 설치되는 Agent 소프트웨어의 상태를 모니터링하는 화면입니다. 6건의 함정 Agent 상태(온라인/오프라인, 버전, 마지막 통신 시간 등)를 확인할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 함정 Agent 6건 상태 목록 조회
|
||||||
|
- Agent별 온라인/오프라인 상태 확인
|
||||||
|
- Agent 소프트웨어 버전 및 업데이트 현황
|
||||||
|
- 마지막 통신 시간 및 통신 이력 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 함정 Agent 6건 상태 모니터링 UI
|
||||||
|
- ✅ 상태별 시각적 표시 (온라인/오프라인)
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 Agent 소프트웨어 개발 및 배포
|
||||||
|
- 🔲 Agent 실시간 통신 연동
|
||||||
|
- 🔲 Agent 원격 업데이트 기능
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 Agent 상태는 샘플 데이터이며, 실제 Agent SW 개발 후 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-17: AI 알림 발송
|
||||||
|
|
||||||
|
**메뉴 위치:** 현장 대응 > AI 알림 발송
|
||||||
|
**URL:** `/ai-alert`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송하는 화면입니다. 5건의 알림 전송 현황을 확인하고 새로운 알림을 발송할 수 있습니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- AI 알림 5건 전송 현황 조회
|
||||||
|
- 알림 유형별(긴급/일반/정보) 분류
|
||||||
|
- 알림 수신자 설정 및 발송
|
||||||
|
- 알림 전송 결과(성공/실패) 확인
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 알림 5건 전송 현황 UI
|
||||||
|
- ✅ 알림 유형별 분류 및 상세 조회
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
|
||||||
|
- 🔲 AI 분석 결과 기반 자동 알림 트리거
|
||||||
|
- 🔲 알림 발송 이력 DB 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-18/19: MLOps 플랫폼
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > MLOps
|
||||||
|
**URL:** `/mlops`
|
||||||
|
**접근 권한:** ADMIN
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
AI 모델의 전체 생명주기(실험, 학습, 배포, 모니터링)를 관리하는 MLOps 플랫폼 화면입니다. 7개 탭으로 구성되어 실험 관리, 모델 레지스트리, 배포 관리, API 서빙, LLMOps 등의 기능을 제공합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 실험 관리 — AI 모델 학습 실험 기록 및 비교
|
||||||
|
- 모델 레지스트리 — 학습된 모델 버전 관리
|
||||||
|
- 배포 관리 — 모델 서빙 환경 배포 및 모니터링
|
||||||
|
- API 서빙 — 모델 추론 API 관리
|
||||||
|
- LLMOps — 대규모 언어 모델(LLM) 운영 관리
|
||||||
|
- 모니터링 — 모델 성능 드리프트 감시
|
||||||
|
- 파이프라인 — 자동화된 학습/배포 파이프라인 관리
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ MLOps 7탭 UI (실험/모델/배포/API/LLMOps/모니터링/파이프라인)
|
||||||
|
- ✅ 각 탭별 상세 관리 화면
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 MLOps 인프라 연동 (MLflow, Kubeflow 등)
|
||||||
|
- 🔲 모델 학습/배포 파이프라인 자동화
|
||||||
|
- 🔲 모델 성능 모니터링 실시간 연동
|
||||||
|
- 🔲 LLM 운영 관리 기능 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 모든 데이터는 샘플이며, MLOps 인프라 구축 후 실제 데이터로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SFR-20: AI 의사결정 지원
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > AI 의사결정 지원
|
||||||
|
**URL:** `/ai-assistant`
|
||||||
|
**접근 권한:** ADMIN, OPERATOR, ANALYST
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이스입니다. RAG(검색 증강 생성) 기술을 활용하여 관련 법령, 규정, 매뉴얼을 참조한 답변을 제공합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- AI Q&A 채팅 UI (질문 입력 및 답변 표시)
|
||||||
|
- RAG 기반 법령/규정 참조 답변 (참조 출처 표시)
|
||||||
|
- 대화 이력 관리
|
||||||
|
- 답변 품질 피드백 (유용함/유용하지 않음)
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ AI Q&A 채팅 UI
|
||||||
|
- ✅ RAG 법령 참조 답변 표시 화면
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 LLM(대규모 언어 모델) 백엔드 서버 연동
|
||||||
|
- 🔲 RAG 시스템 구축 (법령/규정/매뉴얼 벡터 DB)
|
||||||
|
- 🔲 답변 품질 개선을 위한 Fine-tuning
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 답변은 사전 작성된 샘플이며, LLM 백엔드 연동 후 실제 AI 답변으로 교체 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 시스템 관리 메뉴
|
||||||
|
|
||||||
|
### 시스템 관리
|
||||||
|
|
||||||
|
**메뉴 위치:** 시스템 관리 > 시스템 관리
|
||||||
|
**URL:** `/admin`
|
||||||
|
**접근 권한:** ADMIN
|
||||||
|
|
||||||
|
**화면 설명:**
|
||||||
|
시스템 전반의 관리 기능을 제공하는 화면입니다. 서버 상태, 로그 조회, 사용자 관리 등 시스템 운영에 필요한 관리 기능을 포함합니다.
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 시스템 상태 모니터링
|
||||||
|
- 사용자 계정 관리
|
||||||
|
- 시스템 로그 조회
|
||||||
|
|
||||||
|
**구현 완료:**
|
||||||
|
- ✅ 시스템 관리 기본 UI
|
||||||
|
|
||||||
|
**향후 구현 예정:**
|
||||||
|
- 🔲 실제 서버 상태 모니터링 연동
|
||||||
|
- 🔲 사용자 관리 DB 연동
|
||||||
|
- 🔲 시스템 로그 수집/조회 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 역할(Role)별 접근 권한 요약
|
||||||
|
|
||||||
|
| 역할 | 설명 | 접근 가능 주요 메뉴 |
|
||||||
|
|------|------|---------------------|
|
||||||
|
| **ADMIN** | 시스템 관리자 | 모든 메뉴 접근 가능 |
|
||||||
|
| **OPERATOR** | 운영 담당자 | 모니터링, 탐지/분석, 단속/이력, 통계/보고, 현장 대응, 일부 시스템 관리 |
|
||||||
|
| **ANALYST** | 분석 담당자 | 모니터링, 탐지/분석, 단속/이력, 통계/보고, AI 의사결정 지원 |
|
||||||
|
| **FIELD** | 현장 요원 | 종합 상황판, 실시간 감시, 순찰경로, 모바일 서비스 |
|
||||||
|
| **VIEWER** | 조회 전용 | 종합 상황판 (조회만 가능) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 현재 시스템 상태 요약
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| UI 구현 | 모든 SFR 완료 |
|
||||||
|
| 백엔드 연동 | 미구현 (전체) |
|
||||||
|
| 데이터 | 시연용 샘플 데이터 |
|
||||||
|
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
|
||||||
|
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
|
||||||
|
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
|
||||||
|
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
|
||||||
|
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |
|
||||||
30
eslint.config.js
Normal file
30
eslint.config.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-expressions': 'off',
|
||||||
|
'prefer-const': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 기반 선박 모니터링 시스템</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
5087
package-lock.json
generated
Normal file
5087
package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
49
package.json
Normal file
49
package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "kcg-ai-monitoring",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@deck.gl/mapbox": "^9.2.11",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"deck.gl": "^9.2.11",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"i18next": "^26.0.3",
|
||||||
|
"lucide-react": "0.487.0",
|
||||||
|
"maplibre-gl": "^5.22.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-i18next": "^17.0.2",
|
||||||
|
"react-router-dom": "^7.12.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "5.9",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
postcss.config.mjs
Normal file
15
postcss.config.mjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* PostCSS Configuration
|
||||||
|
*
|
||||||
|
* Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
|
||||||
|
* PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
|
||||||
|
*
|
||||||
|
* This file only exists for adding additional PostCSS plugins, if needed.
|
||||||
|
* For example:
|
||||||
|
*
|
||||||
|
* import postcssNested from 'postcss-nested'
|
||||||
|
* export default { plugins: [postcssNested()] }
|
||||||
|
*
|
||||||
|
* Otherwise, you can leave this file empty.
|
||||||
|
*/
|
||||||
|
export default {}
|
||||||
92
src/app/App.tsx
Normal file
92
src/app/App.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
|
||||||
|
import { MainLayout } from '@/app/layout/MainLayout';
|
||||||
|
import { LoginPage } from '@features/auth';
|
||||||
|
/* SFR-01 */ import { AccessControl } from '@features/admin';
|
||||||
|
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin';
|
||||||
|
/* SFR-03 */ import { DataHub } from '@features/admin';
|
||||||
|
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations';
|
||||||
|
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment';
|
||||||
|
/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment';
|
||||||
|
/* SFR-07 */ import { PatrolRoute } from '@features/patrol';
|
||||||
|
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
|
||||||
|
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
|
||||||
|
/* SFR-10 */ import { GearDetection } from '@features/detection';
|
||||||
|
/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement';
|
||||||
|
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
|
||||||
|
/* SFR-13 */ import { Statistics } from '@features/statistics';
|
||||||
|
/* SFR-14 */ import { ExternalService } from '@features/statistics';
|
||||||
|
/* SFR-15 */ import { MobileService } from '@features/field-ops';
|
||||||
|
/* SFR-16 */ import { ShipAgent } from '@features/field-ops';
|
||||||
|
/* SFR-17 */ import { AIAlert } from '@features/field-ops';
|
||||||
|
/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations';
|
||||||
|
/* SFR-20 */ import { AIAssistant } from '@features/ai-operations';
|
||||||
|
/* 기존 */ import { Dashboard } from '@features/dashboard';
|
||||||
|
import { LiveMapView, MapControl } from '@features/surveillance';
|
||||||
|
import { EventList } from '@features/enforcement';
|
||||||
|
import { VesselDetail } from '@features/vessel';
|
||||||
|
import { ChinaFishing } from '@features/detection';
|
||||||
|
import { ReportManagement } from '@features/statistics';
|
||||||
|
import { AdminPanel } from '@features/admin';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
{/* SFR-12 대시보드 */}
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="monitoring" element={<MonitoringDashboard />} />
|
||||||
|
{/* SFR-05~06 위험도·단속계획 */}
|
||||||
|
<Route path="risk-map" element={<RiskMap />} />
|
||||||
|
<Route path="enforcement-plan" element={<EnforcementPlan />} />
|
||||||
|
{/* SFR-09~10 탐지 */}
|
||||||
|
<Route path="dark-vessel" element={<DarkVesselDetection />} />
|
||||||
|
<Route path="gear-detection" element={<GearDetection />} />
|
||||||
|
<Route path="china-fishing" element={<ChinaFishing />} />
|
||||||
|
{/* SFR-07~08 순찰경로 */}
|
||||||
|
<Route path="patrol-route" element={<PatrolRoute />} />
|
||||||
|
<Route path="fleet-optimization" element={<FleetOptimization />} />
|
||||||
|
{/* SFR-11 이력 */}
|
||||||
|
<Route path="enforcement-history" element={<EnforcementHistory />} />
|
||||||
|
<Route path="event-list" element={<EventList />} />
|
||||||
|
{/* SFR-15~17 현장 대응 */}
|
||||||
|
<Route path="mobile-service" element={<MobileService />} />
|
||||||
|
<Route path="ship-agent" element={<ShipAgent />} />
|
||||||
|
<Route path="ai-alert" element={<AIAlert />} />
|
||||||
|
{/* SFR-13~14 통계·외부연계 */}
|
||||||
|
<Route path="statistics" element={<Statistics />} />
|
||||||
|
<Route path="external-service" element={<ExternalService />} />
|
||||||
|
<Route path="reports" element={<ReportManagement />} />
|
||||||
|
{/* SFR-04 AI 모델 */}
|
||||||
|
<Route path="ai-model" element={<AIModelManagement />} />
|
||||||
|
{/* SFR-18~20 AI 운영 */}
|
||||||
|
<Route path="mlops" element={<MLOpsPage />} />
|
||||||
|
<Route path="ai-assistant" element={<AIAssistant />} />
|
||||||
|
{/* SFR-03 데이터허브 */}
|
||||||
|
<Route path="data-hub" element={<DataHub />} />
|
||||||
|
{/* SFR-02 환경설정 */}
|
||||||
|
<Route path="system-config" element={<SystemConfig />} />
|
||||||
|
<Route path="notices" element={<NoticeManagement />} />
|
||||||
|
{/* SFR-01 권한·시스템 */}
|
||||||
|
<Route path="access-control" element={<AccessControl />} />
|
||||||
|
<Route path="admin" element={<AdminPanel />} />
|
||||||
|
{/* 기존 유지 */}
|
||||||
|
<Route path="events" element={<LiveMapView />} />
|
||||||
|
<Route path="map-control" element={<MapControl />} />
|
||||||
|
<Route path="vessel/:id" element={<VesselDetail />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/app/auth/AuthContext.tsx
Normal file
166
src/app/auth/AuthContext.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-01: 시스템 로그인 및 권한 관리
|
||||||
|
* - 역할 기반 권한 관리(RBAC)
|
||||||
|
* - 세션 타임아웃(30분 미사용 시 자동 로그아웃)
|
||||||
|
* - 동시 접속 1계정 1세션
|
||||||
|
* - 감사 로그 기록
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── RBAC 역할 정의 ─────────────────────
|
||||||
|
export type UserRole = 'ADMIN' | 'OPERATOR' | 'ANALYST' | 'FIELD' | 'VIEWER';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rank: string;
|
||||||
|
org: string;
|
||||||
|
role: UserRole;
|
||||||
|
authMethod: 'password' | 'gpki' | 'sso';
|
||||||
|
loginAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 역할별 접근 가능 경로 ──────────────────
|
||||||
|
const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
|
||||||
|
ADMIN: [
|
||||||
|
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
|
||||||
|
'/risk-map', '/enforcement-plan',
|
||||||
|
'/dark-vessel', '/gear-detection', '/china-fishing',
|
||||||
|
'/patrol-route', '/fleet-optimization',
|
||||||
|
'/enforcement-history', '/statistics', '/reports',
|
||||||
|
'/ai-alert', '/mobile-service', '/ship-agent', '/external-service',
|
||||||
|
'/ai-model', '/mlops', '/ai-assistant',
|
||||||
|
'/data-hub', '/system-config', '/notices', '/admin', '/access-control',
|
||||||
|
],
|
||||||
|
OPERATOR: [
|
||||||
|
'/dashboard', '/monitoring', '/events', '/map-control', '/event-list',
|
||||||
|
'/risk-map', '/enforcement-plan',
|
||||||
|
'/dark-vessel', '/gear-detection', '/china-fishing',
|
||||||
|
'/patrol-route', '/fleet-optimization',
|
||||||
|
'/enforcement-history', '/statistics', '/reports',
|
||||||
|
'/ai-alert', '/mobile-service', '/ship-agent',
|
||||||
|
'/data-hub', '/system-config',
|
||||||
|
],
|
||||||
|
ANALYST: [
|
||||||
|
'/dashboard', '/monitoring', '/events', '/event-list',
|
||||||
|
'/risk-map', '/dark-vessel', '/gear-detection', '/china-fishing',
|
||||||
|
'/enforcement-history', '/statistics', '/reports',
|
||||||
|
'/ai-model', '/mlops', '/ai-assistant',
|
||||||
|
'/system-config',
|
||||||
|
],
|
||||||
|
FIELD: [
|
||||||
|
'/dashboard', '/monitoring', '/events', '/event-list',
|
||||||
|
'/risk-map', '/enforcement-plan',
|
||||||
|
'/dark-vessel', '/china-fishing',
|
||||||
|
'/mobile-service', '/ship-agent', '/ai-alert',
|
||||||
|
],
|
||||||
|
VIEWER: [
|
||||||
|
'/dashboard', '/monitoring', '/statistics',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 감사 로그 ──────────────────────────
|
||||||
|
export interface AuditEntry {
|
||||||
|
time: string;
|
||||||
|
user: string;
|
||||||
|
action: string;
|
||||||
|
target: string;
|
||||||
|
ip: string;
|
||||||
|
result: '성공' | '실패' | '차단';
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAuditLog(entry: Omit<AuditEntry, 'time' | 'ip'>) {
|
||||||
|
const log: AuditEntry = {
|
||||||
|
...entry,
|
||||||
|
time: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
ip: '10.20.30.1', // 시뮬레이션
|
||||||
|
};
|
||||||
|
const logs: AuditEntry[] = JSON.parse(sessionStorage.getItem('audit_logs') || '[]');
|
||||||
|
logs.unshift(log);
|
||||||
|
sessionStorage.setItem('audit_logs', JSON.stringify(logs.slice(0, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 세션 타임아웃 (30분) ──────────────────
|
||||||
|
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null;
|
||||||
|
login: (user: AuthUser) => void;
|
||||||
|
logout: () => void;
|
||||||
|
hasAccess: (path: string) => boolean;
|
||||||
|
sessionRemaining: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
const stored = sessionStorage.getItem('auth_user');
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
});
|
||||||
|
const [lastActivity, setLastActivity] = useState(Date.now());
|
||||||
|
const [sessionRemaining, setSessionRemaining] = useState(SESSION_TIMEOUT / 1000);
|
||||||
|
|
||||||
|
// 사용자 활동 감지 → 세션 갱신
|
||||||
|
const resetActivity = useCallback(() => {
|
||||||
|
setLastActivity(Date.now());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||||
|
events.forEach((e) => window.addEventListener(e, resetActivity));
|
||||||
|
return () => events.forEach((e) => window.removeEventListener(e, resetActivity));
|
||||||
|
}, [user, resetActivity]);
|
||||||
|
|
||||||
|
// 세션 타임아웃 체크
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - lastActivity;
|
||||||
|
const remaining = Math.max(0, Math.floor((SESSION_TIMEOUT - elapsed) / 1000));
|
||||||
|
setSessionRemaining(remaining);
|
||||||
|
|
||||||
|
if (elapsed >= SESSION_TIMEOUT) {
|
||||||
|
writeAuditLog({ user: user.name, action: '세션 타임아웃 로그아웃', target: '시스템', result: '성공' });
|
||||||
|
setUser(null);
|
||||||
|
sessionStorage.removeItem('auth_user');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user, lastActivity]);
|
||||||
|
|
||||||
|
const login = useCallback((u: AuthUser) => {
|
||||||
|
setUser(u);
|
||||||
|
setLastActivity(Date.now());
|
||||||
|
sessionStorage.setItem('auth_user', JSON.stringify(u));
|
||||||
|
writeAuditLog({ user: u.name, action: `로그인 (${u.authMethod})`, target: '시스템', result: '성공' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
if (user) {
|
||||||
|
writeAuditLog({ user: user.name, action: '로그아웃', target: '시스템', result: '성공' });
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
sessionStorage.removeItem('auth_user');
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const hasAccess = useCallback((path: string) => {
|
||||||
|
if (!user) return false;
|
||||||
|
const allowed = ROLE_PERMISSIONS[user.role] || [];
|
||||||
|
return allowed.some((p) => path.startsWith(p));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, hasAccess, sessionRemaining }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
568
src/app/layout/MainLayout.tsx
Normal file
568
src/app/layout/MainLayout.tsx
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard, Map, List, Ship, Anchor, Radar,
|
||||||
|
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,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||||
|
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
|
||||||
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-01 반영 사항:
|
||||||
|
* - RBAC 기반 메뉴 접근 제어 (역할별 허용 메뉴만 표시)
|
||||||
|
* - 세션 타임아웃 잔여 시간 표시
|
||||||
|
* - 로그인 사용자 정보·역할·인증방식 표시
|
||||||
|
* - 로그아웃 시 감사 로그 기록
|
||||||
|
*
|
||||||
|
* SFR-02 공통기능:
|
||||||
|
* - 모든 페이지 오른쪽 상단: 페이지 검색, 파일다운로드, 엑셀 내보내기, 인쇄
|
||||||
|
* - 모든 페이지 하단: 페이지네이션
|
||||||
|
*/
|
||||||
|
|
||||||
|
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',
|
||||||
|
gpki: 'GPKI',
|
||||||
|
sso: 'SSO',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
|
||||||
|
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
|
||||||
|
type NavEntry = NavItem | NavGroup;
|
||||||
|
|
||||||
|
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
|
||||||
|
|
||||||
|
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: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
|
||||||
|
// ── 위험도·단속 ──
|
||||||
|
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
|
||||||
|
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
|
||||||
|
// ── 탐지 ──
|
||||||
|
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
|
||||||
|
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
|
||||||
|
{ to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' },
|
||||||
|
// ── 이력·통계 ──
|
||||||
|
{ to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' },
|
||||||
|
{ to: '/event-list', icon: List, labelKey: 'nav.eventList' },
|
||||||
|
{ to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' },
|
||||||
|
{ to: '/reports', icon: FileText, labelKey: 'nav.reports' },
|
||||||
|
// ── 함정용 (그룹) ──
|
||||||
|
{
|
||||||
|
groupKey: 'group.fieldOps', icon: Ship,
|
||||||
|
items: [
|
||||||
|
{ to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' },
|
||||||
|
{ to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' },
|
||||||
|
{ to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' },
|
||||||
|
{ to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' },
|
||||||
|
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// ── 관리자 (그룹) ──
|
||||||
|
{
|
||||||
|
groupKey: 'group.admin', icon: Settings,
|
||||||
|
items: [
|
||||||
|
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
|
||||||
|
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
|
||||||
|
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
|
||||||
|
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
|
||||||
|
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
|
||||||
|
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
|
||||||
|
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
|
||||||
|
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
|
||||||
|
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// getPageLabel용 flat 목록
|
||||||
|
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
|
||||||
|
|
||||||
|
function formatRemaining(seconds: number) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
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();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout, hasAccess, sessionRemaining } = useAuth();
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n)
|
||||||
|
const getPageLabel = (pathname: string): string => {
|
||||||
|
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to));
|
||||||
|
return item ? t(item.labelKey) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공통 검색
|
||||||
|
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;
|
||||||
|
if (!el) { window.print(); return; }
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) return;
|
||||||
|
win.document.write(`<html><head><title>${getPageLabel(location.pathname)} - ${t('layout.print')}</title>
|
||||||
|
<style>body{font-family:'Pretendard',sans-serif;color:#1e293b;padding:20px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:11px}
|
||||||
|
th,td{border:1px solid #cbd5e1;padding:6px 8px;text-align:left}
|
||||||
|
th{background:#f1f5f9;font-weight:600}@media print{body{padding:0}}</style>
|
||||||
|
</head><body>${el.innerHTML}</body></html>`);
|
||||||
|
win.document.close();
|
||||||
|
win.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀(CSV) 내보내기 — 현재 화면 테이블 자동 추출
|
||||||
|
const handleExcelExport = () => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const tables = el.querySelectorAll('table');
|
||||||
|
if (tables.length === 0) { alert(t('layout.noExportTable')); return; }
|
||||||
|
const table = tables[0];
|
||||||
|
const rows: string[] = [];
|
||||||
|
table.querySelectorAll('tr').forEach((tr) => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
tr.querySelectorAll('th, td').forEach((td) => {
|
||||||
|
cells.push(`"${(td.textContent || '').replace(/"/g, '""').trim()}"`);
|
||||||
|
});
|
||||||
|
rows.push(cells.join(','));
|
||||||
|
});
|
||||||
|
const csv = '\uFEFF' + rows.join('\r\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${getPageLabel(location.pathname) || 'export'}_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 다운로드 (현재 페이지 HTML)
|
||||||
|
const handleDownload = () => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const html = `<html><head><meta charset="utf-8"><title>${getPageLabel(location.pathname)}</title></head><body>${el.innerHTML}</body></html>`;
|
||||||
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${getPageLabel(location.pathname) || 'page'}_${new Date().toISOString().slice(0, 10)}.html`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 메뉴 접기/펼치기 (다중 그룹 지원)
|
||||||
|
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||||
|
const toggleGroup = (name: string) => setOpenGroups(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(name) ? next.delete(name) : next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// RBAC
|
||||||
|
const roleColor = user ? ROLE_COLORS[user.role] : null;
|
||||||
|
const isSessionWarning = sessionRemaining <= 5 * 60;
|
||||||
|
|
||||||
|
// SFR-02: 공통알림 데이터
|
||||||
|
const systemNotices: SystemNotice[] = [
|
||||||
|
{
|
||||||
|
id: 'N-001', type: 'urgent', display: 'banner', title: '서해 NLL 인근 경보 강화',
|
||||||
|
message: '2026-04-03부터 서해 NLL 인근 해역에 대한 경계 경보가 강화되었습니다.',
|
||||||
|
startDate: '2026-04-03', endDate: '2026-04-10', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: true, pinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-002', type: 'maintenance', display: 'popup', title: '정기 시스템 점검 안내',
|
||||||
|
message: '2026-04-05(토) 02:00~06:00 시스템 정기점검이 예정되어 있습니다. 점검 중 서비스 이용이 제한될 수 있습니다.',
|
||||||
|
startDate: '2026-04-03', endDate: '2026-04-05', targetRoles: [], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-003', type: 'info', display: 'banner', title: 'AI 탐지 모델 v2.3 업데이트',
|
||||||
|
message: '다크베셀 탐지 정확도 89%→93% 개선. 환적 탐지 알고리즘 업데이트.',
|
||||||
|
startDate: '2026-04-01', endDate: '2026-04-15', targetRoles: ['ADMIN', 'ANALYST'], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-background text-heading overflow-hidden">
|
||||||
|
{/* 사이드바 */}
|
||||||
|
<aside
|
||||||
|
className={`flex flex-col border-r border-border bg-background transition-all duration-300 ${
|
||||||
|
collapsed ? 'w-16' : 'w-56'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 로고 */}
|
||||||
|
<div className="flex items-center gap-2.5 px-4 h-14 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600/20 border border-blue-500/30 shrink-0">
|
||||||
|
<Shield className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-bold text-heading truncate">{t('layout.brandTitle')}</div>
|
||||||
|
<div className="text-[8px] text-hint truncate">{t('layout.brandSub')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RBAC 역할 표시 */}
|
||||||
|
{!collapsed && user && roleColor && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] text-hint mt-0.5">
|
||||||
|
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||||
|
{NAV_ENTRIES.map((entry) => {
|
||||||
|
if (isGroup(entry)) {
|
||||||
|
// 그룹 내 RBAC 필터링
|
||||||
|
const groupItems = entry.items.filter((item) => hasAccess(item.to));
|
||||||
|
if (groupItems.length === 0) return null;
|
||||||
|
const GroupIcon = entry.icon;
|
||||||
|
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
|
||||||
|
return (
|
||||||
|
<div key={entry.groupKey}>
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(entry.groupKey)}
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
|
||||||
|
isAnyActive || openGroups.has(entry.groupKey)
|
||||||
|
? 'text-foreground bg-surface-overlay'
|
||||||
|
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GroupIcon className="w-4 h-4 shrink-0" />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
|
||||||
|
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 그룹 하위 메뉴 */}
|
||||||
|
{(openGroups.has(entry.groupKey) || isAnyActive) && (
|
||||||
|
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
|
||||||
|
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 일반 메뉴 아이템
|
||||||
|
if (!hasAccess(entry.to)) return null;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={entry.to}
|
||||||
|
to={entry.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
|
||||||
|
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<entry.icon className="w-4 h-4 shrink-0" />
|
||||||
|
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 세션 타임아웃 표시 */}
|
||||||
|
{!collapsed && user && (
|
||||||
|
<div className={`mx-2 mb-1 px-3 py-1.5 rounded-lg text-[9px] flex items-center gap-1.5 ${
|
||||||
|
isSessionWarning
|
||||||
|
? 'bg-red-500/10 border border-red-500/20 text-red-400'
|
||||||
|
: 'bg-surface-overlay border border-border text-hint'
|
||||||
|
}`}>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{t('layout.sessionExpiring')} {formatRemaining(sessionRemaining)}</span>
|
||||||
|
{isSessionWarning && <span className="ml-auto text-[8px] whitespace-nowrap animate-pulse">{t('layout.extendNeeded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사이드바 하단 */}
|
||||||
|
<div className="border-t border-border p-2 space-y-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] text-hint hover:text-red-400 hover:bg-red-500/10 w-full transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 shrink-0" />
|
||||||
|
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t('layout.logout')}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="flex items-center justify-center w-full py-1.5 rounded-lg text-hint hover:text-muted-foreground hover:bg-surface-overlay transition-colors"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<header className="flex items-center justify-between h-12 px-4 border-b border-border bg-background/80 backdrop-blur-sm shrink-0">
|
||||||
|
<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
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 경보 */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<div className="w-px h-5 bg-white/[0.06]" />
|
||||||
|
{/* 언어 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
|
||||||
|
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
|
||||||
|
>
|
||||||
|
{language === 'ko' ? 'EN' : '한국어'}
|
||||||
|
</button>
|
||||||
|
{/* 테마 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-1.5 rounded-lg bg-surface-overlay border border-border text-label hover:text-heading transition-colors"
|
||||||
|
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-5 bg-white/[0.06]" />
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<span className="text-[9px] font-bold text-blue-400">{user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] font-medium text-heading">
|
||||||
|
{user.name}
|
||||||
|
<span className="text-hint ml-1">({user.rank})</span>
|
||||||
|
</div>
|
||||||
|
<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]`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* SFR-02: 공통알림 배너 */}
|
||||||
|
<NotificationBanner notices={systemNotices} userRole={user?.role} />
|
||||||
|
|
||||||
|
{/* SFR-02: 공통 페이지 액션바 (검색, 다운로드, 엑셀, 인쇄) */}
|
||||||
|
<div className="flex items-center justify-end gap-1.5 px-4 py-1.5 border-b border-border bg-background/60 shrink-0">
|
||||||
|
{/* 왼쪽: 페이지명 */}
|
||||||
|
<div className="mr-auto text-[9px] text-hint">{getPageLabel(location.pathname)}</div>
|
||||||
|
|
||||||
|
{/* 검색 입력 + 검색 버튼 통합 */}
|
||||||
|
<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
|
||||||
|
value={pageSearch}
|
||||||
|
onChange={(e) => setPageSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && pageSearch) {
|
||||||
|
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('layout.pageSearch')}
|
||||||
|
className="w-48 bg-surface-overlay border border-slate-700/50 rounded-l-md pl-7 pr-2 py-1 text-[10px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (pageSearch) {
|
||||||
|
(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"
|
||||||
|
>
|
||||||
|
<Search className="w-3 h-3" />
|
||||||
|
{t('action.search')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
|
||||||
|
title={t('layout.fileDownload')}
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{t('layout.download')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExcelExport}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
|
||||||
|
title={t('layout.excelExport')}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-3 h-3" />
|
||||||
|
{t('layout.excel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[9px] text-hint hover:text-label hover:bg-surface-overlay transition-colors whitespace-nowrap"
|
||||||
|
title={t('layout.print')}
|
||||||
|
>
|
||||||
|
<Printer className="w-3 h-3" />
|
||||||
|
{t('layout.print')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 */}
|
||||||
|
<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: 공통알림 팝업 */}
|
||||||
|
<NotificationPopup notices={systemNotices} userRole={user?.role} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/data/areasCodes.json
Normal file
418
src/data/areasCodes.json
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "HA-102",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "서해 광역2구역",
|
||||||
|
"authority": "서해지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-103",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "중부 광역 1구역",
|
||||||
|
"authority": "중부지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-104",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "중부 광역2구역",
|
||||||
|
"authority": "중부지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-105",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "특별경비수역",
|
||||||
|
"name": "서특단 1구역",
|
||||||
|
"authority": "서해5도특별경비단",
|
||||||
|
"note": "서해 NLL 인근"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-106",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "특별경비수역",
|
||||||
|
"name": "서특단 2구역",
|
||||||
|
"authority": "서해5도특별경비단",
|
||||||
|
"note": "서해 NLL 인근"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-107",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "특별경비수역",
|
||||||
|
"name": "서특단 3구역",
|
||||||
|
"authority": "서해5도특별경비단",
|
||||||
|
"note": "서해 NLL 인근"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-108",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "인천 연안구역",
|
||||||
|
"authority": "인천해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-109",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "인천 내해1구역",
|
||||||
|
"authority": "인천해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-110",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "인천 내해2구역",
|
||||||
|
"authority": "인천해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-111",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "평택 연안구역",
|
||||||
|
"authority": "평택해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-112",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "평택 내해구역",
|
||||||
|
"authority": "평택해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-113",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "태안 연안구역",
|
||||||
|
"authority": "태안해양경찰서",
|
||||||
|
"note": "태안해안국립공원 인접"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-114",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "태안 내해구역",
|
||||||
|
"authority": "태안해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-115",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "보령 연안구역",
|
||||||
|
"authority": "보령해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-116",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "보령 내해구역",
|
||||||
|
"authority": "보령해양경찰서",
|
||||||
|
"note": "외연도·원산도 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-117",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "군산 연안구역",
|
||||||
|
"authority": "군산해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-118",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "군산 내해구역",
|
||||||
|
"authority": "군산해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-119",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "부안 연안구역",
|
||||||
|
"authority": "군산해양경찰서",
|
||||||
|
"note": "군산서 관할"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-120",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "부안 내해구역",
|
||||||
|
"authority": "군산해양경찰서",
|
||||||
|
"note": "변산반도 인접"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-121",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "목포 연안구역",
|
||||||
|
"authority": "목포해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-122",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "목포 내해1구역",
|
||||||
|
"authority": "목포해양경찰서",
|
||||||
|
"note": "다도해국립공원"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-123",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "목포 내해2구역",
|
||||||
|
"authority": "목포해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-124",
|
||||||
|
"major": "서해",
|
||||||
|
"mid": "특별경비수역",
|
||||||
|
"name": "목포 특별경비수역",
|
||||||
|
"authority": "목포해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-201",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "남해 광역1구역",
|
||||||
|
"authority": "남해지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-202",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "남해 광역2구역",
|
||||||
|
"authority": "남해지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-203",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "완도 연안구역",
|
||||||
|
"authority": "완도해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-204",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "완도 내해구역",
|
||||||
|
"authority": "완도해양경찰서",
|
||||||
|
"note": "청산도·추자도 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-205",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "여수 연안구역",
|
||||||
|
"authority": "여수해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-206",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "여수 내해구역",
|
||||||
|
"authority": "여수해양경찰서",
|
||||||
|
"note": "여자만·거문도 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-207",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "통영 연안구역",
|
||||||
|
"authority": "통영해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-208",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "특별경비수역",
|
||||||
|
"name": "통영 특별 경비수역",
|
||||||
|
"authority": "통영해양경찰서",
|
||||||
|
"note": "한려해상국립공원"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-209",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "사천 연안구역",
|
||||||
|
"authority": "사천해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-210",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "창원 연안구역",
|
||||||
|
"authority": "창원해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-211",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "부산 연안구역",
|
||||||
|
"authority": "부산해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-212",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "울산 연안구역",
|
||||||
|
"authority": "울산해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-213",
|
||||||
|
"major": "남해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "울산 내해구역",
|
||||||
|
"authority": "울산해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-301",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "동해 광역1구역",
|
||||||
|
"authority": "동해지방해양경찰청",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-302",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "동해 광역2구역",
|
||||||
|
"authority": "동해지방해양경찰청",
|
||||||
|
"note": "EEZ 동해측"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-303",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "속초 연안구역",
|
||||||
|
"authority": "속초해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-304",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "속초 내해구역",
|
||||||
|
"authority": "속초해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-305",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "동해 연안구역",
|
||||||
|
"authority": "동해해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-306",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "동해 내해구역",
|
||||||
|
"authority": "동해해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-307",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "울진 연안구역",
|
||||||
|
"authority": "울진해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-308",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "울진 내해구역",
|
||||||
|
"authority": "울진해양경찰서",
|
||||||
|
"note": "왕돌초 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-309",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "포항 연안구역",
|
||||||
|
"authority": "포항해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-310",
|
||||||
|
"major": "동해",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "포항 내해구역",
|
||||||
|
"authority": "포항해양경찰서",
|
||||||
|
"note": "영일만 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-401",
|
||||||
|
"major": "제주",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "제주 광역 1구역",
|
||||||
|
"authority": "제주지방해양경찰청",
|
||||||
|
"note": "제주 북부 EEZ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-402",
|
||||||
|
"major": "제주",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "제주 광역 2구역",
|
||||||
|
"authority": "제주지방해양경찰청",
|
||||||
|
"note": "제주 남부 EEZ·이어도 수역"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-403",
|
||||||
|
"major": "제주",
|
||||||
|
"mid": "내해구역",
|
||||||
|
"name": "제주 내해구역",
|
||||||
|
"authority": "제주해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-404",
|
||||||
|
"major": "제주",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "제주 연안구역",
|
||||||
|
"authority": "제주해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-405",
|
||||||
|
"major": "제주",
|
||||||
|
"mid": "연안구역",
|
||||||
|
"name": "서귀포 연안구역",
|
||||||
|
"authority": "서귀포해양경찰서",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA-501",
|
||||||
|
"major": "광역(원해)",
|
||||||
|
"mid": "광역구역",
|
||||||
|
"name": "광역구역",
|
||||||
|
"authority": "해양경찰청",
|
||||||
|
"note": "EEZ 외측·공해 수역"
|
||||||
|
}
|
||||||
|
]
|
||||||
138
src/data/commonCodes.ts
Normal file
138
src/data/commonCodes.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* SFR-02: 시스템 기본 환경설정 및 공통 기능
|
||||||
|
* 공통코드 기준정보 통합 관리 모듈
|
||||||
|
*
|
||||||
|
* 4개 분류 | 875개 코드
|
||||||
|
* - 해역분류 52개 (해양경찰청 관할 기준)
|
||||||
|
* - 어종 578개 (국립수산과학원 공식 어종코드)
|
||||||
|
* - 어업유형 59개 (수산업법 허가·면허 구분)
|
||||||
|
* - 선박유형 186개 (MDA 5개 출처 통합)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import areaData from './areasCodes.json';
|
||||||
|
import speciesData from './speciesCodes.json';
|
||||||
|
import fisheryData from './fisheryCodes.json';
|
||||||
|
import vesselTypeData from './vesselTypeCodes.json';
|
||||||
|
|
||||||
|
// ─── 타입 정의 ──────────────────────────
|
||||||
|
|
||||||
|
export interface AreaCode {
|
||||||
|
code: string; // HA-101 ~ HA-501
|
||||||
|
major: string; // 서해, 남해, 동해, 제주, 광역(원해)
|
||||||
|
mid: string; // 광역구역, 특별경비수역, 연안구역, 내해구역
|
||||||
|
name: string; // 소분류(해역명)
|
||||||
|
authority: string; // 관할기관
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeciesCode {
|
||||||
|
code: string; // 10000 ~ 90000 계열
|
||||||
|
major: string; // 대분류: 어류, 패류, 갑각류, 연체류, 해조류 등
|
||||||
|
mid: string; // 중분류(상위)
|
||||||
|
name: string; // 어종명
|
||||||
|
nameEn: string; // 영문명
|
||||||
|
scientific: string; // 학명
|
||||||
|
area: string; // 주요 서식해역
|
||||||
|
active: boolean; // 사용여부
|
||||||
|
fishing: boolean; // 낚시연계 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FisheryCode {
|
||||||
|
code: string; // FV-101 / FT-xxx
|
||||||
|
major: string; // 근해어업, 연안어업, 구획어업, 마을어업, 양식어업, 원양어업, 기타어업
|
||||||
|
mid: string; // 트롤, 선망, 연승, 자망 등
|
||||||
|
name: string; // 어업유형명
|
||||||
|
target: string; // 주요 어획대상
|
||||||
|
permit: string; // 허가/면허
|
||||||
|
law: string; // 수산업법 근거
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselTypeCode {
|
||||||
|
code: string; // VT-100 ~ VT-905
|
||||||
|
srcCode: string; // 원본코드 (ship_ty_cd)
|
||||||
|
srcDetail: string; // 원본세부코드 (src_ty_cd)
|
||||||
|
major: string; // 어선, 여객선, 화물선, 유조선, 관공선, 함정, 항공기, 기타선
|
||||||
|
mid: string; // 중분류
|
||||||
|
name: string; // 선박유형명
|
||||||
|
source: string; // 데이터출처: RRA, GIC, PMS, AIS, KSU
|
||||||
|
tonnage: string; // 톤수기준
|
||||||
|
purpose: string; // 주요용도
|
||||||
|
aisCode: string; // AIS/IMO 코드
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 코드 데이터 ─────────────────────────
|
||||||
|
|
||||||
|
export const AREA_CODES: AreaCode[] = areaData as AreaCode[];
|
||||||
|
export const SPECIES_CODES: SpeciesCode[] = speciesData as SpeciesCode[];
|
||||||
|
export const FISHERY_CODES: FisheryCode[] = fisheryData as FisheryCode[];
|
||||||
|
export const VESSEL_TYPE_CODES: VesselTypeCode[] = vesselTypeData as VesselTypeCode[];
|
||||||
|
|
||||||
|
// ─── 조회 유틸리티 ──────────────────────
|
||||||
|
|
||||||
|
/** 코드번호로 해역 조회 */
|
||||||
|
export function getArea(code: string): AreaCode | undefined {
|
||||||
|
return AREA_CODES.find((a) => a.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 코드번호로 어종 조회 */
|
||||||
|
export function getSpecies(code: string): SpeciesCode | undefined {
|
||||||
|
return SPECIES_CODES.find((s) => s.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 코드번호로 어업유형 조회 */
|
||||||
|
export function getFishery(code: string): FisheryCode | undefined {
|
||||||
|
return FISHERY_CODES.find((f) => f.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 코드번호로 선박유형 조회 */
|
||||||
|
export function getVesselType(code: string): VesselTypeCode | undefined {
|
||||||
|
return VESSEL_TYPE_CODES.find((v) => v.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해역 대분류 목록 (중복 제거) */
|
||||||
|
export function getAreaMajors(): string[] {
|
||||||
|
return [...new Set(AREA_CODES.map((a) => a.major))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 어종 대분류 목록 (중복 제거) */
|
||||||
|
export function getSpeciesMajors(): string[] {
|
||||||
|
return [...new Set(SPECIES_CODES.map((s) => s.major).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 어업유형 대분류 목록 (중복 제거) */
|
||||||
|
export function getFisheryMajors(): string[] {
|
||||||
|
return [...new Set(FISHERY_CODES.map((f) => f.major))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선박유형 대분류 목록 (중복 제거) */
|
||||||
|
export function getVesselTypeMajors(): string[] {
|
||||||
|
return [...new Set(VESSEL_TYPE_CODES.map((v) => v.major))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대분류로 필터링 */
|
||||||
|
export function filterByMajor<T extends { major: string }>(items: T[], major: string): T[] {
|
||||||
|
if (!major) return items;
|
||||||
|
return items.filter((i) => i.major === major);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이름 또는 코드로 검색 */
|
||||||
|
export function searchCodes<T extends { code: string; name: string }>(
|
||||||
|
items: T[], query: string
|
||||||
|
): T[] {
|
||||||
|
if (!query) return items;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return items.filter(
|
||||||
|
(i) => i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 코드 분류별 통계 ───────────────────
|
||||||
|
|
||||||
|
export const CODE_STATS = {
|
||||||
|
areas: AREA_CODES.length,
|
||||||
|
species: SPECIES_CODES.length,
|
||||||
|
fishery: FISHERY_CODES.length,
|
||||||
|
vesselTypes: VESSEL_TYPE_CODES.length,
|
||||||
|
total: AREA_CODES.length + SPECIES_CODES.length + FISHERY_CODES.length + VESSEL_TYPE_CODES.length,
|
||||||
|
};
|
||||||
533
src/data/fisheryCodes.json
Normal file
533
src/data/fisheryCodes.json
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "FV-101",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "트롤",
|
||||||
|
"name": "쌍끌이 대형저인망",
|
||||||
|
"target": "명태, 오징어, 새우",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-102",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "트롤",
|
||||||
|
"name": "외끌이 저인망",
|
||||||
|
"target": "저서어류",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-103",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "트롤",
|
||||||
|
"name": "중형 저인망",
|
||||||
|
"target": "고등어, 오징어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-104",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "선망",
|
||||||
|
"name": "대형선망",
|
||||||
|
"target": "고등어, 전갱이, 멸치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-105",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "선망",
|
||||||
|
"name": "소형선망",
|
||||||
|
"target": "고등어, 멸치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-106",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "연승",
|
||||||
|
"name": "근해연승",
|
||||||
|
"target": "참치, 상어류",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-107",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "자망",
|
||||||
|
"name": "근해자망",
|
||||||
|
"target": "꽃게, 조기, 갈치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-108",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "통발",
|
||||||
|
"name": "장어통발",
|
||||||
|
"target": "장어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-109",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "통발",
|
||||||
|
"name": "문어단지",
|
||||||
|
"target": "문어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-110",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "채낚기",
|
||||||
|
"name": "오징어채낚기",
|
||||||
|
"target": "오징어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-111",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "형망",
|
||||||
|
"name": "근해형망",
|
||||||
|
"target": "새우, 패류",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-112",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "안강망",
|
||||||
|
"name": "근해안강망",
|
||||||
|
"target": "멸치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-113",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "봉수망",
|
||||||
|
"name": "봉수망",
|
||||||
|
"target": "자리돔",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-114",
|
||||||
|
"major": "근해어업",
|
||||||
|
"mid": "잠수",
|
||||||
|
"name": "잠수기어업",
|
||||||
|
"target": "전복, 해삼, 성게",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제41조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-201",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "자망",
|
||||||
|
"name": "연안자망",
|
||||||
|
"target": "숭어, 넙치, 도미",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-202",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "자망",
|
||||||
|
"name": "삼중자망",
|
||||||
|
"target": "잡어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-203",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "선망",
|
||||||
|
"name": "연안선망",
|
||||||
|
"target": "멸치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-204",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "안강망",
|
||||||
|
"name": "연안안강망",
|
||||||
|
"target": "멸치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-205",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "연승",
|
||||||
|
"name": "연안연승",
|
||||||
|
"target": "도미",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-206",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "통발",
|
||||||
|
"name": "연안통발",
|
||||||
|
"target": "낙지, 게류",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-207",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "채낚기",
|
||||||
|
"name": "연안채낚기",
|
||||||
|
"target": "오징어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-208",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "들망",
|
||||||
|
"name": "연안들망",
|
||||||
|
"target": "잡어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-209",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "형망",
|
||||||
|
"name": "연안형망",
|
||||||
|
"target": "패류",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-210",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "복합",
|
||||||
|
"name": "연안복합",
|
||||||
|
"target": "다종",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-211",
|
||||||
|
"major": "연안어업",
|
||||||
|
"mid": "해조채취",
|
||||||
|
"name": "해조채취",
|
||||||
|
"target": "미역, 김",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제47조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-301",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "정치망",
|
||||||
|
"name": "정치망어업",
|
||||||
|
"target": "방어, 고등어, 연어",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-302",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "정치망",
|
||||||
|
"name": "각망",
|
||||||
|
"target": "잡어",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-303",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "정치망",
|
||||||
|
"name": "안강망(구획)",
|
||||||
|
"target": "멸치",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-304",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "정치망",
|
||||||
|
"name": "낭장망",
|
||||||
|
"target": "멸치, 잡어",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-305",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "정치망",
|
||||||
|
"name": "장망",
|
||||||
|
"target": "연어",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-306",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "이동형",
|
||||||
|
"name": "문어단지(구획)",
|
||||||
|
"target": "문어",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-307",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "이동형",
|
||||||
|
"name": "패류형망",
|
||||||
|
"target": "패류",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-308",
|
||||||
|
"major": "구획어업",
|
||||||
|
"mid": "이동형",
|
||||||
|
"name": "새우조망",
|
||||||
|
"target": "새우",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-401",
|
||||||
|
"major": "마을어업",
|
||||||
|
"mid": "패류채취",
|
||||||
|
"name": "마을어업(패류)",
|
||||||
|
"target": "바지락, 굴, 꼬막",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-402",
|
||||||
|
"major": "마을어업",
|
||||||
|
"mid": "해조채취",
|
||||||
|
"name": "마을어업(해조류)",
|
||||||
|
"target": "미역, 김, 톳",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-403",
|
||||||
|
"major": "마을어업",
|
||||||
|
"mid": "복합",
|
||||||
|
"name": "마을공동어업",
|
||||||
|
"target": "패류·해조류 복합",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-404",
|
||||||
|
"major": "마을어업",
|
||||||
|
"mid": "한정",
|
||||||
|
"name": "마을어업(한정)",
|
||||||
|
"target": "제한적 어획대상",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-501",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "가두리",
|
||||||
|
"name": "어류등양식업",
|
||||||
|
"target": "넙치, 조피볼락, 참돔",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-502",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "수하식",
|
||||||
|
"name": "패류양식업",
|
||||||
|
"target": "굴, 홍합, 전복",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-503",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "수면",
|
||||||
|
"name": "해조류양식",
|
||||||
|
"target": "미역, 김, 다시마",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-504",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "복합",
|
||||||
|
"name": "복합양식업",
|
||||||
|
"target": "패류+어류 2종 이상",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-505",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "협동",
|
||||||
|
"name": "협동양식업",
|
||||||
|
"target": "미역, 다시마, 김",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-506",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "수면",
|
||||||
|
"name": "김 양식",
|
||||||
|
"target": "김",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-507",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "육상",
|
||||||
|
"name": "수조식 양식",
|
||||||
|
"target": "넙치, 새우",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-508",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "종묘",
|
||||||
|
"name": "종묘 생산",
|
||||||
|
"target": "치어·치패",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-509",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "한정",
|
||||||
|
"name": "양식어업(한정)",
|
||||||
|
"target": "제한적 양식종",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-510",
|
||||||
|
"major": "양식어업",
|
||||||
|
"mid": "한정",
|
||||||
|
"name": "양식어업(한정)-복합",
|
||||||
|
"target": "2종 이상 한정 양식",
|
||||||
|
"permit": "면허",
|
||||||
|
"law": "수산업법 제8조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-601",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "트롤",
|
||||||
|
"name": "원양트롤",
|
||||||
|
"target": "명태, 대구",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-602",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "트롤",
|
||||||
|
"name": "새우트롤",
|
||||||
|
"target": "새우",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-603",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "선망",
|
||||||
|
"name": "원양선망",
|
||||||
|
"target": "참치",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-604",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "연승",
|
||||||
|
"name": "참치연승",
|
||||||
|
"target": "참다랑어, 황다랑어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-605",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "자망",
|
||||||
|
"name": "원양자망",
|
||||||
|
"target": "명태",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-606",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "채낚기",
|
||||||
|
"name": "오징어채낚기(원양)",
|
||||||
|
"target": "살오징어",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-607",
|
||||||
|
"major": "원양어업",
|
||||||
|
"mid": "모선식",
|
||||||
|
"name": "모선식어업",
|
||||||
|
"target": "다종",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "원양산업발전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-701",
|
||||||
|
"major": "기타어업",
|
||||||
|
"mid": "낚시",
|
||||||
|
"name": "낚시어업",
|
||||||
|
"target": "다종",
|
||||||
|
"permit": "신고",
|
||||||
|
"law": "수상레저안전법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-702",
|
||||||
|
"major": "기타어업",
|
||||||
|
"mid": "맨손",
|
||||||
|
"name": "맨손어업",
|
||||||
|
"target": "패류, 해조류",
|
||||||
|
"permit": "신고",
|
||||||
|
"law": "수산업법"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-703",
|
||||||
|
"major": "기타어업",
|
||||||
|
"mid": "시험",
|
||||||
|
"name": "시험·연구어업",
|
||||||
|
"target": "다종",
|
||||||
|
"permit": "허가",
|
||||||
|
"law": "수산업법 제57조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-704",
|
||||||
|
"major": "기타어업",
|
||||||
|
"mid": "가공",
|
||||||
|
"name": "수산물가공",
|
||||||
|
"target": "-",
|
||||||
|
"permit": "신고",
|
||||||
|
"law": "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT-705",
|
||||||
|
"major": "기타어업",
|
||||||
|
"mid": "운반",
|
||||||
|
"name": "어획물운반",
|
||||||
|
"target": "-",
|
||||||
|
"permit": "신고",
|
||||||
|
"law": "-"
|
||||||
|
}
|
||||||
|
]
|
||||||
42
src/data/mock/enforcement.ts
Normal file
42
src/data/mock/enforcement.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export interface EnforcementRecord {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
zone: string;
|
||||||
|
vessel: string;
|
||||||
|
violation: string;
|
||||||
|
action: string;
|
||||||
|
aiMatch: string;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnforcementPlanRecord {
|
||||||
|
id: string;
|
||||||
|
zone: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
risk: number;
|
||||||
|
period: string;
|
||||||
|
ships: string;
|
||||||
|
crew: number;
|
||||||
|
status: string;
|
||||||
|
alert: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforcement history (6 records) — src/features/enforcement/EnforcementHistory.tsx */
|
||||||
|
export const MOCK_ENFORCEMENT_RECORDS: EnforcementRecord[] = [
|
||||||
|
{ id: 'ENF-001', date: '2026-04-03 08:47', zone: 'EEZ 북부', vessel: '鲁荣渔56555', violation: 'EEZ 침범', action: '나포', aiMatch: '일치', result: '처벌' },
|
||||||
|
{ id: 'ENF-002', date: '2026-04-03 07:23', zone: '서해 NLL', vessel: '津塘渔03966', violation: '무허가 조업', action: '검문·경고', aiMatch: '일치', result: '경고' },
|
||||||
|
{ id: 'ENF-003', date: '2026-04-02 22:15', zone: '서해 5도', vessel: '浙岱渔02856 외 7척', violation: '선단 침범', action: '퇴거 조치', aiMatch: '일치', result: '퇴거' },
|
||||||
|
{ id: 'ENF-004', date: '2026-04-02 14:30', zone: 'EEZ 서부', vessel: '冀黄港渔05001', violation: '불법환적', action: '증거 수집', aiMatch: '일치', result: '수사 의뢰' },
|
||||||
|
{ id: 'ENF-005', date: '2026-04-01 09:00', zone: '남해 연안', vessel: '한국어선-03', violation: '조업구역 이탈', action: '검문', aiMatch: '불일치', result: '오탐(정상)' },
|
||||||
|
{ id: 'ENF-006', date: '2026-03-30 16:40', zone: '동해 EEZ', vessel: '鲁荣渔51277', violation: '고속 도주', action: '추적·나포', aiMatch: '일치', result: '처벌' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Enforcement plans (5 plans) — src/features/risk-assessment/EnforcementPlan.tsx */
|
||||||
|
export const MOCK_ENFORCEMENT_PLANS: EnforcementPlanRecord[] = [
|
||||||
|
{ id: 'EP-001', zone: '서해 NLL', lat: 37.80, lng: 124.90, risk: 92, period: '04-04 00:00~06:00', ships: '3001함, 3005함', crew: 48, status: '확정', alert: '경보 발령' },
|
||||||
|
{ id: 'EP-002', zone: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 78, period: '04-04 06:00~12:00', ships: '3009함', crew: 24, status: '확정', alert: '주의' },
|
||||||
|
{ id: 'EP-003', zone: '서해 5도', lat: 37.50, lng: 124.60, risk: 72, period: '04-04 12:00~18:00', ships: '서특단 1정', crew: 18, status: '계획중', alert: '주의' },
|
||||||
|
{ id: 'EP-004', zone: 'EEZ 서부', lat: 36.00, lng: 123.80, risk: 65, period: '04-05 00:00~06:00', ships: '3001함', crew: 24, status: '계획중', alert: '-' },
|
||||||
|
{ id: 'EP-005', zone: '남해 외해', lat: 34.20, lng: 127.50, risk: 45, period: '04-05 06:00~12:00', ships: '미정', crew: 0, status: '검토중', alert: '-' },
|
||||||
|
];
|
||||||
290
src/data/mock/events.ts
Normal file
290
src/data/mock/events.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* Shared mock data: events & alerts
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - EventList.tsx EVENTS (15 records) — primary
|
||||||
|
* - Dashboard.tsx TIMELINE_EVENTS (10)
|
||||||
|
* - MonitoringDashboard.tsx EVENTS (6)
|
||||||
|
* - AIAlert.tsx DATA (5 alerts)
|
||||||
|
* - MobileService.tsx ALERTS (3)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Event record (EventList.tsx as primary, supplemented with Dashboard titles/details)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export interface EventRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
level: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
vesselName?: string;
|
||||||
|
mmsi?: string;
|
||||||
|
area?: string;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
speed?: number;
|
||||||
|
status?: string;
|
||||||
|
assignee?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_EVENTS: EventRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'EVT-0001',
|
||||||
|
time: '2026-04-03 08:47:12',
|
||||||
|
level: 'CRITICAL',
|
||||||
|
type: 'EEZ 침범',
|
||||||
|
title: 'EEZ 침범 탐지',
|
||||||
|
detail: '鲁荣渔56555 외 2척 — N37°12\' E124°38\' 진입',
|
||||||
|
vesselName: '鲁荣渔56555',
|
||||||
|
mmsi: '412345678',
|
||||||
|
area: 'EEZ 북부',
|
||||||
|
lat: 37.2012,
|
||||||
|
lng: 124.6345,
|
||||||
|
speed: 8.2,
|
||||||
|
status: '추적 중',
|
||||||
|
assignee: '3001함',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0002',
|
||||||
|
time: '2026-04-03 08:32:05',
|
||||||
|
level: 'HIGH',
|
||||||
|
type: '다크베셀',
|
||||||
|
title: '다크베셀 출현',
|
||||||
|
detail: 'MMSI 미상 선박 3척 — 서해 NLL 인근 AIS 소실',
|
||||||
|
vesselName: '미상선박-A',
|
||||||
|
mmsi: '미상',
|
||||||
|
area: '서해 NLL',
|
||||||
|
lat: 37.7512,
|
||||||
|
lng: 125.0234,
|
||||||
|
speed: 6.1,
|
||||||
|
status: '감시 중',
|
||||||
|
assignee: '상황실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0003',
|
||||||
|
time: '2026-04-03 08:15:33',
|
||||||
|
level: 'CRITICAL',
|
||||||
|
type: '선단밀집',
|
||||||
|
title: '선단 밀집 경보',
|
||||||
|
detail: '중국어선 14척 밀집 — N36°48\' E124°22\' 반경 2nm',
|
||||||
|
vesselName: '선단(14척)',
|
||||||
|
mmsi: '다수',
|
||||||
|
area: 'EEZ 서부',
|
||||||
|
lat: 36.8001,
|
||||||
|
lng: 124.3678,
|
||||||
|
speed: 4.5,
|
||||||
|
status: '경보 발령',
|
||||||
|
assignee: '서해청',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0004',
|
||||||
|
time: '2026-04-03 07:58:44',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
type: '불법환적',
|
||||||
|
title: '불법환적 의심',
|
||||||
|
detail: '冀黄港渔05001 + 운반선 접현 30분 이상',
|
||||||
|
vesselName: '冀黄港渔05001',
|
||||||
|
mmsi: '412987654',
|
||||||
|
area: '서해 중부',
|
||||||
|
lat: 36.4789,
|
||||||
|
lng: 124.2234,
|
||||||
|
speed: 0.3,
|
||||||
|
status: '확인 중',
|
||||||
|
assignee: '분석팀',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0005',
|
||||||
|
time: '2026-04-03 07:41:18',
|
||||||
|
level: 'HIGH',
|
||||||
|
type: 'MMSI 변조',
|
||||||
|
title: 'MMSI 변조 탐지',
|
||||||
|
detail: '浙甬渔60651 — MMSI 3회 변경 이력 감지',
|
||||||
|
vesselName: '浙甬渔60651',
|
||||||
|
mmsi: '412111222',
|
||||||
|
area: 'EEZ 남부',
|
||||||
|
lat: 35.8678,
|
||||||
|
lng: 125.5012,
|
||||||
|
speed: 5.8,
|
||||||
|
status: '감시 중',
|
||||||
|
assignee: '상황실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0006',
|
||||||
|
time: '2026-04-03 07:23:01',
|
||||||
|
level: 'LOW',
|
||||||
|
type: '검문 완료',
|
||||||
|
title: '함정 검문 완료',
|
||||||
|
detail: '3009함 — 津塘渔03966 검문 완료, 경고 조치',
|
||||||
|
vesselName: '津塘渔03966',
|
||||||
|
mmsi: '412333444',
|
||||||
|
area: '서해 북부',
|
||||||
|
lat: 37.5012,
|
||||||
|
lng: 124.7890,
|
||||||
|
speed: 0,
|
||||||
|
status: '완료',
|
||||||
|
assignee: '3009함',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0007',
|
||||||
|
time: '2026-04-03 07:05:55',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
type: 'AIS 재송출',
|
||||||
|
title: 'AIS 재송출',
|
||||||
|
detail: '辽庄渔55567 — 4시간 소실 후 재송출',
|
||||||
|
vesselName: '辽庄渔55567',
|
||||||
|
mmsi: '412555666',
|
||||||
|
area: 'EEZ 북부',
|
||||||
|
lat: 37.3456,
|
||||||
|
lng: 124.8901,
|
||||||
|
speed: 3.2,
|
||||||
|
status: '확인 완료',
|
||||||
|
assignee: '상황실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0008',
|
||||||
|
time: '2026-04-03 06:48:22',
|
||||||
|
level: 'CRITICAL',
|
||||||
|
type: 'EEZ 침범',
|
||||||
|
title: '긴급 침범 경보',
|
||||||
|
detail: '浙岱渔02856 외 7척 — 서해 5도 수역 진입',
|
||||||
|
vesselName: '浙岱渔02856',
|
||||||
|
mmsi: '412777888',
|
||||||
|
area: '서해 5도',
|
||||||
|
lat: 37.0567,
|
||||||
|
lng: 124.9234,
|
||||||
|
speed: 4.5,
|
||||||
|
status: '추적 중',
|
||||||
|
assignee: '서특단',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0009',
|
||||||
|
time: '2026-04-03 06:30:00',
|
||||||
|
level: 'LOW',
|
||||||
|
type: '정기 보고',
|
||||||
|
title: '정기 보고',
|
||||||
|
detail: '전 해역 야간 감시 결과 보고 완료',
|
||||||
|
vesselName: undefined,
|
||||||
|
mmsi: undefined,
|
||||||
|
area: '전 해역',
|
||||||
|
status: '완료',
|
||||||
|
assignee: '상황실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0010',
|
||||||
|
time: '2026-04-03 06:12:33',
|
||||||
|
level: 'HIGH',
|
||||||
|
type: '속력 이상',
|
||||||
|
title: '속력 이상 탐지',
|
||||||
|
detail: '鲁荣渔51277 — 18kt 고속 이동, 도주 패턴',
|
||||||
|
vesselName: '鲁荣渔51277',
|
||||||
|
mmsi: '412999000',
|
||||||
|
area: '동해 중부',
|
||||||
|
lat: 36.2512,
|
||||||
|
lng: 130.0890,
|
||||||
|
speed: 18.1,
|
||||||
|
status: '추적 중',
|
||||||
|
assignee: '동해청',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0011',
|
||||||
|
time: '2026-04-03 05:45:10',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
type: 'AIS 소실',
|
||||||
|
title: 'AIS 소실',
|
||||||
|
detail: '浙甬渔30112 남해 외해 AIS 소실',
|
||||||
|
vesselName: '浙甬渔30112',
|
||||||
|
mmsi: '412444555',
|
||||||
|
area: '남해 외해',
|
||||||
|
lat: 34.1234,
|
||||||
|
lng: 128.5678,
|
||||||
|
status: '감시 중',
|
||||||
|
assignee: '남해청',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0012',
|
||||||
|
time: '2026-04-03 05:20:48',
|
||||||
|
level: 'HIGH',
|
||||||
|
type: '불법환적',
|
||||||
|
title: '불법환적 의심',
|
||||||
|
detail: '冀黄港渔03012 EEZ 서부 환적 의심',
|
||||||
|
vesselName: '冀黄港渔03012',
|
||||||
|
mmsi: '412666777',
|
||||||
|
area: 'EEZ 서부',
|
||||||
|
lat: 36.5678,
|
||||||
|
lng: 124.1234,
|
||||||
|
speed: 0.5,
|
||||||
|
status: '확인 중',
|
||||||
|
assignee: '분석팀',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0013',
|
||||||
|
time: '2026-04-03 04:55:30',
|
||||||
|
level: 'LOW',
|
||||||
|
type: '구역 이탈',
|
||||||
|
title: '구역 이탈',
|
||||||
|
detail: '한국어선-12 연안 구역 이탈 경고',
|
||||||
|
vesselName: '한국어선-12',
|
||||||
|
mmsi: '440123456',
|
||||||
|
area: '연안 구역',
|
||||||
|
lat: 35.4567,
|
||||||
|
lng: 129.3456,
|
||||||
|
speed: 7.0,
|
||||||
|
status: '경고 완료',
|
||||||
|
assignee: '포항서',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0014',
|
||||||
|
time: '2026-04-03 04:30:15',
|
||||||
|
level: 'CRITICAL',
|
||||||
|
type: 'EEZ 침범',
|
||||||
|
title: 'EEZ 침범 — 나포 작전',
|
||||||
|
detail: '鲁威渔15028 EEZ 북부 나포 작전 진행',
|
||||||
|
vesselName: '鲁威渔15028',
|
||||||
|
mmsi: '412888999',
|
||||||
|
area: 'EEZ 북부',
|
||||||
|
lat: 37.4012,
|
||||||
|
lng: 124.5567,
|
||||||
|
speed: 6.9,
|
||||||
|
status: '나포 작전',
|
||||||
|
assignee: '3001함',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-0015',
|
||||||
|
time: '2026-04-03 04:10:00',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
type: 'MMSI 변조',
|
||||||
|
title: 'MMSI 변조 의심',
|
||||||
|
detail: '浙甬渔99871 남해 연안 MMSI 변조 의심',
|
||||||
|
vesselName: '浙甬渔99871',
|
||||||
|
mmsi: '412222333',
|
||||||
|
area: '남해 연안',
|
||||||
|
lat: 34.5678,
|
||||||
|
lng: 127.8901,
|
||||||
|
speed: 4.2,
|
||||||
|
status: '확인 중',
|
||||||
|
assignee: '상황실',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Alert records (AIAlert.tsx as primary)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export interface AlertRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
location: string;
|
||||||
|
confidence: number;
|
||||||
|
target: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_ALERTS: AlertRecord[] = [
|
||||||
|
{ id: 'ALR-001', time: '08:47:12', type: 'EEZ 침범', location: 'N37.20 E124.63', confidence: 96, target: '3001함, 상황실', status: '수신확인' },
|
||||||
|
{ id: 'ALR-002', time: '08:32:05', type: '다크베셀', location: 'N37.75 E125.02', confidence: 91, target: '상황실', status: '수신확인' },
|
||||||
|
{ id: 'ALR-003', time: '08:15:33', type: '선단밀집', location: 'N36.80 E124.37', confidence: 88, target: '서특단, 상황실', status: '발송완료' },
|
||||||
|
{ id: 'ALR-004', time: '07:58:44', type: '불법환적', location: 'N36.48 E124.22', confidence: 82, target: '3005함', status: '수신확인' },
|
||||||
|
{ id: 'ALR-005', time: '07:41:18', type: 'MMSI변조', location: 'N35.87 E125.50', confidence: 94, target: '상황실', status: '미수신' },
|
||||||
|
];
|
||||||
23
src/data/mock/gear.ts
Normal file
23
src/data/mock/gear.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface GearRecord {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
owner: string;
|
||||||
|
zone: string;
|
||||||
|
status: string;
|
||||||
|
permit: string;
|
||||||
|
installed: string;
|
||||||
|
lastSignal: string;
|
||||||
|
risk: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gear detection data (6 records) — src/features/detection/GearDetection.tsx */
|
||||||
|
export const MOCK_GEAR: GearRecord[] = [
|
||||||
|
{ id: 'GR-001', type: '저층트롤', owner: '鲁荣渔56555', zone: 'EEZ 북부', status: '불법 의심', permit: '무허가', installed: '2026-04-01', lastSignal: '2h 전', risk: '고위험', lat: 37.20, lng: 124.63 },
|
||||||
|
{ id: 'GR-002', type: '유자망', owner: '浙甬渔60651', zone: '서해 5도', status: '불법 의심', permit: '기간 초과', installed: '2026-03-15', lastSignal: '30분 전', risk: '고위험', lat: 37.55, lng: 124.75 },
|
||||||
|
{ id: 'GR-003', type: '통발', owner: '한국어선-05', zone: '남해 연안', status: '정상', permit: '유효', installed: '2026-04-02', lastSignal: '10분 전', risk: '안전', lat: 34.45, lng: 127.80 },
|
||||||
|
{ id: 'GR-004', type: '선망', owner: '冀黄港渔05001', zone: 'EEZ 서부', status: '확인 중', permit: '구역 이탈', installed: '2026-03-28', lastSignal: '4h 전', risk: '중위험', lat: 36.80, lng: 124.37 },
|
||||||
|
{ id: 'GR-005', type: '연승', owner: '한국어선-12', zone: '동해 연안', status: '정상', permit: '유효', installed: '2026-04-01', lastSignal: '5분 전', risk: '안전', lat: 36.77, lng: 129.42 },
|
||||||
|
{ id: 'GR-006', type: '유자망(대형)', owner: '미상', zone: '서해 NLL', status: '불법 확정', permit: '무허가', installed: '미상', lastSignal: '소실', risk: '고위험', lat: 37.82, lng: 124.95 },
|
||||||
|
];
|
||||||
51
src/data/mock/kpi.ts
Normal file
51
src/data/mock/kpi.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export interface KpiMetric {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
prev?: number;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTrend {
|
||||||
|
month: string;
|
||||||
|
enforce: number;
|
||||||
|
detect: number;
|
||||||
|
accuracy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViolationType {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard KPI (6 metrics) — src/features/dashboard/Dashboard.tsx */
|
||||||
|
export const MOCK_KPI_METRICS: KpiMetric[] = [
|
||||||
|
{ id: 'KPI-RT', label: '실시간 탐지', value: 47, prev: 42, description: 'AI 감시 탐지 선박' },
|
||||||
|
{ id: 'KPI-EEZ', label: 'EEZ 침범', value: 18, prev: 21, description: '배타적경제수역 침범' },
|
||||||
|
{ id: 'KPI-DV', label: '다크베셀', value: 12, prev: 9, description: 'AIS 미송출 선박' },
|
||||||
|
{ id: 'KPI-TR', label: '불법환적 의심', value: 8, prev: 6, description: '해상전재 의심 건' },
|
||||||
|
{ id: 'KPI-TK', label: '추적 중', value: 15, prev: 13, description: '함정 추적 진행' },
|
||||||
|
{ id: 'KPI-EN', label: '나포/검문', value: 3, prev: 2, description: '금일 단속 실적' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Statistics monthly trend (7 months) — src/features/statistics/Statistics.tsx */
|
||||||
|
export const MOCK_MONTHLY_TRENDS: MonthlyTrend[] = [
|
||||||
|
{ month: '10월', enforce: 42, detect: 128, accuracy: 81 },
|
||||||
|
{ month: '11월', enforce: 38, detect: 145, accuracy: 84 },
|
||||||
|
{ month: '12월', enforce: 55, detect: 167, accuracy: 86 },
|
||||||
|
{ month: '1월', enforce: 61, detect: 189, accuracy: 88 },
|
||||||
|
{ month: '2월', enforce: 48, detect: 156, accuracy: 89 },
|
||||||
|
{ month: '3월', enforce: 52, detect: 172, accuracy: 90 },
|
||||||
|
{ month: '4월', enforce: 15, detect: 67, accuracy: 93 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Statistics violation types (5 types) — src/features/statistics/Statistics.tsx */
|
||||||
|
export const MOCK_VIOLATION_TYPES: ViolationType[] = [
|
||||||
|
{ type: 'EEZ 침범', count: 124, pct: 35 },
|
||||||
|
{ type: '다크베셀', count: 89, pct: 25 },
|
||||||
|
{ type: 'MMSI 변조', count: 64, pct: 18 },
|
||||||
|
{ type: '불법환적', count: 43, pct: 12 },
|
||||||
|
{ type: '어구 불법', count: 35, pct: 10 },
|
||||||
|
];
|
||||||
241
src/data/mock/patrols.ts
Normal file
241
src/data/mock/patrols.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Shared mock data: patrol ships, routes, scenarios, coverage
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - Dashboard.tsx PATROL_SHIPS (6)
|
||||||
|
* - PatrolRoute.tsx SHIPS (4), ROUTES, SCENARIOS (3)
|
||||||
|
* - FleetOptimization.tsx FLEET (5), COVERAGE (6), FLEET_ROUTES
|
||||||
|
* - ShipAgent.tsx DATA (6 agents)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Patrol ship interface
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export interface PatrolShip {
|
||||||
|
id: string; // P-3001 등
|
||||||
|
name: string; // 3001함
|
||||||
|
shipClass: string; // 태극급, 삼봉급 등
|
||||||
|
speed: number; // max knots
|
||||||
|
status: string; // 작전중, 가용, 정비중
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
fuel: number; // 0-100%
|
||||||
|
zone?: string;
|
||||||
|
target?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Waypoint / route types
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export interface Waypoint {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
eta: string;
|
||||||
|
desc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteSummary {
|
||||||
|
dist: string;
|
||||||
|
time: string;
|
||||||
|
fuel: string;
|
||||||
|
grids: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatrolRoute {
|
||||||
|
waypoints: Waypoint[];
|
||||||
|
summary: RouteSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatrolScenario {
|
||||||
|
name: string;
|
||||||
|
weight: { risk: number; fuel: number; time: number };
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageZone {
|
||||||
|
zone: string;
|
||||||
|
current: number;
|
||||||
|
optimized: number;
|
||||||
|
ships: number;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Patrol ships (merged & deduplicated)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_PATROL_SHIPS: PatrolShip[] = [
|
||||||
|
{
|
||||||
|
id: 'P-3001',
|
||||||
|
name: '3001함',
|
||||||
|
shipClass: '태극급',
|
||||||
|
speed: 28,
|
||||||
|
status: '추적중',
|
||||||
|
lat: 37.69,
|
||||||
|
lng: 124.60,
|
||||||
|
fuel: 78,
|
||||||
|
zone: 'NLL',
|
||||||
|
target: '鲁荣渔56555',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-3005',
|
||||||
|
name: '3005함',
|
||||||
|
shipClass: '태극급',
|
||||||
|
speed: 28,
|
||||||
|
status: '가용',
|
||||||
|
lat: 37.20,
|
||||||
|
lng: 124.63,
|
||||||
|
fuel: 72,
|
||||||
|
zone: 'EEZ 북부',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-3009',
|
||||||
|
name: '3009함',
|
||||||
|
shipClass: '참수리급',
|
||||||
|
speed: 40,
|
||||||
|
status: '검문중',
|
||||||
|
lat: 37.50,
|
||||||
|
lng: 124.75,
|
||||||
|
fuel: 45,
|
||||||
|
zone: '서해 5도',
|
||||||
|
target: '津塘渔03966',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-5001',
|
||||||
|
name: '5001함',
|
||||||
|
shipClass: '삼봉급',
|
||||||
|
speed: 22,
|
||||||
|
status: '가용',
|
||||||
|
lat: 34.20,
|
||||||
|
lng: 127.50,
|
||||||
|
fuel: 90,
|
||||||
|
zone: '남해',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-1002',
|
||||||
|
name: '1002함',
|
||||||
|
shipClass: '태극급',
|
||||||
|
speed: 28,
|
||||||
|
status: '초계중',
|
||||||
|
lat: 36.50,
|
||||||
|
lng: 124.80,
|
||||||
|
fuel: 82,
|
||||||
|
zone: '서해 중부',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-3007',
|
||||||
|
name: '3007함',
|
||||||
|
shipClass: '참수리급',
|
||||||
|
speed: 40,
|
||||||
|
status: '귀항중',
|
||||||
|
lat: 37.45,
|
||||||
|
lng: 126.60,
|
||||||
|
fuel: 32,
|
||||||
|
zone: '인천항',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-1501',
|
||||||
|
name: '1501함',
|
||||||
|
shipClass: '대형함',
|
||||||
|
speed: 22,
|
||||||
|
status: '대기',
|
||||||
|
lat: 37.53,
|
||||||
|
lng: 129.11,
|
||||||
|
fuel: 95,
|
||||||
|
zone: '동해 기지',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-1503',
|
||||||
|
name: '1503함',
|
||||||
|
shipClass: '대형함',
|
||||||
|
speed: 0,
|
||||||
|
status: '정비중',
|
||||||
|
lat: 37.53,
|
||||||
|
lng: 129.11,
|
||||||
|
fuel: 0,
|
||||||
|
zone: '동해',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'P-3012',
|
||||||
|
name: '3012함',
|
||||||
|
shipClass: '참수리급',
|
||||||
|
speed: 40,
|
||||||
|
status: '추적중',
|
||||||
|
lat: 37.80,
|
||||||
|
lng: 125.10,
|
||||||
|
fuel: 58,
|
||||||
|
zone: '서해 NLL',
|
||||||
|
target: '미상선박-A',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Patrol routes per ship (PatrolRoute.tsx)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_PATROL_ROUTES: Record<string, PatrolRoute> = {
|
||||||
|
'P-3001': {
|
||||||
|
waypoints: [
|
||||||
|
{ id: 'WP1', name: '출발(인천)', lat: 37.45, lng: 126.60, eta: '00:00', desc: '인천 해경 부두' },
|
||||||
|
{ id: 'WP2', name: 'NLL 초계점 A', lat: 37.69, lng: 124.60, eta: '02:15', desc: '서해 NLL 서단 초계' },
|
||||||
|
{ id: 'WP3', name: '고위험 격자 G-371', lat: 37.12, lng: 124.63, eta: '04:30', desc: 'EEZ 북부 집중 감시' },
|
||||||
|
{ id: 'WP4', name: 'EEZ 경계 순찰', lat: 36.80, lng: 124.37, eta: '06:45', desc: 'EEZ 서부 경계선 순찰' },
|
||||||
|
{ id: 'WP5', name: '귀항(인천)', lat: 37.45, lng: 126.60, eta: '10:00', desc: '인천 해경 부두 복귀' },
|
||||||
|
],
|
||||||
|
summary: { dist: '186 NM', time: '10h 00m', fuel: '12,400L', grids: '142개' },
|
||||||
|
},
|
||||||
|
'P-3005': {
|
||||||
|
waypoints: [
|
||||||
|
{ id: 'WP1', name: '출발(목포)', lat: 34.78, lng: 126.38, eta: '00:00', desc: '목포 해경 부두' },
|
||||||
|
{ id: 'WP2', name: '서해 5도 순찰', lat: 37.50, lng: 124.60, eta: '03:30', desc: '서해 5도 수역 감시' },
|
||||||
|
{ id: 'WP3', name: 'NLL 초계점 B', lat: 37.80, lng: 125.10, eta: '05:00', desc: 'NLL 중단부 초계' },
|
||||||
|
{ id: 'WP4', name: '서해 중부 순찰', lat: 36.50, lng: 124.80, eta: '07:30', desc: '중국어선 밀집 해역' },
|
||||||
|
{ id: 'WP5', name: '귀항(목포)', lat: 34.78, lng: 126.38, eta: '12:00', desc: '목포 해경 부두 복귀' },
|
||||||
|
],
|
||||||
|
summary: { dist: '245 NM', time: '12h 00m', fuel: '16,200L', grids: '198개' },
|
||||||
|
},
|
||||||
|
'P-5001': {
|
||||||
|
waypoints: [
|
||||||
|
{ id: 'WP1', name: '출발(동해)', lat: 37.53, lng: 129.11, eta: '00:00', desc: '동해 해경 부두' },
|
||||||
|
{ id: 'WP2', name: '동해 EEZ 북부', lat: 38.03, lng: 129.25, eta: '01:30', desc: '동해 북부 다크베셀 감시' },
|
||||||
|
{ id: 'WP3', name: '울릉도 순찰', lat: 37.50, lng: 130.90, eta: '05:00', desc: '울릉도 근해 초계' },
|
||||||
|
{ id: 'WP4', name: '독도 순찰', lat: 37.24, lng: 131.87, eta: '07:00', desc: '독도 영해 경비' },
|
||||||
|
{ id: 'WP5', name: '동해 중부', lat: 36.25, lng: 130.13, eta: '09:30', desc: '동해 중부 어선 감시' },
|
||||||
|
{ id: 'WP6', name: '귀항(동해)', lat: 37.53, lng: 129.11, eta: '13:00', desc: '동해 해경 부두 복귀' },
|
||||||
|
],
|
||||||
|
summary: { dist: '320 NM', time: '13h 00m', fuel: '18,800L', grids: '215개' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Fleet routes – coordinate arrays (FleetOptimization.tsx)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_FLEET_ROUTES: Record<string, [number, number][]> = {
|
||||||
|
'P-3001': [[37.45, 126.60], [37.69, 124.60], [37.80, 125.10], [37.90, 124.80], [37.69, 124.60]],
|
||||||
|
'P-3005': [[37.45, 126.60], [37.20, 124.63], [36.80, 124.37], [36.50, 124.80], [37.20, 124.63]],
|
||||||
|
'P-3009': [[37.45, 126.60], [37.50, 124.75], [37.55, 124.50], [37.40, 124.90], [37.50, 124.75]],
|
||||||
|
'P-5001': [[34.78, 126.38], [34.20, 127.50], [33.80, 127.00], [34.50, 126.80], [34.78, 126.38]],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Patrol scenarios (PatrolRoute.tsx)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_PATROL_SCENARIOS: PatrolScenario[] = [
|
||||||
|
{ name: '표준 순찰', weight: { risk: 40, fuel: 30, time: 30 }, score: 82 },
|
||||||
|
{ name: '위험 집중', weight: { risk: 70, fuel: 15, time: 15 }, score: 91 },
|
||||||
|
{ name: '연료 절약', weight: { risk: 20, fuel: 60, time: 20 }, score: 74 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Coverage zones (FleetOptimization.tsx)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_COVERAGE_ZONES: CoverageZone[] = [
|
||||||
|
{ zone: '서해 NLL', current: 85, optimized: 98, ships: 2, lat: 37.80, lng: 124.90, radius: 30000 },
|
||||||
|
{ zone: 'EEZ 북부', current: 60, optimized: 92, ships: 1, lat: 37.20, lng: 124.63, radius: 35000 },
|
||||||
|
{ zone: 'EEZ 서부', current: 45, optimized: 88, ships: 1, lat: 36.00, lng: 123.80, radius: 40000 },
|
||||||
|
{ zone: '서해 5도', current: 70, optimized: 95, ships: 1, lat: 37.50, lng: 124.60, radius: 25000 },
|
||||||
|
{ zone: '남해 외해', current: 30, optimized: 75, ships: 1, lat: 34.20, lng: 127.50, radius: 45000 },
|
||||||
|
{ zone: '동해 EEZ', current: 20, optimized: 50, ships: 0, lat: 37.00, lng: 130.50, radius: 50000 },
|
||||||
|
];
|
||||||
48
src/data/mock/transfers.ts
Normal file
48
src/data/mock/transfers.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export interface TransferRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
vesselA: { name: string; mmsi: string };
|
||||||
|
vesselB: { name: string; mmsi: string };
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
speed: number;
|
||||||
|
score: number;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfer detection data (3 records) — shared by TransferDetection.tsx & ChinaFishing.tsx */
|
||||||
|
export const MOCK_TRANSFERS: TransferRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'TR-001',
|
||||||
|
time: '2026-01-20 13:42:11',
|
||||||
|
vesselA: { name: '장저우8호', mmsi: '412345680' },
|
||||||
|
vesselB: { name: '黑江9호', mmsi: '412345690' },
|
||||||
|
distance: 45,
|
||||||
|
duration: 52,
|
||||||
|
speed: 2.3,
|
||||||
|
score: 89,
|
||||||
|
location: '서해 중부',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TR-002',
|
||||||
|
time: '2026-01-20 11:15:33',
|
||||||
|
vesselA: { name: '江苏如东號', mmsi: '412345683' },
|
||||||
|
vesselB: { name: '산동위해호', mmsi: '412345691' },
|
||||||
|
distance: 38,
|
||||||
|
duration: 67,
|
||||||
|
speed: 1.8,
|
||||||
|
score: 92,
|
||||||
|
location: '서해 북부',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TR-003',
|
||||||
|
time: '2026-01-20 09:23:45',
|
||||||
|
vesselA: { name: '辽宁大连號', mmsi: '412345682' },
|
||||||
|
vesselB: { name: '무명선박-D', mmsi: '412345692' },
|
||||||
|
distance: 62,
|
||||||
|
duration: 41,
|
||||||
|
speed: 2.7,
|
||||||
|
score: 78,
|
||||||
|
location: 'EEZ 북부',
|
||||||
|
},
|
||||||
|
];
|
||||||
330
src/data/mock/vessels.ts
Normal file
330
src/data/mock/vessels.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* Shared mock data: vessels (VesselData)
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - Dashboard.tsx TOP_RISK_VESSELS (8)
|
||||||
|
* - DarkVesselDetection.tsx DATA (7 suspects)
|
||||||
|
* - LiveMapView.tsx AIS_VESSELS (9) + mockEvents (3 event vessels)
|
||||||
|
*
|
||||||
|
* Risk scores are normalised to 0-100 integer scale.
|
||||||
|
* Coordinates are [lat, lng].
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VesselData {
|
||||||
|
id: string;
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
type: string; // 중국어선, 화물선, 미상 등
|
||||||
|
flag: string; // CN, KR, UNKNOWN 등
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
speed?: number;
|
||||||
|
heading?: number;
|
||||||
|
tonnage?: number;
|
||||||
|
risk: number; // 0-100 integer scale
|
||||||
|
status: string; // 추적중, 감시중, 정상 등
|
||||||
|
pattern?: string; // AIS 차단, MMSI 변조 등
|
||||||
|
lastSignal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Combined & deduplicated vessel list
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_VESSELS: VesselData[] = [
|
||||||
|
// --- TOP_RISK_VESSELS (Dashboard) merged with DarkVesselDetection & LiveMapView ---
|
||||||
|
{
|
||||||
|
id: 'V-2024-0142',
|
||||||
|
mmsi: '412345678',
|
||||||
|
name: '鲁荣渔56555',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 37.2012,
|
||||||
|
lng: 124.6345,
|
||||||
|
speed: 8.2,
|
||||||
|
heading: 225,
|
||||||
|
tonnage: 127,
|
||||||
|
risk: 96,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: 'EEZ 침범',
|
||||||
|
lastSignal: '2분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-001',
|
||||||
|
mmsi: '미상',
|
||||||
|
name: '미상선박-A',
|
||||||
|
type: '미상',
|
||||||
|
flag: 'UNKNOWN',
|
||||||
|
lat: 37.75,
|
||||||
|
lng: 125.02,
|
||||||
|
speed: 6.1,
|
||||||
|
heading: 180,
|
||||||
|
tonnage: undefined,
|
||||||
|
risk: 96,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: 'AIS 완전차단',
|
||||||
|
lastSignal: '6h+ 소실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'V-2024-0231',
|
||||||
|
mmsi: '412777888',
|
||||||
|
name: '浙岱渔02856',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 37.0567,
|
||||||
|
lng: 124.9234,
|
||||||
|
speed: 4.5,
|
||||||
|
heading: 45,
|
||||||
|
tonnage: 219,
|
||||||
|
risk: 92,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: '선단침범',
|
||||||
|
lastSignal: '5분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-002',
|
||||||
|
mmsi: '412345678',
|
||||||
|
name: '浙甬渔60651',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 35.87,
|
||||||
|
lng: 125.50,
|
||||||
|
speed: 5.8,
|
||||||
|
heading: 135,
|
||||||
|
tonnage: 258,
|
||||||
|
risk: 94,
|
||||||
|
status: '감시중',
|
||||||
|
pattern: 'MMSI 3회 변경',
|
||||||
|
lastSignal: '8분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-007',
|
||||||
|
mmsi: '미상',
|
||||||
|
name: '미상선박-B',
|
||||||
|
type: '미상',
|
||||||
|
flag: 'UNKNOWN',
|
||||||
|
lat: 38.03,
|
||||||
|
lng: 129.25,
|
||||||
|
speed: 11.2,
|
||||||
|
heading: 310,
|
||||||
|
tonnage: undefined,
|
||||||
|
risk: 93,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: 'AIS 완전차단',
|
||||||
|
lastSignal: '12h+ 소실',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-006',
|
||||||
|
mmsi: '412333444',
|
||||||
|
name: '津塘渔03966',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 35.95,
|
||||||
|
lng: 125.80,
|
||||||
|
speed: 4.2,
|
||||||
|
heading: 45,
|
||||||
|
tonnage: undefined,
|
||||||
|
risk: 91,
|
||||||
|
status: '감시중',
|
||||||
|
pattern: '국적 위장 의심',
|
||||||
|
lastSignal: '3분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'V-2024-0156',
|
||||||
|
mmsi: '412987654',
|
||||||
|
name: '冀黄港渔05001',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 36.60,
|
||||||
|
lng: 125.40,
|
||||||
|
speed: 2.1,
|
||||||
|
heading: 0,
|
||||||
|
tonnage: 106,
|
||||||
|
risk: 89,
|
||||||
|
status: '확인중',
|
||||||
|
pattern: '불법환적',
|
||||||
|
lastSignal: '42분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-003',
|
||||||
|
mmsi: '412111222',
|
||||||
|
name: '鲁荣渔51277',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 36.25,
|
||||||
|
lng: 130.13,
|
||||||
|
speed: 18.1,
|
||||||
|
heading: 290,
|
||||||
|
tonnage: 126,
|
||||||
|
risk: 88,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: '급격 속력변화',
|
||||||
|
lastSignal: '1분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-004',
|
||||||
|
mmsi: '412987654',
|
||||||
|
name: '冀黄港渔05001-B',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 36.80,
|
||||||
|
lng: 124.37,
|
||||||
|
speed: 0.3,
|
||||||
|
heading: 90,
|
||||||
|
tonnage: 106,
|
||||||
|
risk: 82,
|
||||||
|
status: '확인중',
|
||||||
|
pattern: '신호 간헐송출',
|
||||||
|
lastSignal: '42분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'V-2024-0312',
|
||||||
|
mmsi: '412333886',
|
||||||
|
name: '津塘渔03886',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 37.30,
|
||||||
|
lng: 124.75,
|
||||||
|
speed: 6.7,
|
||||||
|
heading: 200,
|
||||||
|
tonnage: 148,
|
||||||
|
risk: 80,
|
||||||
|
status: '감시중',
|
||||||
|
pattern: 'EEZ 침범',
|
||||||
|
lastSignal: '3분 전',
|
||||||
|
},
|
||||||
|
// --- LiveMapView event vessels ---
|
||||||
|
{
|
||||||
|
id: 'EVT-V-001',
|
||||||
|
mmsi: 'MMSI 412xxxx',
|
||||||
|
name: '浙江렌센號',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 37.20,
|
||||||
|
lng: 124.63,
|
||||||
|
speed: undefined,
|
||||||
|
heading: undefined,
|
||||||
|
tonnage: undefined,
|
||||||
|
risk: 94,
|
||||||
|
status: '추적중',
|
||||||
|
pattern: 'EEZ 침범',
|
||||||
|
lastSignal: '14:23 UTC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EVT-V-003',
|
||||||
|
mmsi: 'MMSI 412345xxx',
|
||||||
|
name: '福建海丰號',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 36.48,
|
||||||
|
lng: 124.38,
|
||||||
|
speed: undefined,
|
||||||
|
heading: undefined,
|
||||||
|
tonnage: undefined,
|
||||||
|
risk: 88,
|
||||||
|
status: '감시중',
|
||||||
|
pattern: 'AIS 신호 소실',
|
||||||
|
lastSignal: '13:58 UTC',
|
||||||
|
},
|
||||||
|
// --- LiveMapView AIS vessels (non-hostile) ---
|
||||||
|
{
|
||||||
|
id: 'AIS-001',
|
||||||
|
mmsi: '',
|
||||||
|
name: '3009함',
|
||||||
|
type: '경비함',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 37.45,
|
||||||
|
lng: 125.30,
|
||||||
|
speed: 18,
|
||||||
|
heading: 225,
|
||||||
|
risk: 0,
|
||||||
|
status: '작전중',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AIS-002',
|
||||||
|
mmsi: '',
|
||||||
|
name: '5001함 삼봉',
|
||||||
|
type: '경비함',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 36.80,
|
||||||
|
lng: 125.60,
|
||||||
|
speed: 14,
|
||||||
|
heading: 180,
|
||||||
|
risk: 0,
|
||||||
|
status: '작전중',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AIS-003',
|
||||||
|
mmsi: '',
|
||||||
|
name: '한라호',
|
||||||
|
type: '순찰선',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 37.10,
|
||||||
|
lng: 126.20,
|
||||||
|
speed: 12,
|
||||||
|
heading: 270,
|
||||||
|
risk: 0,
|
||||||
|
status: '작전중',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AIS-004',
|
||||||
|
mmsi: '',
|
||||||
|
name: '辽庄渔55567',
|
||||||
|
type: '중국어선',
|
||||||
|
flag: 'CN',
|
||||||
|
lat: 37.55,
|
||||||
|
lng: 124.80,
|
||||||
|
speed: 3.8,
|
||||||
|
heading: 135,
|
||||||
|
risk: 35,
|
||||||
|
status: '정상',
|
||||||
|
pattern: '비정기 신호',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DV-005',
|
||||||
|
mmsi: '440123456',
|
||||||
|
name: '한국어선-12',
|
||||||
|
type: '한국어선',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 34.80,
|
||||||
|
lng: 128.60,
|
||||||
|
speed: 5.5,
|
||||||
|
heading: 315,
|
||||||
|
risk: 35,
|
||||||
|
status: '정상',
|
||||||
|
pattern: '비정기 신호',
|
||||||
|
lastSignal: '5분 전',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AIS-006',
|
||||||
|
mmsi: '',
|
||||||
|
name: '제7동진호',
|
||||||
|
type: '한국어선',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 35.50,
|
||||||
|
lng: 126.50,
|
||||||
|
speed: 5.5,
|
||||||
|
heading: 315,
|
||||||
|
risk: 0,
|
||||||
|
status: '정상',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AIS-007',
|
||||||
|
mmsi: '',
|
||||||
|
name: '새한울호',
|
||||||
|
type: '한국어선',
|
||||||
|
flag: 'KR',
|
||||||
|
lat: 37.00,
|
||||||
|
lng: 125.85,
|
||||||
|
speed: 7.2,
|
||||||
|
heading: 200,
|
||||||
|
risk: 0,
|
||||||
|
status: '정상',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Derived: suspects only (risk >= 80)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
export const MOCK_SUSPECTS: VesselData[] = MOCK_VESSELS.filter(
|
||||||
|
(v) => v.risk >= 80,
|
||||||
|
);
|
||||||
6360
src/data/speciesCodes.json
Normal file
6360
src/data/speciesCodes.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2420
src/data/vesselTypeCodes.json
Normal file
2420
src/data/vesselTypeCodes.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
310
src/features/admin/AccessControl.tsx
Normal file
310
src/features/admin/AccessControl.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import {
|
||||||
|
Shield, Users, UserCheck, Key, Clock, Search, Plus, Edit2, Trash2,
|
||||||
|
Eye, Lock, AlertTriangle, FileText, ChevronDown, ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-01: 역할 기반 권한 관리(RBAC)
|
||||||
|
* - 조직·직급·직무에 따른 권한 관리
|
||||||
|
* - 메뉴·기능·데이터 접근 권한 분리
|
||||||
|
* - 감사 로그 기록 및 조회
|
||||||
|
* - 비밀번호/계정 잠금 정책 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface UserAccount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rank: string;
|
||||||
|
org: string;
|
||||||
|
role: string;
|
||||||
|
status: '활성' | '잠금' | '비활성';
|
||||||
|
lastLogin: string;
|
||||||
|
loginCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
time: string;
|
||||||
|
user: string;
|
||||||
|
action: string;
|
||||||
|
target: string;
|
||||||
|
ip: string;
|
||||||
|
result: '성공' | '실패' | '차단';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ name: '시스템 관리자', level: 'ADMIN', count: 3, color: 'bg-red-500/20 text-red-400', menus: '전체 메뉴', data: '전체 데이터' },
|
||||||
|
{ name: '상황실 운영자', level: 'OPERATOR', count: 12, color: 'bg-blue-500/20 text-blue-400', menus: '상황판·통계·경보', data: '관할 해역' },
|
||||||
|
{ name: '분석 담당자', level: 'ANALYST', count: 8, color: 'bg-purple-500/20 text-purple-400', menus: 'AI모드·통계·항적', data: '분석 데이터' },
|
||||||
|
{ name: '현장 단속요원', level: 'FIELD', count: 45, color: 'bg-green-500/20 text-green-400', menus: '함정Agent·모바일', data: '할당 구역' },
|
||||||
|
{ name: '유관기관 열람자', level: 'VIEWER', count: 6, color: 'bg-yellow-500/20 text-yellow-400', menus: '공유 대시보드', data: '공개 정보' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USERS: UserAccount[] = [
|
||||||
|
{ id: 'U001', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: '시스템 관리자', status: '활성', lastLogin: '2026-04-03 09:15', loginCount: 342 },
|
||||||
|
{ id: 'U002', name: '이상호', rank: '경위', org: '서해지방해경청', role: '상황실 운영자', status: '활성', lastLogin: '2026-04-03 08:30', loginCount: 128 },
|
||||||
|
{ id: 'U003', name: '박민수', rank: '경사', org: '5001함 삼봉', role: '현장 단속요원', status: '활성', lastLogin: '2026-04-02 22:15', loginCount: 67 },
|
||||||
|
{ id: 'U004', name: '정해진', rank: '주무관', org: '남해지방해경청', role: '분석 담당자', status: '잠금', lastLogin: '2026-04-01 14:20', loginCount: 89 },
|
||||||
|
{ id: 'U005', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: '유관기관 열람자', status: '활성', lastLogin: '2026-03-28 10:00', loginCount: 12 },
|
||||||
|
{ id: 'U006', name: '한지영', rank: '경장', org: '3009함', role: '현장 단속요원', status: '비활성', lastLogin: '2026-02-15 16:40', loginCount: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUDIT_LOGS: AuditLog[] = [
|
||||||
|
{ time: '2026-04-03 09:15:23', user: '김영수', action: '로그인', target: '시스템', ip: '10.20.30.1', result: '성공' },
|
||||||
|
{ time: '2026-04-03 09:12:05', user: '미상', action: '로그인 시도', target: '시스템', ip: '192.168.5.99', result: '차단' },
|
||||||
|
{ time: '2026-04-03 08:55:11', user: '이상호', action: '위험도 지도 조회', target: 'SFR-05', ip: '10.20.31.5', result: '성공' },
|
||||||
|
{ time: '2026-04-03 08:30:44', user: '이상호', action: '로그인', target: '시스템', ip: '10.20.31.5', result: '성공' },
|
||||||
|
{ time: '2026-04-03 07:45:00', user: '정해진', action: '로그인 시도(5회 실패)', target: '시스템', ip: '10.20.40.12', result: '실패' },
|
||||||
|
{ time: '2026-04-03 07:44:30', user: '시스템', action: '계정 잠금 처리', target: '정해진(U004)', ip: '-', result: '성공' },
|
||||||
|
{ time: '2026-04-02 22:15:10', user: '박민수', action: '불법어선 탐지 결과 조회', target: 'SFR-09', ip: '10.50.1.33', result: '성공' },
|
||||||
|
{ time: '2026-04-02 21:00:00', user: '시스템', action: '일일 감사 로그 백업', target: 'DB', ip: '-', result: '성공' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'roles' | 'users' | 'audit' | 'policy';
|
||||||
|
|
||||||
|
// DataTable 컬럼: 사용자 관리
|
||||||
|
const userColumns: DataColumn<UserAccount & Record<string, unknown>>[] = [
|
||||||
|
{ key: 'id', label: 'ID', width: '60px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
|
||||||
|
{ key: 'name', label: '이름', width: '70px', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
|
{ key: 'rank', label: '직급', width: '60px' },
|
||||||
|
{ key: 'org', label: '소속', sortable: true },
|
||||||
|
{ key: 'role', label: '역할', width: '100px', sortable: true,
|
||||||
|
render: (v) => <Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{v as string}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'status', label: '상태', width: '60px', 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: 'lastLogin', label: '최종 로그인', width: '130px', sortable: true,
|
||||||
|
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'id', label: '관리', width: '70px', align: 'center', sortable: false,
|
||||||
|
render: (_v, row) => (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<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>
|
||||||
|
{row.status === '잠금' && <button className="p-1 text-hint hover:text-green-400" title="잠금 해제"><Key className="w-3 h-3" /></button>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// DataTable 컬럼: 감사 로그
|
||||||
|
const auditColumns: DataColumn<AuditLog & Record<string, unknown>>[] = [
|
||||||
|
{ key: 'time', label: '일시', width: '160px', sortable: true,
|
||||||
|
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'user', label: '사용자', width: '70px', sortable: true },
|
||||||
|
{ key: 'action', label: '행위', sortable: true, render: (v) => <span className="text-heading">{v as string}</span> },
|
||||||
|
{ key: 'target', label: '대상', width: '80px' },
|
||||||
|
{ key: 'ip', label: 'IP', width: '110px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
|
||||||
|
{ key: 'result', label: '결과', width: '60px', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const r = v as string;
|
||||||
|
const c = r === '성공' ? 'bg-green-500/20 text-green-400' : r === '실패' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400';
|
||||||
|
return <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AccessControl() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const [tab, setTab] = useState<Tab>('roles');
|
||||||
|
|
||||||
|
const tabs: { key: Tab; icon: React.ElementType; label: string }[] = [
|
||||||
|
{ key: 'roles', icon: Shield, label: '역할 관리' },
|
||||||
|
{ key: 'users', icon: Users, label: '사용자 관리' },
|
||||||
|
{ key: 'audit', icon: FileText, label: '감사 로그' },
|
||||||
|
{ key: 'policy', icon: Lock, label: '보안 정책' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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-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 text-[10px] text-hint">
|
||||||
|
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
||||||
|
활성 사용자 <span className="text-green-400 font-bold">{USERS.filter((u) => u.status === '활성').length}</span>명
|
||||||
|
<span className="mx-1">|</span>
|
||||||
|
총 등록 <span className="text-heading font-bold">{USERS.length}</span>명
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={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-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 역할 관리 ── */}
|
||||||
|
{tab === 'roles' && (
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<Card key={r.name} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className={`${r.color} border-0 text-[10px] px-2 py-0.5`}>{r.level}</Badge>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-heading">{r.name}</div>
|
||||||
|
<div className="text-[10px] text-hint">할당 인원: {r.count}명</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-[10px]">
|
||||||
|
<div>
|
||||||
|
<span className="text-hint">메뉴 접근: </span>
|
||||||
|
<span className="text-label">{r.menus}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-hint">데이터 범위: </span>
|
||||||
|
<span className="text-label">{r.data}</span>
|
||||||
|
</div>
|
||||||
|
<button className="text-hint hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
<button className="flex items-center justify-center gap-1.5 py-3 border border-dashed border-slate-700/50 rounded-lg text-[11px] text-hint hover:text-blue-400 hover:border-blue-500/30 transition-colors">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
새 역할 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 사용자 관리 — DataTable 적용 ── */}
|
||||||
|
{tab === 'users' && (
|
||||||
|
<DataTable
|
||||||
|
data={USERS as (UserAccount & Record<string, unknown>)[]}
|
||||||
|
columns={userColumns}
|
||||||
|
pageSize={10}
|
||||||
|
searchPlaceholder="이름, 소속, 역할 검색..."
|
||||||
|
searchKeys={['name', 'org', 'role', 'rank']}
|
||||||
|
exportFilename="사용자목록"
|
||||||
|
showPagination
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 감사 로그 — DataTable 적용 ── */}
|
||||||
|
{tab === 'audit' && (
|
||||||
|
<DataTable
|
||||||
|
data={AUDIT_LOGS as (AuditLog & Record<string, unknown>)[]}
|
||||||
|
columns={auditColumns}
|
||||||
|
pageSize={10}
|
||||||
|
searchPlaceholder="사용자, 행위, IP 검색..."
|
||||||
|
searchKeys={['user', 'action', 'ip', 'target']}
|
||||||
|
exportFilename="감사로그"
|
||||||
|
title="로그인/로그아웃·비정상 접속·중요 정보 접근 감사 로그"
|
||||||
|
showPagination
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 보안 정책 ── */}
|
||||||
|
{tab === 'policy' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label">비밀번호 정책</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[
|
||||||
|
['최소 길이', '9자 이상'],
|
||||||
|
['복잡도', '영문+숫자+특수문자 조합'],
|
||||||
|
['변경 주기', '90일'],
|
||||||
|
['재사용 제한', '최근 3회'],
|
||||||
|
['만료 경고', '14일 전'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-hint">{k}</span>
|
||||||
|
<span className="text-label font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label">계정 잠금 정책</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[
|
||||||
|
['잠금 임계', '5회 연속 실패'],
|
||||||
|
['잠금 시간', '30분'],
|
||||||
|
['자동 해제', '활성'],
|
||||||
|
['관리자 해제', '즉시 가능'],
|
||||||
|
['비정상 접속 알림', 'SMS + 시스템 알림'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-hint">{k}</span>
|
||||||
|
<span className="text-label font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label">세션 관리</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[
|
||||||
|
['세션 타임아웃', '30분 (미사용 시)'],
|
||||||
|
['동시 접속', '1계정 1세션'],
|
||||||
|
['중복 로그인', '이전 세션 종료'],
|
||||||
|
['세션 갱신', '활동 시 자동 연장'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-hint">{k}</span>
|
||||||
|
<span className="text-label font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label">감사 로그 정책</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[
|
||||||
|
['로그 보존', '1년 이상'],
|
||||||
|
['기록 대상', '로그인·권한변경·데이터접근'],
|
||||||
|
['무결성 보장', 'Hash 검증'],
|
||||||
|
['백업 주기', '일 1회 자동'],
|
||||||
|
['조회 권한', 'ADMIN 전용'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]">
|
||||||
|
<span className="text-hint">{k}</span>
|
||||||
|
<span className="text-label font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/features/admin/AdminPanel.tsx
Normal file
91
src/features/admin/AdminPanel.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 시스템 관리 — 서버 상태, 디스크, 보안 설정 등 인프라 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SERVER_STATUS = [
|
||||||
|
{ name: 'WAS-01 (운영)', cpu: 42, mem: 65, disk: 38, status: '정상' },
|
||||||
|
{ name: 'WAS-02 (이중화)', cpu: 18, mem: 32, disk: 38, status: '정상' },
|
||||||
|
{ name: 'DB-01 (PostgreSQL)', cpu: 55, mem: 72, disk: 61, status: '정상' },
|
||||||
|
{ name: 'DB-02 (TimescaleDB)', cpu: 48, mem: 68, disk: 55, status: '정상' },
|
||||||
|
{ name: 'AI-Engine-01', cpu: 78, mem: 85, disk: 42, status: '주의' },
|
||||||
|
{ name: 'File-NAS', cpu: 5, mem: 12, disk: 82, status: '경고' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UsageBar({ value }: { value: number }) {
|
||||||
|
const color = value > 80 ? 'bg-red-500' : value > 60 ? 'bg-yellow-500' : 'bg-green-500';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-16 h-1.5 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${color}`} style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground w-8 text-right">{value}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서버 상태 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{SERVER_STATUS.map((s) => (
|
||||||
|
<Card key={s.name} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<div className="flex items-center justify-between"><span className="text-[9px] text-hint">MEM</span><UsageBar value={s.mem} /></div>
|
||||||
|
<div className="flex items-center justify-between"><span className="text-[9px] text-hint">DISK</span><UsageBar value={s.disk} /></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시스템 정보 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-blue-400" />데이터베이스</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-green-400" />보안 현황</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-[11px]"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
675
src/features/admin/DataHub.tsx
Normal file
675
src/features/admin/DataHub.tsx
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
|
import {
|
||||||
|
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
|
||||||
|
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
|
||||||
|
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
|
||||||
|
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
|
||||||
|
Network, X, ChevronRight, Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-03: 통합데이터 허브 수집·연계 관리
|
||||||
|
* ① 선박신호 수신 현황 — 24시간 타임라인 히트맵
|
||||||
|
* ② 선박위치정보 모니터링 — 연계 채널 테이블
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── ① 선박신호 수신 현황 데이터 ──────────────
|
||||||
|
|
||||||
|
type SignalStatus = 'ok' | 'warn' | 'error';
|
||||||
|
|
||||||
|
interface SignalSource {
|
||||||
|
name: string;
|
||||||
|
rate: number; // 수신율 %
|
||||||
|
timeline: SignalStatus[]; // 24시간 × 6 (10분 단위 = 144 슬롯)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTimeline(): SignalStatus[] {
|
||||||
|
return Array.from({ length: 144 }, () => {
|
||||||
|
const r = Math.random();
|
||||||
|
return r < 0.75 ? 'ok' : r < 0.90 ? 'warn' : 'error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_SOURCES: SignalSource[] = [
|
||||||
|
{ name: 'VTS', rate: 88.9, timeline: generateTimeline() },
|
||||||
|
{ name: 'VTS-AIS', rate: 85.4, timeline: generateTimeline() },
|
||||||
|
{ name: 'V-PASS', rate: 84.0, timeline: generateTimeline() },
|
||||||
|
{ name: 'E-NAVI', rate: 88.9, timeline: generateTimeline() },
|
||||||
|
{ name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIGNAL_COLORS: Record<SignalStatus, string> = {
|
||||||
|
ok: '#22c55e',
|
||||||
|
warn: '#eab308',
|
||||||
|
error: '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}시`);
|
||||||
|
|
||||||
|
// ─── ② 선박위치정보 모니터링 데이터 ──────────────
|
||||||
|
|
||||||
|
interface ChannelRecord {
|
||||||
|
no: number;
|
||||||
|
source: string; // 원천기관
|
||||||
|
code: string; // 기관코드
|
||||||
|
system: string; // 정보시스템명
|
||||||
|
linkInfo: string; // 연계정보
|
||||||
|
storage: string; // 저장장소
|
||||||
|
linkMethod: string; // 연계방식
|
||||||
|
cycle: string; // 수집주기
|
||||||
|
vesselCount: string; // 선박건수/신호건수
|
||||||
|
status: 'ON' | 'OFF'; // 연결상태
|
||||||
|
lastUpdate: string; // 최종갱신
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNELS: ChannelRecord[] = [
|
||||||
|
{ no: 1, source: '부산항', code: 'BS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '439 / 499', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 2, source: '부산항', code: 'BS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '133 / 463', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 3, source: '부산신항', code: 'BSN', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '255 / 278', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 4, source: '부산신항', code: 'BSN', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '133 / 426', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 5, source: '동해안', code: 'DH', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 6, source: '동해안', code: 'DH', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 7, source: '대산항', code: 'DS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '0', status: 'OFF', lastUpdate: '2026-03-15 15:38:57' },
|
||||||
|
{ no: 8, source: '대산항', code: 'DS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '0', status: 'OFF', lastUpdate: '2026-03-15 15:38:56' },
|
||||||
|
{ no: 9, source: '경인항', code: 'GI', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '120 / 136', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 10, source: '경인항', code: 'GI', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '55 / 467', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 11, source: '경인연안', code: 'GIC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '180 / 216', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 12, source: '경인연안', code: 'GIC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 13, source: '군산항', code: 'GS', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 14, source: '군산항', code: 'GS', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 15, source: '인천항', code: 'IC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '149 / 176', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 16, source: '인천항', code: 'IC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '55 / 503', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 17, source: '진도연안', code: 'JDC', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '433 / 524', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 18, source: '진도연안', code: 'JDC', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '256 / 1619', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 19, source: '제주항', code: 'JJ', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '429 / 508', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 20, source: '제주항', code: 'JJ', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '00:00:00', vesselCount: '160 / 1592', status: 'ON', lastUpdate: '2026-03-25 10:29:09' },
|
||||||
|
{ no: 21, source: '목포항', code: 'MP', system: 'VTS_AIS', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
{ no: 22, source: '목포항', code: 'MP', system: 'VTS_RT', linkInfo: 'VTS', storage: 'signal_t_dynamic_all_reply', linkMethod: 'KAFKA', cycle: '수신대기중', vesselCount: '0', status: 'OFF', lastUpdate: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 채널 테이블 컬럼 정의 ───────────────────
|
||||||
|
|
||||||
|
const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||||
|
{ key: 'no', label: '번호', width: '50px', align: 'center', sortable: true,
|
||||||
|
render: (v) => <span className="text-hint">{v as number}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'source', label: '원천기관', width: '80px', sortable: true,
|
||||||
|
render: (v) => <span className="text-label">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'code', label: '기관코드', width: '65px', align: 'center',
|
||||||
|
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
|
||||||
|
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ 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>,
|
||||||
|
},
|
||||||
|
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
|
||||||
|
render: (v) => {
|
||||||
|
const s = v as string;
|
||||||
|
return s === '수신대기중'
|
||||||
|
? <span className="text-orange-400 text-[9px]">{s}</span>
|
||||||
|
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'vesselCount', label: '선박건수/신호건수', width: '120px', align: 'right', sortable: true,
|
||||||
|
render: (v) => <span className="text-heading font-mono">{v as string}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'status', label: '연결상태', width: '80px', align: 'center', sortable: true,
|
||||||
|
render: (v, row) => {
|
||||||
|
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'}`}>
|
||||||
|
{v as string}
|
||||||
|
</Badge>
|
||||||
|
{row.lastUpdate && (
|
||||||
|
<span className="text-[8px] text-hint">{row.lastUpdate as string}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 히트맵 컴포넌트 ──────────────────────
|
||||||
|
|
||||||
|
function SignalTimeline({ source }: { source: SignalSource }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 라벨 */}
|
||||||
|
<div className="w-16 shrink-0 text-right">
|
||||||
|
<div className="text-[11px] font-bold" style={{
|
||||||
|
color: source.name === 'VTS' ? '#22c55e'
|
||||||
|
: source.name === 'VTS-AIS' ? '#3b82f6'
|
||||||
|
: source.name === 'V-PASS' ? '#a855f7'
|
||||||
|
: source.name === 'E-NAVI' ? '#ef4444'
|
||||||
|
: '#eab308',
|
||||||
|
}}>{source.name}</div>
|
||||||
|
<div className="text-[10px] text-hint">{source.rate}%</div>
|
||||||
|
</div>
|
||||||
|
{/* 타임라인 바 */}
|
||||||
|
<div className="flex-1 flex gap-px">
|
||||||
|
{source.timeline.map((status, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 h-5 rounded-[1px]"
|
||||||
|
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
|
||||||
|
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')} — ${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ③ 수집 작업 관리 데이터 ──────────────────
|
||||||
|
|
||||||
|
type JobStatus = '정지' | '대기중' | '수행중' | '장애발생';
|
||||||
|
type ServerType = 'SQL' | 'FILE' | 'FTP';
|
||||||
|
|
||||||
|
interface CollectJob {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serverType: ServerType;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
status: JobStatus;
|
||||||
|
schedule: string;
|
||||||
|
lastRun: string;
|
||||||
|
successRate: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLECT_JOBS: CollectJob[] = [
|
||||||
|
{ id: 'COL-001', name: '부산항 AIS 수집', serverType: 'SQL', serverName: 'vts-bs-db01', serverIp: '10.20.30.11', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', successRate: 98.5 },
|
||||||
|
{ id: 'COL-002', name: '인천항 VTS 수집', serverType: 'SQL', serverName: 'vts-ic-db01', serverIp: '10.20.30.12', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', successRate: 97.2 },
|
||||||
|
{ id: 'COL-003', name: 'V-PASS 파일 수집', serverType: 'FILE', serverName: 'vpass-ftp01', serverIp: '10.20.31.20', status: '수행중', schedule: '매 30분', lastRun: '2026-04-03 09:00:00', successRate: 94.1 },
|
||||||
|
{ id: 'COL-004', name: 'E-NAVI 로그 수집', serverType: 'FILE', serverName: 'enavi-nas01', serverIp: '10.20.31.30', status: '대기중', schedule: '매 1시간', lastRun: '2026-04-03 08:00:00', successRate: 91.8 },
|
||||||
|
{ id: 'COL-005', name: 'S&P AIS 해외 수집', serverType: 'FTP', serverName: 'sp-ais-ftp', serverIp: '203.45.67.89', status: '수행중', schedule: '매 15분', lastRun: '2026-04-03 09:15:00', successRate: 85.4 },
|
||||||
|
{ id: 'COL-006', name: '동해안 VTS 수집', serverType: 'SQL', serverName: 'vts-dh-db01', serverIp: '10.20.30.15', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-02 22:10:00', successRate: 0 },
|
||||||
|
{ id: 'COL-007', name: '군산항 레이더 수집', serverType: 'SQL', serverName: 'vts-gs-db01', serverIp: '10.20.30.18', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-01 14:30:00', successRate: 0 },
|
||||||
|
{ id: 'COL-008', name: '제주항 CCTV 수집', serverType: 'FTP', serverName: 'jj-cctv-ftp', serverIp: '10.20.32.50', status: '정지', schedule: '매 1시간', lastRun: '2026-03-28 12:00:00', successRate: 0 },
|
||||||
|
{ id: 'COL-009', name: '위성 SAR 수집', serverType: 'FTP', serverName: 'sat-sar-ftp', serverIp: '10.20.40.10', status: '수행중', schedule: '매 6시간', lastRun: '2026-04-03 06:00:00', successRate: 99.0 },
|
||||||
|
{ id: 'COL-010', name: '해수부 VMS 연동', serverType: 'SQL', serverName: 'mof-vms-db', serverIp: '10.20.50.11', status: '수행중', schedule: '매 5분', lastRun: '2026-04-03 09:20:00', successRate: 96.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const collectColumns: DataColumn<CollectJob>[] = [
|
||||||
|
{ 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: '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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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> },
|
||||||
|
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = v as number;
|
||||||
|
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
|
||||||
|
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||||
|
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>
|
||||||
|
) : row.status !== '장애발생' ? (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ④ 적재 작업 관리 데이터 ──────────────────
|
||||||
|
|
||||||
|
interface LoadJob {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourceJob: string;
|
||||||
|
targetTable: string;
|
||||||
|
targetDb: string;
|
||||||
|
status: JobStatus;
|
||||||
|
schedule: string;
|
||||||
|
lastRun: string;
|
||||||
|
recordCount: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOAD_JOBS: LoadJob[] = [
|
||||||
|
{ id: 'LOD-001', name: 'AIS 동적정보 적재', sourceJob: 'COL-001', targetTable: 'tb_ais_dynamic', targetDb: 'MDA_DB', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', recordCount: '12,450' },
|
||||||
|
{ id: 'LOD-002', name: 'VTS 레이더 적재', sourceJob: 'COL-002', targetTable: 'tb_vts_radar', targetDb: 'MDA_DB', status: '수행중', schedule: '매 10분', lastRun: '2026-04-03 09:20:00', recordCount: '8,320' },
|
||||||
|
{ id: 'LOD-003', name: 'V-PASS 위치 적재', sourceJob: 'COL-003', targetTable: 'tb_vpass_position', targetDb: 'MDA_DB', status: '수행중', schedule: '매 30분', lastRun: '2026-04-03 09:00:00', recordCount: '3,210' },
|
||||||
|
{ id: 'LOD-004', name: 'VMS 데이터 적재', sourceJob: 'COL-010', targetTable: 'tb_vms_track', targetDb: 'MDA_DB', status: '수행중', schedule: '매 5분', lastRun: '2026-04-03 09:20:00', recordCount: '5,677' },
|
||||||
|
{ id: 'LOD-005', name: 'SAR 위성 적재', sourceJob: 'COL-009', targetTable: 'tb_sat_imagery', targetDb: 'SAT_DB', status: '대기중', schedule: '매 6시간', lastRun: '2026-04-03 06:00:00', recordCount: '24' },
|
||||||
|
{ id: 'LOD-006', name: '해외 AIS 적재', sourceJob: 'COL-005', targetTable: 'tb_sais_global', targetDb: 'MDA_DB', status: '수행중', schedule: '매 15분', lastRun: '2026-04-03 09:15:00', recordCount: '45,200' },
|
||||||
|
{ id: 'LOD-007', name: '동해안 VTS 적재', sourceJob: 'COL-006', targetTable: 'tb_vts_dh', targetDb: 'MDA_DB', status: '장애발생', schedule: '매 10분', lastRun: '2026-04-02 22:10:00', recordCount: '0' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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: '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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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> },
|
||||||
|
{ key: 'recordCount', label: '적재건수', width: '80px', align: 'right', render: (v) => <span className="text-heading font-bold">{v as string}</span> },
|
||||||
|
{ 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>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ⑤ 연계서버 모니터링 데이터 ────────────────
|
||||||
|
|
||||||
|
type AgentRole = '수집' | '적재';
|
||||||
|
|
||||||
|
interface AgentServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: AgentRole;
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
port: string;
|
||||||
|
mac: string;
|
||||||
|
status: JobStatus;
|
||||||
|
taskCount: number;
|
||||||
|
cpuUsage: number;
|
||||||
|
memUsage: number;
|
||||||
|
diskUsage: number;
|
||||||
|
lastHeartbeat: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENTS: AgentServer[] = [
|
||||||
|
{ id: 'AGT-1001', name: '부산항 AIS 수집Agent', role: '수집', hostname: 'vts-bs-col01', ip: '10.20.30.11', port: '8081', mac: '00:1A:2B:3C:4D:01', status: '수행중', taskCount: 3, cpuUsage: 42, memUsage: 65, diskUsage: 38, lastHeartbeat: '2026-04-03 09:20:05' },
|
||||||
|
{ id: 'AGT-1002', name: '인천항 VTS 수집Agent', role: '수집', hostname: 'vts-ic-col01', ip: '10.20.30.12', port: '8081', mac: '00:1A:2B:3C:4D:02', status: '수행중', taskCount: 2, cpuUsage: 35, memUsage: 52, diskUsage: 41, lastHeartbeat: '2026-04-03 09:20:03' },
|
||||||
|
{ id: 'AGT-1003', name: '제주항 수집Agent', role: '수집', hostname: 'vts-jj-col01', ip: '10.20.30.19', port: '8081', mac: '00:1A:2B:3C:4D:03', status: '수행중', taskCount: 2, cpuUsage: 28, memUsage: 45, diskUsage: 55, lastHeartbeat: '2026-04-03 09:20:02' },
|
||||||
|
{ id: 'AGT-1004', name: '동해안 수집Agent', role: '수집', hostname: 'vts-dh-col01', ip: '10.20.30.15', port: '8081', mac: '00:1A:2B:3C:4D:04', status: '장애발생', taskCount: 0, cpuUsage: 0, memUsage: 0, diskUsage: 72, lastHeartbeat: '2026-04-02 22:10:15' },
|
||||||
|
{ id: 'AGT-1005', name: 'V-PASS 수집Agent', role: '수집', hostname: 'vpass-col01', ip: '10.20.31.20', port: '8082', mac: '00:1A:2B:3C:4D:05', status: '수행중', taskCount: 1, cpuUsage: 18, memUsage: 30, diskUsage: 25, lastHeartbeat: '2026-04-03 09:20:01' },
|
||||||
|
{ id: 'AGT-2001', name: 'MDA DB 적재Agent', role: '적재', hostname: 'mda-lod01', ip: '10.20.40.11', port: '9091', mac: '00:1A:2B:3C:4D:11', status: '수행중', taskCount: 5, cpuUsage: 55, memUsage: 72, diskUsage: 48, lastHeartbeat: '2026-04-03 09:20:04' },
|
||||||
|
{ id: 'AGT-2002', name: 'SAT DB 적재Agent', role: '적재', hostname: 'sat-lod01', ip: '10.20.40.12', port: '9091', mac: '00:1A:2B:3C:4D:12', status: '대기중', taskCount: 1, cpuUsage: 5, memUsage: 22, diskUsage: 33, lastHeartbeat: '2026-04-03 09:20:00' },
|
||||||
|
{ id: 'AGT-2003', name: '백업 적재Agent', role: '적재', hostname: 'bak-lod01', ip: '10.20.40.13', port: '9091', mac: '00:1A:2B:3C:4D:13', status: '정지', taskCount: 0, cpuUsage: 0, memUsage: 12, diskUsage: 15, lastHeartbeat: '2026-03-30 18:00:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UsageBar({ value, color }: { value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-14 h-1.5 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${color}`} style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground w-7 text-right">{value}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ──────────────────────
|
||||||
|
|
||||||
|
type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
|
||||||
|
|
||||||
|
export function DataHub() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const [tab, setTab] = useState<Tab>('signal');
|
||||||
|
const [selectedDate, setSelectedDate] = useState('2026-04-02');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
|
||||||
|
|
||||||
|
// 수집 작업 필터
|
||||||
|
const [collectTypeFilter, setCollectTypeFilter] = useState<'' | ServerType>('');
|
||||||
|
const [collectStatusFilter, setCollectStatusFilter] = useState<'' | JobStatus>('');
|
||||||
|
// 적재 작업 필터
|
||||||
|
const [loadStatusFilter, setLoadStatusFilter] = useState<'' | JobStatus>('');
|
||||||
|
// 연계서버 필터
|
||||||
|
const [agentRoleFilter, setAgentRoleFilter] = useState<'' | AgentRole>('');
|
||||||
|
const [agentStatusFilter, setAgentStatusFilter] = useState<'' | JobStatus>('');
|
||||||
|
|
||||||
|
const onCount = CHANNELS.filter((c) => c.status === 'ON').length;
|
||||||
|
const offCount = CHANNELS.filter((c) => c.status === 'OFF').length;
|
||||||
|
const hasPartialOff = offCount > 0;
|
||||||
|
|
||||||
|
const filteredChannels = statusFilter
|
||||||
|
? CHANNELS.filter((c) => c.status === statusFilter)
|
||||||
|
: CHANNELS;
|
||||||
|
|
||||||
|
const filteredCollectJobs = COLLECT_JOBS.filter((j) =>
|
||||||
|
(!collectTypeFilter || j.serverType === collectTypeFilter) &&
|
||||||
|
(!collectStatusFilter || j.status === collectStatusFilter)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredLoadJobs = LOAD_JOBS.filter((j) =>
|
||||||
|
(!loadStatusFilter || j.status === loadStatusFilter)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredAgents = AGENTS.filter((a) =>
|
||||||
|
(!agentRoleFilter || a.role === agentRoleFilter) &&
|
||||||
|
(!agentStatusFilter || a.status === agentStatusFilter)
|
||||||
|
);
|
||||||
|
|
||||||
|
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')}
|
||||||
|
</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" />
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
|
||||||
|
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||||
|
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
|
].map((kpi) => (
|
||||||
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
|
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${kpi.color}`}>{kpi.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{kpi.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' },
|
||||||
|
{ key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' },
|
||||||
|
{ key: 'collect' as Tab, icon: ArrowDownToLine, label: '수집 작업 관리' },
|
||||||
|
{ key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' },
|
||||||
|
{ key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' },
|
||||||
|
].map((t) => (
|
||||||
|
<button
|
||||||
|
key={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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ① 선박신호 수신 현황 ── */}
|
||||||
|
{tab === 'signal' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{/* 상단: 제목 + 날짜 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="text-sm font-bold text-heading">선박신호 수신 현황</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시간축 헤더 */}
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-16 shrink-0" />
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
{HOURS.map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 text-center text-[8px] text-hint" style={{ minWidth: 0 }}>
|
||||||
|
{i % 2 === 0 ? h : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 히트맵 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SIGNAL_SOURCES.map((src) => (
|
||||||
|
<SignalTimeline key={src.name} source={src} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="text-[9px] text-hint">범례:</div>
|
||||||
|
{[
|
||||||
|
{ label: '정상 수신', color: '#22c55e' },
|
||||||
|
{ label: '지연/경고', color: '#eab308' },
|
||||||
|
{ label: '장애/미수신', color: '#ef4444' },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: item.color }} />
|
||||||
|
<span className="text-[9px] text-muted-foreground">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto text-[9px] text-hint">
|
||||||
|
10분 단위 집계 · 24시간 (144 슬롯)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 선박위치정보 모니터링 ── */}
|
||||||
|
{tab === 'monitor' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 상태 요약 바 */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPartialOff ? (
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
||||||
|
)}
|
||||||
|
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}>
|
||||||
|
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-hint">
|
||||||
|
연계 채널 {CHANNELS.length}개 (ON: {onCount} / OFF: {offCount})
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-hint ml-1">
|
||||||
|
갱신: 오후 06:57:33
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{(['', 'ON', 'OFF'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f || '전체'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DataTable */}
|
||||||
|
<DataTable
|
||||||
|
data={filteredChannels}
|
||||||
|
columns={channelColumns}
|
||||||
|
pageSize={12}
|
||||||
|
searchPlaceholder="기관명, 코드, 시스템명 검색..."
|
||||||
|
searchKeys={['source', 'code', 'system', 'linkMethod']}
|
||||||
|
exportFilename="선박위치정보_모니터링"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 수집 작업 관리 ── */}
|
||||||
|
{tab === 'collect' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
<DataTable data={filteredCollectJobs} columns={collectColumns} pageSize={10}
|
||||||
|
searchPlaceholder="작업명, 서버명, IP 검색..." searchKeys={['name', 'serverName', 'serverIp']} exportFilename="수집작업목록" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 적재 작업 관리 ── */}
|
||||||
|
{tab === 'load' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable data={filteredLoadJobs} columns={loadColumns} pageSize={10}
|
||||||
|
searchPlaceholder="작업명, 테이블명, DB 검색..." searchKeys={['name', 'targetTable', 'targetDb', 'sourceJob']} exportFilename="적재작업목록" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 연계서버 모니터링 ── */}
|
||||||
|
{tab === 'agents' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 연계서버 카드 그리드 */}
|
||||||
|
<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">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">IP</span><span className="text-label font-mono">{agent.ip}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">Port</span><span className="text-label font-mono">{agent.port}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">MAC</span><span className="text-muted-foreground font-mono text-[9px]">{agent.mac}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pt-2 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">CPU</span><UsageBar value={agent.cpuUsage} color={agent.cpuUsage > 80 ? 'bg-red-500' : agent.cpuUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
|
||||||
|
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">MEM</span><UsageBar value={agent.memUsage} color={agent.memUsage > 80 ? 'bg-red-500' : agent.memUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
|
||||||
|
<div className="flex items-center justify-between"><span className="text-[9px] text-hint w-10">DISK</span><UsageBar value={agent.diskUsage} color={agent.diskUsage > 80 ? 'bg-red-500' : agent.diskUsage > 50 ? 'bg-yellow-500' : 'bg-green-500'} /></div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
src/features/admin/NoticeManagement.tsx
Normal file
424
src/features/admin/NoticeManagement.tsx
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
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 {
|
||||||
|
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
|
||||||
|
Users, Megaphone, AlertTriangle, Info, Search, Filter,
|
||||||
|
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
|
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-02: 공통알림(팝업, 배너, 시스템 공지 등) 관리
|
||||||
|
* - 노출 기간 설정 (시작일 ~ 종료일)
|
||||||
|
* - 대상 설정 (역할 기반)
|
||||||
|
* - 알림 유형: 정보, 경고, 긴급, 점검
|
||||||
|
* - 표시 방식: 배너, 팝업, 토스트
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── 시뮬레이션 데이터 ──────────────────
|
||||||
|
const INITIAL_NOTICES: SystemNotice[] = [
|
||||||
|
{
|
||||||
|
id: 'N-001', type: 'urgent', display: 'banner', title: '서해 NLL 인근 경보 강화',
|
||||||
|
message: '2026-04-03부터 서해 NLL 인근 해역에 대한 경계 경보가 강화되었습니다. 모든 상황실 운영자는 경계 태세를 유지하시기 바랍니다.',
|
||||||
|
startDate: '2026-04-03', endDate: '2026-04-10', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: true, pinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-002', type: 'maintenance', display: 'popup', title: '정기 시스템 점검 안내',
|
||||||
|
message: '2026-04-05(토) 02:00~06:00 시스템 정기점검이 예정되어 있습니다. 점검 중 서비스 이용이 제한될 수 있습니다.',
|
||||||
|
startDate: '2026-04-03', endDate: '2026-04-05', targetRoles: [], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-003', type: 'info', display: 'banner', title: 'AI 탐지 모델 v2.3 업데이트',
|
||||||
|
message: '다크베셀 탐지 정확도가 89% → 93%로 개선되었습니다. 환적 탐지 알고리즘이 업데이트되었습니다.',
|
||||||
|
startDate: '2026-04-01', endDate: '2026-04-15', targetRoles: ['ADMIN', 'ANALYST'], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-004', type: 'warning', display: 'banner', title: '비밀번호 변경 권고',
|
||||||
|
message: '비밀번호 변경 주기(90일)가 도래한 사용자는 보안정책에 따라 비밀번호를 변경해 주세요.',
|
||||||
|
startDate: '2026-03-25', endDate: '2026-04-25', targetRoles: [], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-005', type: 'info', display: 'toast', title: 'S-57 해도 데이터 갱신',
|
||||||
|
message: '해경GIS통합위치정보시스템 S-57 전자해도 데이터가 2026-04-01 기준으로 갱신되었습니다.',
|
||||||
|
startDate: '2026-04-01', endDate: '2026-04-07', targetRoles: ['ADMIN', 'OPERATOR', 'ANALYST'], dismissible: true, pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'N-006', type: 'urgent', display: 'popup', title: '중국어선 대규모 출항 정보',
|
||||||
|
message: '중국 산동성 위해·영성 항에서 대규모 어선단(약 300척) 출항이 감지되었습니다. 서해 EEZ 진입 예상시간: 2026-04-04 06:00경.',
|
||||||
|
startDate: '2026-04-03', endDate: '2026-04-06', targetRoles: ['ADMIN', 'OPERATOR'], dismissible: false, pinned: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
|
||||||
|
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
|
||||||
|
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
|
||||||
|
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
|
||||||
|
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ key: 'banner', label: '배너', icon: Monitor },
|
||||||
|
{ key: 'popup', label: '팝업', icon: MessageSquare },
|
||||||
|
{ key: 'toast', label: '토스트', icon: Bell },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
|
||||||
|
|
||||||
|
export function NoticeManagement() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const [notices, setNotices] = useState<SystemNotice[]>(INITIAL_NOTICES);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState<SystemNotice>({
|
||||||
|
id: '', type: 'info', display: 'banner', title: '', message: '',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||||
|
targetRoles: [], dismissible: true, pinned: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const openNew = () => {
|
||||||
|
setForm({
|
||||||
|
id: `N-${String(notices.length + 1).padStart(3, '0')}`,
|
||||||
|
type: 'info', display: 'banner', title: '', message: '',
|
||||||
|
startDate: now,
|
||||||
|
endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||||
|
targetRoles: [], dismissible: true, pinned: false,
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (notice: SystemNotice) => {
|
||||||
|
setForm({ ...notice });
|
||||||
|
setEditingId(notice.id);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editingId) {
|
||||||
|
setNotices((prev) => prev.map((n) => n.id === editingId ? form : n));
|
||||||
|
} else {
|
||||||
|
setNotices((prev) => [form, ...prev]);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setNotices((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRole = (role: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
targetRoles: prev.targetRoles.includes(role)
|
||||||
|
? prev.targetRoles.filter((r) => r !== role)
|
||||||
|
: [...prev.targetRoles, role],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// KPI
|
||||||
|
const activeCount = notices.filter((n) => n.startDate <= now && n.endDate >= now).length;
|
||||||
|
const scheduledCount = notices.filter((n) => n.startDate > now).length;
|
||||||
|
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')}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* KPI — 가로 한 줄 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
|
||||||
|
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||||
|
].map((kpi) => (
|
||||||
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
|
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${kpi.color}`}>{kpi.count}</span>
|
||||||
|
<span className="text-[9px] text-hint">{kpi.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px] table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '6%' }} />
|
||||||
|
<col style={{ width: '6%' }} />
|
||||||
|
<col style={{ width: '6%' }} />
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
<col style={{ width: '18%' }} />
|
||||||
|
<col style={{ width: '14%' }} />
|
||||||
|
<col style={{ width: '4%' }} />
|
||||||
|
<col style={{ width: '6%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="px-2 py-2 text-left font-medium">상태</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">유형</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">표시</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">제목</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">노출기간</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">대상</th>
|
||||||
|
<th className="px-1 py-2 text-center font-medium">고정</th>
|
||||||
|
<th className="px-1 py-2 text-center font-medium">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{notices.map((n) => {
|
||||||
|
const status = getStatus(n);
|
||||||
|
const typeOpt = TYPE_OPTIONS.find((t) => t.key === n.type)!;
|
||||||
|
const dispOpt = DISPLAY_OPTIONS.find((d) => d.key === n.display)!;
|
||||||
|
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>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[10px] ${typeOpt.color}`}>
|
||||||
|
<typeOpt.icon className="w-3 h-3" />
|
||||||
|
{typeOpt.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<dispOpt.icon className="w-3 h-3" />
|
||||||
|
{dispOpt.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 truncate">
|
||||||
|
<span className="text-heading font-medium">{n.title}</span>
|
||||||
|
<span className="text-hint text-[10px] ml-2">{n.message.slice(0, 50)}…</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-muted-foreground font-mono text-[10px] whitespace-nowrap">
|
||||||
|
{n.startDate.slice(5)} ~ {n.endDate.slice(5)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 truncate">
|
||||||
|
{n.targetRoles.length === 0 ? (
|
||||||
|
<span className="text-hint text-[10px]">전체</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] text-label">{n.targetRoles.join(' · ')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1.5 text-center">
|
||||||
|
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />}
|
||||||
|
</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="수정">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 등록/수정 폼 모달 ── */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-sm font-bold text-heading">
|
||||||
|
{editingId ? '알림 수정' : '새 알림 등록'}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* 제목 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">제목</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
placeholder="알림 제목 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">내용</label>
|
||||||
|
<textarea
|
||||||
|
value={form.message}
|
||||||
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
placeholder="알림 내용 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유형 + 표시방식 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">알림 유형</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TYPE_OPTIONS.map((opt) => (
|
||||||
|
<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 ${
|
||||||
|
form.type === opt.key
|
||||||
|
? `bg-switch-background/50 ${opt.color} font-bold`
|
||||||
|
: 'text-hint hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<opt.icon className="w-3 h-3" />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">표시 방식</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DISPLAY_OPTIONS.map((opt) => (
|
||||||
|
<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 ${
|
||||||
|
form.display === opt.key
|
||||||
|
? 'bg-blue-600/20 text-blue-400 font-bold'
|
||||||
|
: 'text-hint hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<opt.icon className="w-3 h-3" />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 노출기간 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">시작일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.startDate}
|
||||||
|
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||||
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">종료일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.endDate}
|
||||||
|
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||||
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 역할 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">
|
||||||
|
대상 역할 <span className="text-hint">(미선택 시 전체)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => toggleRole(role)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||||
|
form.targetRoles.includes(role)
|
||||||
|
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold'
|
||||||
|
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.dismissible}
|
||||||
|
onChange={(e) => setForm({ ...form, dismissible: e.target.checked })}
|
||||||
|
className="w-3.5 h-3.5 rounded border-slate-600 bg-secondary text-blue-600 focus:ring-blue-500/30"
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] text-muted-foreground">닫기 가능</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.pinned}
|
||||||
|
onChange={(e) => setForm({ ...form, pinned: e.target.checked })}
|
||||||
|
className="w-3.5 h-3.5 rounded border-slate-600 bg-secondary text-blue-600 focus:ring-blue-500/30"
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] text-muted-foreground">상단 고정</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<SaveButton
|
||||||
|
onClick={handleSave}
|
||||||
|
label={editingId ? '수정' : '등록'}
|
||||||
|
disabled={!form.title.trim() || !form.message.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
507
src/features/admin/SystemConfig.tsx
Normal file
507
src/features/admin/SystemConfig.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
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 {
|
||||||
|
Settings, Database, Search, ChevronDown, ChevronRight,
|
||||||
|
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
||||||
|
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
||||||
|
CODE_STATS, getAreaMajors, getSpeciesMajors, getFisheryMajors, getVesselTypeMajors,
|
||||||
|
filterByMajor, searchCodes,
|
||||||
|
type AreaCode, type SpeciesCode, type FisheryCode, type VesselTypeCode,
|
||||||
|
} from '@/data/commonCodes';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-02: 시스템 기본 환경설정 및 공통 기능
|
||||||
|
* - 공통코드 기준정보 조회·검색·필터링
|
||||||
|
* - 해역분류(52) / 어종(578) / 어업유형(59) / 선박유형(186)
|
||||||
|
* - 시스템 기본 환경설정 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
type CodeTab = 'areas' | 'species' | 'fishery' | 'vessels' | 'settings';
|
||||||
|
|
||||||
|
const TAB_ITEMS: { key: CodeTab; icon: React.ElementType; label: string; count?: number }[] = [
|
||||||
|
{ key: 'areas', icon: Map, label: '해역분류', count: CODE_STATS.areas },
|
||||||
|
{ key: 'species', icon: Fish, label: '어종', count: CODE_STATS.species },
|
||||||
|
{ key: 'fishery', icon: Anchor, label: '어업유형', count: CODE_STATS.fishery },
|
||||||
|
{ key: 'vessels', icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes },
|
||||||
|
{ key: 'settings', icon: Settings, label: '환경설정' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
// ─── 시스템 설정 기본값 ──────────────────
|
||||||
|
const SYSTEM_SETTINGS = {
|
||||||
|
general: [
|
||||||
|
{ key: 'system_name', label: '시스템명', value: 'AI 기반 불법조업 탐지·차단 플랫폼', type: 'text' },
|
||||||
|
{ key: 'org_name', label: '운영기관', value: '해양경찰청', type: 'text' },
|
||||||
|
{ key: 'version', label: '시스템 버전', value: 'v1.0.0', type: 'text' },
|
||||||
|
{ key: 'timezone', label: '시간대', value: 'Asia/Seoul (UTC+9)', type: 'text' },
|
||||||
|
{ key: 'language', label: '기본 언어', value: '한국어 (ko-KR)', type: 'text' },
|
||||||
|
{ key: 'date_format', label: '날짜 형식', value: 'YYYY-MM-DD HH:mm:ss', type: 'text' },
|
||||||
|
{ key: 'coord_format', label: '좌표 형식', value: 'DD.DDDDDD (십진도)', type: 'select' },
|
||||||
|
],
|
||||||
|
map: [
|
||||||
|
{ key: 'default_center', label: '기본 지도 중심', value: 'N35.5° E127.0°', type: 'text' },
|
||||||
|
{ key: 'default_zoom', label: '기본 줌 레벨', value: '7', type: 'number' },
|
||||||
|
{ key: 'map_provider', label: '지도 타일', value: 'CartoDB Dark Matter', type: 'select' },
|
||||||
|
{ key: 'eez_layer', label: 'EEZ 경계선', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'nll_layer', label: 'NLL 표시', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'ais_refresh', label: 'AIS 갱신 주기', value: '10초', type: 'select' },
|
||||||
|
{ key: 'trail_length', label: '항적 표시 기간', value: '24시간', type: 'select' },
|
||||||
|
],
|
||||||
|
alert: [
|
||||||
|
{ key: 'eez_alert', label: 'EEZ 침범 경보', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'dark_vessel', label: '다크베셀 경보', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'mmsi_spoof', label: 'MMSI 변조 경보', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'transfer_alert', label: '불법환적 경보', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'speed_alert', label: '속도이상 경보', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'alert_sound', label: '경보 사운드', value: '활성', type: 'toggle' },
|
||||||
|
{ key: 'alert_retention', label: '경보 보존기간', value: '90일', type: 'select' },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ key: 'ais_source', label: 'AIS 데이터 출처', value: 'LRIT / S-AIS / T-AIS 통합', type: 'text' },
|
||||||
|
{ key: 'vms_source', label: 'VMS 데이터 출처', value: '해수부 VMS 연동', type: 'text' },
|
||||||
|
{ key: 'sat_source', label: '위성 데이터', value: 'SAR / 광학위성 연동', type: 'text' },
|
||||||
|
{ key: 'data_retention', label: '데이터 보존기간', value: '5년', type: 'select' },
|
||||||
|
{ key: 'backup_cycle', label: '백업 주기', value: '일 1회 (03:00)', type: 'select' },
|
||||||
|
{ key: 'db_encryption', label: 'DB 암호화', value: 'AES-256', type: 'text' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SystemConfig() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const [tab, setTab] = useState<CodeTab>('areas');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [majorFilter, setMajorFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 탭 변경 시 필터 초기화
|
||||||
|
const changeTab = (t: CodeTab) => {
|
||||||
|
setTab(t);
|
||||||
|
setQuery('');
|
||||||
|
setMajorFilter('');
|
||||||
|
setPage(0);
|
||||||
|
setExpandedRow(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 해역분류 필터링 ─────────────────
|
||||||
|
const filteredAreas = useMemo(() => {
|
||||||
|
const result = filterByMajor(AREA_CODES, majorFilter);
|
||||||
|
return searchCodes(result, query);
|
||||||
|
}, [query, majorFilter]);
|
||||||
|
|
||||||
|
// ─── 어종 필터링 ──────────────────────
|
||||||
|
const filteredSpecies = useMemo(() => {
|
||||||
|
const result = filterByMajor(SPECIES_CODES, majorFilter);
|
||||||
|
if (query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return result.filter(
|
||||||
|
(s) => s.code.includes(q) || s.name.includes(q) || s.nameEn.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [query, majorFilter]);
|
||||||
|
|
||||||
|
// ─── 어업유형 필터링 ──────────────────
|
||||||
|
const filteredFishery = useMemo(() => {
|
||||||
|
const result = filterByMajor(FISHERY_CODES, majorFilter);
|
||||||
|
return searchCodes(result, query);
|
||||||
|
}, [query, majorFilter]);
|
||||||
|
|
||||||
|
// ─── 선박유형 필터링 ──────────────────
|
||||||
|
const filteredVessels = useMemo(() => {
|
||||||
|
const result = filterByMajor(VESSEL_TYPE_CODES, majorFilter);
|
||||||
|
if (query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return result.filter(
|
||||||
|
(v) => v.code.toLowerCase().includes(q) || v.name.toLowerCase().includes(q) || v.aisCode.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [query, majorFilter]);
|
||||||
|
|
||||||
|
// ─── 현재 탭에 따른 데이터 ────────────
|
||||||
|
const currentData = tab === 'areas' ? filteredAreas
|
||||||
|
: tab === 'species' ? filteredSpecies
|
||||||
|
: tab === 'fishery' ? filteredFishery
|
||||||
|
: tab === 'vessels' ? filteredVessels
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const totalItems = currentData.length;
|
||||||
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
|
||||||
|
const pagedData = currentData.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
|
||||||
|
const majors = tab === 'areas' ? getAreaMajors()
|
||||||
|
: tab === 'species' ? getSpeciesMajors()
|
||||||
|
: tab === 'fishery' ? getFisheryMajors()
|
||||||
|
: tab === 'vessels' ? getVesselTypeMajors()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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')}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* KPI 카드 */}
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
{[
|
||||||
|
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
||||||
|
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
||||||
|
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
||||||
|
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
||||||
|
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
||||||
|
].map((kpi) => (
|
||||||
|
<Card key={kpi.label} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
|
<kpi.icon className={`w-4 h-4 ${kpi.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-lg font-bold ${kpi.color}`}>{kpi.count.toLocaleString()}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{kpi.label}</div>
|
||||||
|
<div className="text-[8px] text-hint">{kpi.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAB_ITEMS.map((t) => (
|
||||||
|
<button
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />
|
||||||
|
{t.label}
|
||||||
|
{t.count != null && (
|
||||||
|
<span className={`text-[9px] ml-1 px-1.5 py-0.5 rounded ${
|
||||||
|
tab === t.key ? 'bg-white/20' : 'bg-switch-background/50'
|
||||||
|
}`}>{t.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색·필터 (코드 탭에서만) */}
|
||||||
|
{tab !== 'settings' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
||||||
|
placeholder={
|
||||||
|
tab === 'areas' ? '코드번호, 해역명 검색...'
|
||||||
|
: tab === 'species' ? '코드, 어종명, 영문명 검색...'
|
||||||
|
: tab === 'fishery' ? '코드, 어업유형명 검색...'
|
||||||
|
: '코드, 선박유형명, AIS코드 검색...'
|
||||||
|
}
|
||||||
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-4 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-cyan-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||||
|
<select
|
||||||
|
value={majorFilter}
|
||||||
|
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
||||||
|
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
||||||
|
>
|
||||||
|
<option value="">전체 대분류</option>
|
||||||
|
{majors.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint ml-auto">
|
||||||
|
<Hash className="w-3 h-3 inline mr-1" />
|
||||||
|
{totalItems.toLocaleString()}건
|
||||||
|
{query && ` (검색: "${query}")`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 해역분류 테이블 ── */}
|
||||||
|
{tab === 'areas' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-24">코드</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-20">대분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-28">중분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">해역명</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">관할기관</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-32">비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(pagedData as AreaCode[]).map((a) => (
|
||||||
|
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-label">{a.mid}</td>
|
||||||
|
<td className="px-4 py-2 text-heading font-medium">{a.name}</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{a.authority}</td>
|
||||||
|
<td className="px-4 py-2 text-hint text-[10px]">{a.note}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 어종 테이블 ── */}
|
||||||
|
{tab === 'species' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-20">코드</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-20">대분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-24">중분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">어종명</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">영문명</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">서식해역</th>
|
||||||
|
<th className="px-4 py-2.5 text-center font-medium w-14">사용</th>
|
||||||
|
<th className="px-4 py-2.5 text-center font-medium w-14">낚시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(pagedData as SpeciesCode[]).map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.code}
|
||||||
|
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
||||||
|
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{s.major}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
|
||||||
|
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.nameEn}</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{s.active
|
||||||
|
? <span className="text-green-400 text-[9px]">Y</span>
|
||||||
|
: <span className="text-hint text-[9px]">N</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{s.fishing && <span className="text-yellow-400">★</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 어업유형 테이블 ── */}
|
||||||
|
{tab === 'fishery' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-24">코드</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-24">대분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-20">중분류</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">어업유형명</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">주요 어획대상</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium w-16">허가</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium">법적 근거</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(pagedData as FisheryCode[]).map((f) => (
|
||||||
|
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
|
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-hint text-[10px]">{f.law}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 선박유형 테이블 ── */}
|
||||||
|
{tab === 'vessels' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-20">코드</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-20">대분류</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-24">중분류</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium">선박유형명</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-14">출처</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-28">톤수기준</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium">주요용도</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium w-20">AIS코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(pagedData as VesselTypeCode[]).map((v) => (
|
||||||
|
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
||||||
|
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
||||||
|
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`text-[9px] font-mono ${
|
||||||
|
v.source === 'AIS' ? 'text-cyan-400'
|
||||||
|
: v.source === 'GIC' ? 'text-green-400'
|
||||||
|
: v.source === 'RRA' ? 'text-blue-400'
|
||||||
|
: v.source === 'PMS' ? 'text-orange-400'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}>{v.source}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
|
||||||
|
<td className="px-3 py-2 text-hint font-mono text-[10px]">{v.aisCode}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 (코드 탭에서만) */}
|
||||||
|
{tab !== 'settings' && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-hint">
|
||||||
|
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 환경설정 탭 ── */}
|
||||||
|
{tab === 'settings' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{Object.entries(SYSTEM_SETTINGS).map(([section, items]) => {
|
||||||
|
const sectionLabels: Record<string, { title: string; icon: React.ElementType }> = {
|
||||||
|
general: { title: '일반 설정', icon: Settings },
|
||||||
|
map: { title: '지도 설정', icon: Map },
|
||||||
|
alert: { title: '경보 설정', icon: BarChart3 },
|
||||||
|
data: { title: '데이터 설정', icon: Database },
|
||||||
|
};
|
||||||
|
const meta = sectionLabels[section] || { title: section, icon: Info };
|
||||||
|
return (
|
||||||
|
<Card key={section} className="bg-surface-raised border-border">
|
||||||
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<meta.icon className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
{meta.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
||||||
|
<span className="text-hint">{item.label}</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
item.value === '활성' ? 'text-green-400' : 'text-label'
|
||||||
|
}`}>{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/features/admin/index.ts
Normal file
5
src/features/admin/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AccessControl } from './AccessControl';
|
||||||
|
export { SystemConfig } from './SystemConfig';
|
||||||
|
export { NoticeManagement } from './NoticeManagement';
|
||||||
|
export { AdminPanel } from './AdminPanel';
|
||||||
|
export { DataHub } from './DataHub';
|
||||||
136
src/features/ai-operations/AIAssistant.tsx
Normal file
136
src/features/ai-operations/AIAssistant.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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 { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
/* SFR-20: 자연어 처리 기반 AI 의사결정 지원(Q&A) 서비스 */
|
||||||
|
|
||||||
|
interface Message { role: 'user' | 'assistant'; content: string; refs?: string[]; }
|
||||||
|
|
||||||
|
const SAMPLE_CONVERSATIONS = [
|
||||||
|
{ id: '1', title: '서해 NLL 침범 대응 절차', time: '08:45' },
|
||||||
|
{ id: '2', title: '중국어선 환적 판별 기준', time: '08:20' },
|
||||||
|
{ id: '3', title: '조업 금지 기간 법령 조회', time: '어제' },
|
||||||
|
{ id: '4', title: 'MMSI 변조 선박 처리 방안', time: '04-02' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_MESSAGES: Message[] = [
|
||||||
|
{ role: 'user', content: '서해 NLL 인근에서 중국어선이 EEZ를 침범한 경우 단속 절차는 어떻게 되나요?' },
|
||||||
|
{ role: 'assistant', content: `EEZ 침범 중국어선에 대한 단속 절차는 다음과 같습니다:
|
||||||
|
|
||||||
|
**1단계: 탐지·식별**
|
||||||
|
- AI 시스템에서 EEZ 침범 자동 탐지 (SFR-09 패턴탐지)
|
||||||
|
- AIS/레이더를 통한 선박 식별 (MMSI, 선명, 국적)
|
||||||
|
|
||||||
|
**2단계: 경보·출동**
|
||||||
|
- 상황실 즉시 경보 발령 (SFR-17 AI 알림)
|
||||||
|
- 인근 함정 출동 지시 (SFR-07 경로 추천)
|
||||||
|
|
||||||
|
**3단계: 정선·검문**
|
||||||
|
- VHF 16채널 정선 명령 (한·중·영)
|
||||||
|
- 임검반 승선 검문 (선박서류, 어획물, 어구)
|
||||||
|
|
||||||
|
**4단계: 조치**
|
||||||
|
- 위반 확인 시: 나포 → 인천/목포 호송 → 담보금 부과
|
||||||
|
- 경미 위반: 경고 조치 후 퇴거 명령
|
||||||
|
|
||||||
|
**관련 법령:**
|
||||||
|
- 배타적경제수역법 제5조 (외국인 어업 제한)
|
||||||
|
- 한중어업협정 제6조 (특정수역 관리)`, refs: ['배타적경제수역법 제5조', '한중어업협정 제6조', 'SFR-09 패턴탐지', 'SFR-17 AI알림'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AIAssistant() {
|
||||||
|
const { t } = useTranslation('ai');
|
||||||
|
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [selectedConv, setSelectedConv] = useState('1');
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setMessages(prev => [...prev,
|
||||||
|
{ role: 'user', content: input },
|
||||||
|
{ role: 'assistant', content: '질의를 분석 중입니다. 관련 법령·사례·AI 예측 결과를 종합하여 답변을 생성합니다...', refs: [] },
|
||||||
|
]);
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex-1 flex gap-3 min-h-0">
|
||||||
|
{/* 대화 이력 사이드바 */}
|
||||||
|
<Card className="w-56 shrink-0 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="text-[11px] font-bold text-label mb-2">대화 이력</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{SAMPLE_CONVERSATIONS.map(c => (
|
||||||
|
<div key={c.id} onClick={() => setSelectedConv(c.id)}
|
||||||
|
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
|
||||||
|
<div className="truncate">{c.title}</div>
|
||||||
|
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-2 border-t border-border">
|
||||||
|
<div className="text-[9px] text-hint flex items-center gap-1"><Shield className="w-3 h-3" />부적절 답변 필터링 활성</div>
|
||||||
|
<div className="text-[9px] text-hint flex items-center gap-1 mt-1"><BookOpen className="w-3 h-3" />법령 DB 2,345건 연결</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 채팅 영역 */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 mb-3">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-blue-600/20 border border-blue-500/20'
|
||||||
|
: 'bg-surface-overlay border border-border'
|
||||||
|
}`}>
|
||||||
|
<div className="text-[11px] text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
|
||||||
|
{msg.refs && msg.refs.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
|
||||||
|
{msg.refs.map(r => (
|
||||||
|
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
|
||||||
|
<FileText className="w-2.5 h-2.5" />{r}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
|
||||||
|
<User className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 입력창 */}
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<input
|
||||||
|
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">
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
991
src/features/ai-operations/AIModelManagement.tsx
Normal file
991
src/features/ai-operations/AIModelManagement.tsx
Normal file
@ -0,0 +1,991 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import {
|
||||||
|
Brain, Settings, Zap, Activity, TrendingUp, BarChart3,
|
||||||
|
Target, Eye, AlertTriangle, CheckCircle, RefreshCw,
|
||||||
|
Play, Square, Upload, GitBranch, Layers, Shield,
|
||||||
|
Anchor, Ship, Radio, Radar, Clock, ArrowUpRight, ArrowDownRight,
|
||||||
|
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';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-04: AI 불법조업 예측 모델 관리
|
||||||
|
*
|
||||||
|
* ① 모델 레지스트리 — 버전 관리, 배포 이력, 성능 비교
|
||||||
|
* ② 탐지 규칙 관리 — ON/OFF, 가중치 조정
|
||||||
|
* ③ 피처 엔지니어링 — Kinematic/Geometric/Temporal/Behavioral/Contextual
|
||||||
|
* ④ 학습 파이프라인 — 데이터→전처리→학습→평가→배포
|
||||||
|
* ⑤ 성능 모니터링 — 정확도, Recall, F1, 오탐률, 리드타임 추이
|
||||||
|
* ⑥ 어구 탐지 모델 — GB/T 5147 어구 분류, 피처 프로파일
|
||||||
|
* ⑦ 7대 탐지 엔진 — 불법조업 감시 알고리즘 v4.0 (906척 허가어선)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── 탭 정의 ─────────────────────────
|
||||||
|
|
||||||
|
type Tab = 'registry' | 'rules' | 'features' | 'pipeline' | 'monitoring' | 'gear' | 'engines' | 'api';
|
||||||
|
|
||||||
|
// ─── ① 모델 레지스트리 ──────────────────
|
||||||
|
|
||||||
|
interface ModelVersion {
|
||||||
|
version: string;
|
||||||
|
status: '운영중' | '대기' | '테스트' | '폐기';
|
||||||
|
accuracy: number;
|
||||||
|
recall: number;
|
||||||
|
f1: number;
|
||||||
|
falseAlarm: number;
|
||||||
|
trainData: string;
|
||||||
|
deployDate: string;
|
||||||
|
note: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODELS: ModelVersion[] = [
|
||||||
|
{ version: 'v2.4.0', status: '테스트', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8, trainData: '1,456,200', deployDate: '-', note: '다크베셀 탐지 강화' },
|
||||||
|
{ version: 'v2.3.1', status: '운영중', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9, trainData: '1,245,891', deployDate: '2026-03-01', note: 'MMSI 변조 탐지 개선' },
|
||||||
|
{ version: 'v2.2.0', status: '대기', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5, trainData: '1,102,340', deployDate: '2026-01-15', note: '환적 탐지 추가' },
|
||||||
|
{ version: 'v2.1.0', status: '폐기', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7, trainData: '980,120', deployDate: '2025-11-01', note: '초기 멀티센서 융합' },
|
||||||
|
{ version: 'v2.0.0', status: '폐기', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0, trainData: '750,000', deployDate: '2025-08-15', note: '베이스라인 모델' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelColumns: DataColumn<ModelVersion>[] = [
|
||||||
|
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
||||||
|
{ key: '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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => <span className="text-heading font-bold">{v as number}%</span> },
|
||||||
|
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||||
|
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
|
||||||
|
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
|
||||||
|
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
|
||||||
|
},
|
||||||
|
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
|
||||||
|
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'note', label: '비고', render: (v) => <span className="text-hint">{v as string}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ② 탐지 규칙 ──────────────────────
|
||||||
|
|
||||||
|
const defaultRules = [
|
||||||
|
{ name: 'EEZ 침범 탐지', model: 'LSTM+CNN', accuracy: 95, enabled: true, weight: 30, desc: '배타적경제수역 무단 진입 실시간 탐지' },
|
||||||
|
{ name: 'MMSI 변조 탐지', model: 'Transformer', accuracy: 92, enabled: true, weight: 25, desc: 'MMSI 번호 위조·변경 패턴 감지' },
|
||||||
|
{ name: '다크베셀 탐지', model: 'SAR+RF Fusion', accuracy: 91, enabled: true, weight: 20, desc: 'AIS 미송출 선박 위성·RF 기반 탐지' },
|
||||||
|
{ name: 'AIS 신호 소실 탐지', model: 'Anomaly Det.', accuracy: 89, enabled: true, weight: 10, desc: 'AIS 고의 비활성화 패턴 분석' },
|
||||||
|
{ name: '불법환적 탐지', model: 'GNN', accuracy: 87, enabled: true, weight: 10, desc: '어선-운반선 간 접현·환적 행위 탐지' },
|
||||||
|
{ name: '불법 조업 패턴', model: 'Ensemble', accuracy: 78, enabled: false, weight: 5, desc: '궤적 기반 불법 조업 행위 패턴 분류' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ③ 피처 엔지니어링 ──────────────────
|
||||||
|
|
||||||
|
const FEATURE_CATEGORIES = [
|
||||||
|
{ cat: 'Kinematic', color: '#3b82f6', icon: Activity, features: [
|
||||||
|
{ name: 'Mean Speed', desc: '평균속도', unit: 'kt' },
|
||||||
|
{ name: 'Speed Std', desc: '속도편차', unit: 'kt' },
|
||||||
|
{ name: 'Low Speed Ratio', desc: '저속비율', unit: '%' },
|
||||||
|
{ name: 'High Speed Ratio', desc: '고속비율', unit: '%' },
|
||||||
|
]},
|
||||||
|
{ cat: 'Geometric', color: '#8b5cf6', icon: Target, features: [
|
||||||
|
{ name: 'Trajectory Curvature', desc: '궤적 곡률', unit: '' },
|
||||||
|
{ name: 'Turn Angle', desc: '회전각', unit: '°' },
|
||||||
|
{ name: 'Circularity Index', desc: '원형지수', unit: '' },
|
||||||
|
{ name: 'Path Entropy', desc: '경로 엔트로피', unit: '' },
|
||||||
|
]},
|
||||||
|
{ cat: 'Temporal', color: '#f59e0b', icon: Clock, features: [
|
||||||
|
{ name: 'Dwell Time', desc: '정지시간', unit: 'min' },
|
||||||
|
{ name: 'Operation Duration', desc: '조업지속시간', unit: 'hr' },
|
||||||
|
{ name: 'Revisit Freq', desc: '반복방문주기', unit: 'day' },
|
||||||
|
{ name: 'Night Activity', desc: '야간활동비율', unit: '%' },
|
||||||
|
]},
|
||||||
|
{ cat: 'Behavioral', color: '#ef4444', icon: Eye, features: [
|
||||||
|
{ name: 'Speed Transition', desc: '속도변화패턴', unit: '' },
|
||||||
|
{ name: 'Stop-Move Pattern', desc: '정지-이동 패턴', unit: '' },
|
||||||
|
{ name: 'Scanning Pattern', desc: '탐색패턴', unit: '' },
|
||||||
|
{ name: 'Drift Pattern', desc: '표류패턴', unit: '' },
|
||||||
|
]},
|
||||||
|
{ cat: 'Contextual', color: '#10b981', icon: Layers, features: [
|
||||||
|
{ name: 'Vessel Density', desc: '주변 선박밀도', unit: '척/nm²' },
|
||||||
|
{ name: 'Fleet Coherence', desc: '선단 동기화', unit: '' },
|
||||||
|
{ name: 'EEZ Distance', desc: 'EEZ 경계거리', unit: 'nm' },
|
||||||
|
{ name: 'Fishing Zone', desc: '조업구역 여부', unit: '' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ④ 파이프라인 ──────────────────────
|
||||||
|
|
||||||
|
const PIPELINE_STAGES = [
|
||||||
|
{ stage: '데이터 수집', status: '정상', items: ['AIS/VMS', 'SAR 위성', '광학 위성', 'V-PASS/E-NAVI', 'VTS/VHF', '기상·해양'], icon: Database, color: '#3b82f6' },
|
||||||
|
{ stage: '전처리', status: '정상', items: ['노이즈 제거', '결측값 보정', '시간 동기화', '공간 정합', '정규화'], icon: Settings, color: '#8b5cf6' },
|
||||||
|
{ stage: '피처 추출', status: '정상', items: ['항적 패턴', '조업행동', '경계선 거리', '환경 컨텍스트'], icon: Cpu, color: '#f59e0b' },
|
||||||
|
{ stage: 'AI 학습', status: '진행중', items: ['LSTM+CNN', 'Transformer', 'GNN', 'Ensemble'], icon: Brain, color: '#ef4444' },
|
||||||
|
{ stage: '평가', status: '대기', items: ['Confusion Matrix', '과적합 검사', 'K-Fold 검증', '드리프트 감시'], icon: BarChart3, color: '#10b981' },
|
||||||
|
{ stage: '배포', status: '대기', items: ['Online Serving', '버전 관리', 'MDA 연계', '대시보드 출력'], icon: Upload, color: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ⑤ 성능 모니터링 차트 데이터 ──────────
|
||||||
|
|
||||||
|
const PERF_HISTORY = [
|
||||||
|
{ month: '10월', accuracy: 81.0, recall: 78.5, f1: 79.7, falseAlarm: 19.0 },
|
||||||
|
{ month: '11월', accuracy: 84.3, recall: 82.1, f1: 83.2, falseAlarm: 15.7 },
|
||||||
|
{ month: '12월', accuracy: 85.8, recall: 83.5, f1: 84.6, falseAlarm: 14.2 },
|
||||||
|
{ month: '1월', accuracy: 87.5, recall: 85.2, f1: 86.3, falseAlarm: 12.5 },
|
||||||
|
{ month: '2월', accuracy: 89.0, recall: 87.1, f1: 88.0, falseAlarm: 11.0 },
|
||||||
|
{ month: '3월', accuracy: 90.1, recall: 88.7, f1: 89.4, falseAlarm: 9.9 },
|
||||||
|
{ month: '4월', accuracy: 93.2, recall: 91.5, f1: 92.3, falseAlarm: 7.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DETECTION_BY_TYPE = [
|
||||||
|
{ name: 'EEZ 침범', value: 35, color: '#ef4444' },
|
||||||
|
{ name: '다크베셀', value: 25, color: '#f97316' },
|
||||||
|
{ name: 'MMSI 변조', value: 18, color: '#eab308' },
|
||||||
|
{ name: '불법환적', value: 12, color: '#8b5cf6' },
|
||||||
|
{ name: '조업패턴', value: 10, color: '#3b82f6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ⑥ 어구 탐지 모델 ──────────────────
|
||||||
|
|
||||||
|
interface GearCode {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
risk: '고위험' | '중위험' | '저위험';
|
||||||
|
speed: string;
|
||||||
|
feature: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEAR_CODES: GearCode[] = [
|
||||||
|
{ code: 'TDD', name: '단선 저층트롤', risk: '고위험', speed: '2.5-4.5kt', feature: '저속직선왕복' },
|
||||||
|
{ code: 'TDS', name: '쌍선 저층트롤', risk: '고위험', speed: '2.0-3.5kt', feature: '2선편대동기속도' },
|
||||||
|
{ code: 'WDD', name: '집어등 단선선망', risk: '고위험', speed: '0-1.5kt', feature: '야간정선원형항적' },
|
||||||
|
{ code: 'WSS', name: '집어등 쌍선선망', risk: '고위험', speed: '0-2.0kt', feature: '대향포위VHF교신' },
|
||||||
|
{ code: 'CLD', name: '단선 유자망', risk: '고위험', speed: '0.5-2.0kt', feature: '해류동조장시간저속' },
|
||||||
|
{ code: 'TZD', name: '단선 중층트롤', risk: '중위험', speed: '3.0-5.0kt', feature: '지그재그어군반응' },
|
||||||
|
{ code: 'TXD', name: '새우 트롤', risk: '중위험', speed: '1.5-3.0kt', feature: '연안접근극저속' },
|
||||||
|
{ code: 'DYD', name: '단선 연승낚시', risk: '저위험', speed: '0-3.0kt', feature: '직선투승왕복' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const gearColumns: DataColumn<GearCode>[] = [
|
||||||
|
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||||
|
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
|
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => <span className="text-label font-mono">{v as string}</span> },
|
||||||
|
{ key: 'feature', label: 'AI 피처 포인트', render: (v) => <span className="text-muted-foreground">{v as string}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GEAR_PROFILES = [
|
||||||
|
{ type: '트롤 (Trawl)', desc: '"느리게, 꾸준히, 반복적으로"', color: '#3b82f6',
|
||||||
|
features: [{ k: 'Mean Speed', v: '2~5 kt' }, { k: 'Path Entropy', v: 'High' }, { k: 'Turn Angle', v: 'Medium' }, { k: 'Duration', v: 'Long' }] },
|
||||||
|
{ type: '자망 (Gillnet)', desc: '"멈추고, 기다리고, 다시 온다"', color: '#eab308',
|
||||||
|
features: [{ k: 'Mean Speed', v: '0~2 kt' }, { k: 'Dwell Time', v: 'Very High' }, { k: 'Low Speed', v: '>80%' }, { k: 'Revisit', v: 'High' }] },
|
||||||
|
{ type: '선망 (Purse Seine)', desc: '"빠르게 돌고 → 느려짐"', color: '#ef4444',
|
||||||
|
features: [{ k: 'High Speed', v: '>7 kt' }, { k: 'Circularity', v: 'High' }, { k: 'Speed Trans.', v: 'High' }, { k: 'Fleet', v: 'High' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ⑦ 7대 탐지 엔진 (불법조업 감시 알고리즘 v4.0) ───
|
||||||
|
|
||||||
|
interface DetectionEngine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
purpose: string;
|
||||||
|
input: string;
|
||||||
|
severity: string;
|
||||||
|
cooldown: string;
|
||||||
|
detail: string;
|
||||||
|
status: '운영중' | '테스트' | '개발중';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETECTION_ENGINES: DetectionEngine[] = [
|
||||||
|
{ id: '#1', name: '허가 유효성 검증', purpose: '미등록/미허가 중국어선 식별', input: 'AIS MMSI', severity: 'CRITICAL', cooldown: '24시간', detail: 'MMSI가 906척 허가 DB에 매핑되지 않으면 즉시 경보. 신규 MMSI 감지 시 미등록 선박 분류.', status: '운영중' },
|
||||||
|
{ id: '#2', name: '휴어기 조업 탐지', purpose: '허가 조업기간 외 조업행위 탐지', input: '위치, 속도, 허가기간', severity: 'CRITICAL', cooldown: '60분', detail: '선박별 최대 2개 조업기간 부여. 엔진#7 조업판정 결과 참조하여 단순 통과와 실제 조업 구분.', status: '운영중' },
|
||||||
|
{ id: '#3', name: '수역 이탈 감시', purpose: '업종별 허가수역 외 조업/체류 탐지', input: '위치(lat,lon)', severity: 'HIGH~CRITICAL', cooldown: '30분', detail: 'Ray-casting Point-in-Polygon 알고리즘. 수역II 787개 좌표점 등 복잡 폴리곤 정밀 판정. 비허가수역 HIGH, 전수역이탈 CRITICAL.', status: '운영중' },
|
||||||
|
{ id: '#4', name: '본선-부속선 분리 탐지', purpose: '2척식 저인망 쌍의 비정상 이격 감시', input: '쌍 위치, Haversine 거리', severity: 'HIGH~CRITICAL', cooldown: '60분', detail: '311쌍(622척) 매칭. 0~3NM 정상, 3~10NM HIGH(독립조업 의심), 10NM 초과 CRITICAL(긴급분리).', status: '운영중' },
|
||||||
|
{ id: '#5', name: '어획 할당량 초과 감시', purpose: '선박별 누적 어획량과 허가 할당량 비교', input: '어획량(톤)', severity: 'MEDIUM~CRITICAL', cooldown: '24시간', detail: '80% 도달 MEDIUM, 100% 초과 CRITICAL. PS 1,500톤, GN 23~52톤, FC 0톤(어획 없음).', status: '운영중' },
|
||||||
|
{ id: '#6', name: '환적 의심 탐지', purpose: '조업선-운반선(FC) 간 해상 환적 탐지', input: '근접거리, 속도, 시간', severity: 'HIGH', cooldown: '120분', detail: '3조건 동시충족: ①0.5NM 이하 근접 ②양쪽 2.0kn 이하 저속 ③30분 이상 지속. 신뢰도 0.6~0.95.', status: '운영중' },
|
||||||
|
{ id: '#7', name: '조업 행위 판정 (보조)', purpose: 'AIS 속도/침로로 조업 여부 추정', input: '속도, 침로 패턴', severity: '-', cooldown: '-', detail: 'PT 1.5~5.0kn, GN 0.5~3.0kn, PS 1.0~6.0kn, FC 0~2.0kn. 엔진#2·#3에서 참조.', status: '운영중' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TARGET_VESSELS = [
|
||||||
|
{ code: 'PT', name: '2척식 저인망 (본선)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
|
||||||
|
{ code: 'PT-S', name: '2척식 저인망 (부속)', count: 323, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '1.5~5.0 kn' },
|
||||||
|
{ code: 'GN', name: '유망 (Gill Net)', count: 200, zones: 'II, III, IV', period1: '01/01~04/15', period2: '10/16~12/31', speed: '0.5~3.0 kn' },
|
||||||
|
{ code: 'OT', name: '1척식 저인망 (Otter)', count: 13, zones: 'II, III', period1: '01/01~04/15', period2: '10/16~12/31', speed: '2.0~5.5 kn' },
|
||||||
|
{ code: 'PS', name: '위망 (Purse Seine)', count: 16, zones: 'I, II, III, IV', period1: '02/01~06/01', period2: '09/01~12/31', speed: '1.0~6.0 kn' },
|
||||||
|
{ code: 'FC', name: '운반선 (Carrier)', count: 31, zones: 'I, II, III, IV', period1: '01/01~12/31', period2: '-', speed: '0~2.0 kn' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALARM_SEVERITY = [
|
||||||
|
{ level: 'CRITICAL', label: '긴급', color: '#ef4444', desc: '즉시 대응. 미등록·휴어기·할당초과·전수역이탈' },
|
||||||
|
{ level: 'HIGH', label: '높음', color: '#f97316', desc: '1시간 내 확인. 수역이탈·쌍분리·AIS소실·환적' },
|
||||||
|
{ level: 'MEDIUM', label: '보통', color: '#eab308', desc: '당일 확인. 할당량 80% 경고' },
|
||||||
|
{ level: 'LOW', label: '낮음', color: '#3b82f6', desc: '참고. 경미한 속도 이상' },
|
||||||
|
{ level: 'INFO', label: '정보', color: '#6b7280', desc: '로그 기록용. 정상 조업 패턴' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ──────────────────────
|
||||||
|
|
||||||
|
export function AIModelManagement() {
|
||||||
|
const { t } = useTranslation('ai');
|
||||||
|
const [tab, setTab] = useState<Tab>('registry');
|
||||||
|
const [rules, setRules] = useState(defaultRules);
|
||||||
|
|
||||||
|
const toggleRule = (i: number) => setRules((prev) => prev.map((r, idx) => idx === i ? { ...r, enabled: !r.enabled } : r));
|
||||||
|
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')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
|
{t('modelManagement.desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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">
|
||||||
|
{[
|
||||||
|
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||||
|
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
|
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||||
|
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
|
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
|
].map((kpi) => (
|
||||||
|
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
|
<kpi.icon className={`w-3.5 h-3.5 ${kpi.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-base font-bold ${kpi.color}`}>{kpi.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{kpi.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ key: 'registry' as Tab, icon: GitBranch, label: '모델 레지스트리' },
|
||||||
|
{ key: 'rules' as Tab, icon: Settings, label: '탐지 규칙 관리' },
|
||||||
|
{ key: 'features' as Tab, icon: Cpu, label: '피처 엔지니어링' },
|
||||||
|
{ key: 'pipeline' as Tab, icon: Activity, label: '학습 파이프라인' },
|
||||||
|
{ key: 'monitoring' as Tab, icon: BarChart3, label: '성능 모니터링' },
|
||||||
|
{ key: 'gear' as Tab, icon: Anchor, label: '어구 탐지 모델' },
|
||||||
|
{ 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'}`}>
|
||||||
|
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ① 모델 레지스트리 ── */}
|
||||||
|
{tab === 'registry' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 업데이트 알림 */}
|
||||||
|
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-blue-300 font-bold">새로운 모델 v2.4.0 테스트 완료</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button 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>
|
||||||
|
</div>
|
||||||
|
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 탐지 규칙 관리 ── */}
|
||||||
|
{tab === 'rules' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* 규칙 ON/OFF */}
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
{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)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[9px] text-hint">정확도</div>
|
||||||
|
<div className="text-[12px] font-bold text-heading">{rule.accuracy}%</div>
|
||||||
|
</div>
|
||||||
|
<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: `${rule.accuracy}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[9px] text-hint">가중치</div>
|
||||||
|
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 가중치 합계 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" />위험도 가중치</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{rules.filter((r) => r.enabled).map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex justify-between text-[10px] mb-1"><span className="text-muted-foreground">{r.name}</span><span className="text-heading font-bold">{r.weight}%</span></div>
|
||||||
|
<div className="relative h-2 bg-switch-background rounded-full">
|
||||||
|
<div className="h-2 bg-gradient-to-r from-slate-500 to-white rounded-full" style={{ width: `${r.weight}%` }} />
|
||||||
|
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full border-2 border-slate-400 shadow" style={{ left: `calc(${r.weight}% - 6px)` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between text-[11px] pt-3 border-t border-border">
|
||||||
|
<span className="text-muted-foreground">합계</span>
|
||||||
|
<span className="text-heading font-bold">{rules.filter((r) => r.enabled).reduce((s, r) => s + r.weight, 0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 피처 엔지니어링 ── */}
|
||||||
|
{tab === 'features' && (
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
{FEATURE_CATEGORIES.map((cat) => (
|
||||||
|
<Card key={cat.cat} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${cat.color}15` }}>
|
||||||
|
<cat.icon className="w-4 h-4" style={{ color: cat.color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold text-heading">{cat.cat}</div>
|
||||||
|
<div className="text-[9px] text-hint">{cat.features.length} features</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cat.features.map((f) => (
|
||||||
|
<div key={f.name} className="flex items-center justify-between px-2 py-1.5 bg-surface-overlay rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-label font-medium">{f.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{f.desc}</div>
|
||||||
|
</div>
|
||||||
|
{f.unit && <span className="text-[8px] text-hint shrink-0">{f.unit}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 학습 파이프라인 ── */}
|
||||||
|
{tab === 'pipeline' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 파이프라인 스테이지 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{PIPELINE_STAGES.map((stage, i) => {
|
||||||
|
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
|
||||||
|
return (
|
||||||
|
<div key={stage.stage} className="flex-1 flex items-start gap-2">
|
||||||
|
<Card className="flex-1 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${stage.color}15` }}>
|
||||||
|
<stage.icon className="w-4 h-4" style={{ color: stage.color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold text-heading">{stage.stage}</div>
|
||||||
|
<div className={`text-[9px] font-medium ${stColor}`}>{stage.status}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{stage.items.map((item) => (
|
||||||
|
<div key={item} className="text-[9px] text-hint flex items-center gap-1">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-muted" />
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{i < PIPELINE_STAGES.length - 1 && (
|
||||||
|
<ChevronRight className="w-4 h-4 text-hint mt-6 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자가학습 루프 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">Self-Learning Cycle — 지속 학습 루프</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{[
|
||||||
|
{ step: '1. 신호 수집', desc: '단속 결과, 오탐/미탐 사례 실시간 수집', color: '#3b82f6' },
|
||||||
|
{ step: '2. 데이터 축적', desc: '정제 및 라벨링 → 학습 데이터셋 자동 갱신', color: '#8b5cf6' },
|
||||||
|
{ step: '3. 자동 재학습', desc: 'AutoML 기반 재학습 및 성능 검증', color: '#ef4444' },
|
||||||
|
{ step: '4. 모델 개선', desc: '검증 모델 배포 → 탐지 정확도 향상', color: '#10b981' },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="flex-1 text-center">
|
||||||
|
<div className="w-10 h-10 rounded-full mx-auto mb-2 flex items-center justify-center" style={{ backgroundColor: `${s.color}15`, border: `2px solid ${s.color}40` }}>
|
||||||
|
<span className="text-[12px] font-bold" style={{ color: s.color }}>{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-heading">{s.step}</div>
|
||||||
|
<div className="text-[9px] text-hint mt-0.5">{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 성능 모니터링 ── */}
|
||||||
|
{tab === 'monitoring' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* 정확도 추이 */}
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">모델 성능 추이</div>
|
||||||
|
<EcAreaChart
|
||||||
|
data={PERF_HISTORY}
|
||||||
|
xKey="month"
|
||||||
|
series={[
|
||||||
|
{ key: 'accuracy', name: 'Accuracy', color: '#22c55e' },
|
||||||
|
{ key: 'recall', name: 'Recall', color: '#3b82f6' },
|
||||||
|
{ key: 'f1', name: 'F1', color: '#a855f7' },
|
||||||
|
]}
|
||||||
|
height={220}
|
||||||
|
yAxisDomain={[70, 100]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 탐지 유형별 비율 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">탐지 유형별 비율</div>
|
||||||
|
<EcPieChart
|
||||||
|
data={DETECTION_BY_TYPE.map(d => ({ name: d.name, value: d.value, color: d.color }))}
|
||||||
|
height={160}
|
||||||
|
innerRadius={35}
|
||||||
|
outerRadius={65}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 mt-2">
|
||||||
|
{DETECTION_BY_TYPE.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center justify-between text-[10px]">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} />
|
||||||
|
<span className="text-muted-foreground">{d.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-heading font-bold">{d.value}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 오탐률 추이 */}
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">오탐률 (False Alarm Rate) 추이</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={PERF_HISTORY}
|
||||||
|
xKey="month"
|
||||||
|
series={[{ key: 'falseAlarm', name: '오탐률 %' }]}
|
||||||
|
height={160}
|
||||||
|
itemColors={PERF_HISTORY.map(d => d.falseAlarm < 10 ? '#22c55e' : d.falseAlarm < 15 ? '#eab308' : '#ef4444')}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* KPI 목표 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">KPI 목표 달성</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ label: '탐지 정확도', current: 93.2, target: 90, unit: '%', color: '#22c55e' },
|
||||||
|
{ label: '오탐률', current: 7.8, target: 10, unit: '%', color: '#eab308', reverse: true },
|
||||||
|
{ label: '리드타임', current: 12, target: 15, unit: 'min', color: '#06b6d4', reverse: true },
|
||||||
|
].map((kpi) => {
|
||||||
|
const achieved = kpi.reverse ? kpi.current <= kpi.target : kpi.current >= kpi.target;
|
||||||
|
return (
|
||||||
|
<div key={kpi.label}>
|
||||||
|
<div className="flex justify-between text-[10px] mb-1">
|
||||||
|
<span className="text-muted-foreground">{kpi.label}</span>
|
||||||
|
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
|
||||||
|
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full" style={{
|
||||||
|
width: `${Math.min(100, kpi.reverse ? (kpi.target / Math.max(kpi.current, 1)) * 100 : (kpi.current / kpi.target) * 100)}%`,
|
||||||
|
backgroundColor: kpi.color,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑥ 어구 탐지 모델 ── */}
|
||||||
|
{tab === 'gear' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* GB/T 5147 어구 코드 테이블 */}
|
||||||
|
<DataTable data={GEAR_CODES} columns={gearColumns} pageSize={10}
|
||||||
|
title="GB/T 5147 주요 어구 코드 매핑" searchPlaceholder="코드, 어구명 검색..."
|
||||||
|
searchKeys={['code', 'name', 'feature']} exportFilename="어구코드" />
|
||||||
|
|
||||||
|
{/* 어구별 피처 프로파일 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{GEAR_PROFILES.map((gp) => (
|
||||||
|
<Card key={gp.type} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: gp.color }} />
|
||||||
|
<div className="text-[12px] font-bold text-heading">{gp.type}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint italic mb-3">{gp.desc}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gp.features.map((f) => (
|
||||||
|
<div key={f.k} className="flex justify-between text-[10px] px-2 py-1.5 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-muted-foreground">{f.k}</span>
|
||||||
|
<span className="text-heading font-medium">{f.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑦ 7대 탐지 엔진 ── */}
|
||||||
|
{tab === 'engines' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 개요 배너 */}
|
||||||
|
<div className="bg-indigo-950/20 border border-indigo-900/30 rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<Shield className="w-8 h-8 text-indigo-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-indigo-300">불법조업 감시 알고리즘 v4.0 — 7대 핵심 탐지 엔진</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
대상: 중국 허가어선 906척 (497개 소유주) · 특정어업수역 I~IV · AIS 실시간 데이터 입력 → 7대 엔진 순차 실행 → AlarmEvent 생성
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex gap-3 shrink-0 text-center">
|
||||||
|
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint">허가 선박</div></div>
|
||||||
|
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint">탐지 엔진</div></div>
|
||||||
|
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint">업종 분류</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{/* 번호 */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-indigo-500/15 border border-indigo-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-indigo-400 font-bold text-sm">{eng.id}</span>
|
||||||
|
</div>
|
||||||
|
{/* 내용 */}
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
{/* 우측 정보 */}
|
||||||
|
<div className="flex items-center gap-4 shrink-0 text-right">
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-hint">입력</div>
|
||||||
|
<div className="text-[10px] text-label">{eng.input}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-hint">심각도</div>
|
||||||
|
<Badge className={`border-0 text-[9px] ${sevColor}`}>{eng.severity}</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-hint">쿨다운</div>
|
||||||
|
<div className="text-[10px] text-label font-mono">{eng.cooldown}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 2칸: 대상 선박 + 알람 등급 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* 대상 선박 업종 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<Ship className="w-4 h-4 text-cyan-400" />대상 선박 현황 (906척, 6개 업종)
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="py-1.5 text-left font-medium">업종</th>
|
||||||
|
<th className="py-1.5 text-left font-medium">업종명</th>
|
||||||
|
<th className="py-1.5 text-right font-medium">척수</th>
|
||||||
|
<th className="py-1.5 text-left font-medium pl-3">허가수역</th>
|
||||||
|
<th className="py-1.5 text-left font-medium">조업기간1</th>
|
||||||
|
<th className="py-1.5 text-left font-medium">조업기간2</th>
|
||||||
|
<th className="py-1.5 text-left font-medium">조업속도</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{TARGET_VESSELS.map((v) => (
|
||||||
|
<tr key={v.code} className="border-b border-border">
|
||||||
|
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
|
||||||
|
<td className="py-1.5 text-label">{v.name}</td>
|
||||||
|
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground font-mono">{v.period1}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground font-mono">{v.period2}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground font-mono">{v.speed}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td className="py-1.5 text-hint font-bold" colSpan={2}>합계</td>
|
||||||
|
<td className="py-1.5 text-heading font-bold text-right">906</td>
|
||||||
|
<td className="py-1.5 text-hint pl-3" colSpan={4}>497개 소유주</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 알람 심각도 체계 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-400" />알람 심각도 체계
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ALARM_SEVERITY.map((a) => (
|
||||||
|
<div key={a.level} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-surface-overlay">
|
||||||
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: a.color }} />
|
||||||
|
<div className="w-16 shrink-0">
|
||||||
|
<span className="text-[10px] font-bold" style={{ color: a.color }}>{a.level}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-label font-medium w-10">{a.label}</span>
|
||||||
|
<span className="text-[10px] text-hint flex-1">{a.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 쿨다운 요약 */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-border">
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground mb-2">알람 쿨다운 (중복 방지)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-[9px]">
|
||||||
|
{[
|
||||||
|
['수역이탈', '30분'], ['쌍분리', '60분'], ['휴어기', '60분'], ['환적', '120분'],
|
||||||
|
['AIS소실', '360분'], ['할당량/미등록', '24시간'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded">
|
||||||
|
<span className="text-hint">{k}</span>
|
||||||
|
<span className="text-label font-mono">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑧ 예측 결과 API ── */}
|
||||||
|
{tab === 'api' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 개요 */}
|
||||||
|
<div className="bg-emerald-950/20 border border-emerald-900/30 rounded-xl p-4 flex items-center gap-4">
|
||||||
|
<Globe className="w-8 h-8 text-emerald-400 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-bold text-emerald-300">RFP-04 · 예측 결과 API 제공</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
격자(Grid) · 해역(Zone) · 시간(Time) 단위로 예측 결과를 저장하고, SFR-06(위험도 지도), SFR-09(불법어선 탐지) 등 후속 서비스에서 RESTful API로 활용
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 shrink-0 text-center">
|
||||||
|
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API 엔드포인트</div></div>
|
||||||
|
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint">저장 단위</div></div>
|
||||||
|
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint">가용률</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 단위 3종 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ unit: '격자 (Grid)', icon: Layers, color: '#3b82f6', desc: '1km × 1km 격자 단위', store: 'tb_pred_grid',
|
||||||
|
fields: ['grid_id', 'lat_center / lon_center', 'risk_score (0~100)', 'illegal_prob', 'dark_vessel_count', 'predicted_at', 'model_version'],
|
||||||
|
sample: { grid_id: 'G-37120-12463', lat: 37.120, lon: 124.630, risk: 87.5, prob: 0.92, dark: 3, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||||||
|
},
|
||||||
|
{ unit: '해역 (Zone)', icon: Globe, color: '#10b981', desc: '특정어업수역 I~IV + EEZ', store: 'tb_pred_zone',
|
||||||
|
fields: ['zone_id (I/II/III/IV/EEZ)', 'zone_name', 'total_risk_score', 'vessel_count', 'violation_count', 'top_threat_type', 'predicted_at'],
|
||||||
|
sample: { grid_id: 'ZONE-II', lat: null, lon: null, risk: 72.3, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||||||
|
},
|
||||||
|
{ unit: '시간 (Time)', icon: Clock, color: '#f59e0b', desc: '1시간 / 6시간 / 24시간 집계', store: 'tb_pred_time',
|
||||||
|
fields: ['time_bucket (1h/6h/24h)', 'start_time / end_time', 'avg_risk_score', 'max_risk_score', 'total_alarms', 'detection_count', 'model_version'],
|
||||||
|
sample: { grid_id: 'T-2026040309-1H', lat: null, lon: null, risk: 65.8, prob: null, dark: null, time: '2026-04-03T09:00Z', ver: 'v2.3.1' },
|
||||||
|
},
|
||||||
|
].map((s) => (
|
||||||
|
<Card key={s.unit} className="bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${s.color}15` }}>
|
||||||
|
<s.icon className="w-4 h-4" style={{ color: s.color }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-bold text-heading">{s.unit}</div>
|
||||||
|
<div className="text-[9px] text-hint">{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mb-2 font-mono">{s.store}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{s.fields.map((f) => (
|
||||||
|
<div key={f} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: s.color }} />
|
||||||
|
<span className="text-muted-foreground font-mono">{f}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 엔드포인트 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<Code className="w-4 h-4 text-cyan-400" />
|
||||||
|
RESTful API 엔드포인트
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-[10px] table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '7%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '8%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '15%' }} />
|
||||||
|
<col style={{ width: '10%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-hint">
|
||||||
|
<th className="py-2 text-left font-medium">Method</th>
|
||||||
|
<th className="py-2 text-left font-medium">Endpoint</th>
|
||||||
|
<th className="py-2 text-left font-medium">단위</th>
|
||||||
|
<th className="py-2 text-left font-medium">설명</th>
|
||||||
|
<th className="py-2 text-left font-medium">활용 SFR</th>
|
||||||
|
<th className="py-2 text-center font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/grid', unit: '격자', desc: '격자별 위험도 예측 결과 조회', sfr: 'SFR-06 위험도', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/grid/{id}', unit: '격자', desc: '특정 격자 상세 예측 결과', sfr: 'SFR-06', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/grid/heatmap', unit: '격자', desc: '히트맵용 전체 격자 위험도', sfr: 'SFR-06 히트맵', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/zone', unit: '해역', desc: '해역별 위험도 집계 조회', sfr: 'SFR-06, SFR-09', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}', unit: '해역', desc: '특정 수역 상세 (위협 유형 포함)', sfr: 'SFR-09 탐지', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/zone/{id}/vessels', unit: '해역', desc: '수역 내 의심 선박 목록', sfr: 'SFR-09', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/time', unit: '시간', desc: '시간대별 위험도 추이', sfr: 'SFR-06 추이', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/time/forecast', unit: '시간', desc: '향후 6/12/24시간 예측', sfr: 'SFR-06 예보', status: '테스트' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/vessel/{mmsi}', unit: '선박', desc: '특정 선박 위험도 이력', sfr: 'SFR-09', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/alarms', unit: '알람', desc: '예측 기반 알람 목록', sfr: 'SFR-09 경보', status: '운영' },
|
||||||
|
{ method: 'POST', endpoint: '/api/v1/predictions/run', unit: '실행', desc: '수동 예측 실행 트리거', sfr: 'SFR-04', status: '운영' },
|
||||||
|
{ method: 'GET', endpoint: '/api/v1/predictions/status', unit: '상태', desc: '예측 엔진 상태·버전 정보', sfr: 'SFR-04', status: '운영' },
|
||||||
|
].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>
|
||||||
|
</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 하단: API 호출 예시 + 연계 서비스 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* API 호출 예시 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<Code className="w-4 h-4 text-cyan-400" />
|
||||||
|
API 호출 예시
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 격자 조회 */}
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
?lat_min=36.0&lat_max=38.0
|
||||||
|
&lon_min=124.0&lon_max=126.0
|
||||||
|
&time=2026-04-03T09:00Z`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{/* 응답 예시 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">응답 (JSON)</div>
|
||||||
|
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-emerald-400/80 overflow-x-auto">
|
||||||
|
{`{
|
||||||
|
"model_version": "v2.3.1",
|
||||||
|
"predicted_at": "2026-04-03T09:00:00Z",
|
||||||
|
"grids": [
|
||||||
|
{
|
||||||
|
"grid_id": "G-37120-12463",
|
||||||
|
"center": { "lat": 37.120, "lon": 124.630 },
|
||||||
|
"risk_score": 87.5,
|
||||||
|
"illegal_prob": 0.92,
|
||||||
|
"dark_vessel_count": 3,
|
||||||
|
"top_threat": "EEZ_VIOLATION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"grid_id": "G-36800-12422",
|
||||||
|
"center": { "lat": 36.800, "lon": 124.220 },
|
||||||
|
"risk_score": 45.2,
|
||||||
|
"illegal_prob": 0.38,
|
||||||
|
"dark_vessel_count": 0,
|
||||||
|
"top_threat": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1842,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 100
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 연계 서비스 매핑 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<ExternalLink className="w-4 h-4 text-purple-400" />
|
||||||
|
후속 서비스 연계 매핑
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ sfr: 'SFR-06', name: '위험도 지도 서비스', apis: ['grid/heatmap', 'zone', 'time'], color: '#ef4444',
|
||||||
|
desc: '격자 히트맵 → 위험도 레이어 표출, 해역별 집계 → 관할해역 위험 현황, 시간별 추이 → 변화 애니메이션' },
|
||||||
|
{ sfr: 'SFR-09', name: '불법어선 탐지 서비스', apis: ['zone/{id}/vessels', 'vessel/{mmsi}', 'alarms'], color: '#f97316',
|
||||||
|
desc: '수역별 의심 선박 목록 → 타겟 리스트, 선박별 위험도 이력 → 상세 분석, 알람 → 실시간 경보' },
|
||||||
|
{ sfr: 'SFR-05', name: 'MDA 상황판', apis: ['grid', 'zone', 'status'], color: '#3b82f6',
|
||||||
|
desc: '격자+해역 위험도 → 종합 상황 오버레이, 엔진 상태 → 시스템 모니터링 패널' },
|
||||||
|
{ sfr: 'SFR-10', name: 'AI 순찰경로 최적화', apis: ['grid/heatmap', 'time/forecast'], color: '#10b981',
|
||||||
|
desc: '위험도 히트맵 → 순찰 우선순위 지역, 예측 → 사전 배치 경로 생성' },
|
||||||
|
{ sfr: 'SFR-11', name: '보고서·증거 관리', apis: ['alarms', 'vessel/{mmsi}'], color: '#8b5cf6',
|
||||||
|
desc: '알람 이력 → 자동 보고서 첨부, 선박 위험도 → 증거 패키지' },
|
||||||
|
].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>
|
||||||
|
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{s.apis.map((a) => (
|
||||||
|
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 사용 통계 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">API 호출 통계 (금일)</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '총 호출', value: '142,856', color: 'text-heading' },
|
||||||
|
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
|
||||||
|
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
|
||||||
|
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
|
||||||
|
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
|
||||||
|
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
|
||||||
|
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
|
||||||
|
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
|
||||||
|
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>
|
||||||
|
<div className="text-[9px] text-hint">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
565
src/features/ai-operations/MLOpsPage.tsx
Normal file
565
src/features/ai-operations/MLOpsPage.tsx
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
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 {
|
||||||
|
Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield,
|
||||||
|
FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square,
|
||||||
|
Rocket, Zap, FlaskConical, Search, ChevronRight, CheckCircle, XCircle,
|
||||||
|
AlertTriangle, Eye, Terminal, MessageSquare, Send, Bot, User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-18: 기계학습 운영 기능 (MLOps)
|
||||||
|
* SFR-19: 대규모 언어모델(LLM) 운영 기능 (LLMOps)
|
||||||
|
*
|
||||||
|
* WING AI 플랫폼 운영 대시보드 반영:
|
||||||
|
* ① 운영 대시보드 ② Experiment Studio ③ Model Registry
|
||||||
|
* ④ Deploy Center ⑤ API Playground ⑥ LLMOps (학습/HPS/로그/워커/LLM테스트)
|
||||||
|
* ⑦ 플랫폼 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'dashboard' | 'experiment' | 'registry' | 'deploy' | 'api' | 'llmops' | 'platform';
|
||||||
|
type LLMSubTab = 'train' | 'hps' | 'log' | 'worker' | 'llmtest';
|
||||||
|
|
||||||
|
// ─── 대시보드 KPI ──────────────────
|
||||||
|
const DASH_KPI = [
|
||||||
|
{ label: '고위험 (HIGH)', value: 8, sub: '↑3 전일비', color: '#ef4444', icon: AlertTriangle },
|
||||||
|
{ label: '중위험 (MED)', value: 16, sub: '↓2 전일비', color: '#f59e0b', icon: Eye },
|
||||||
|
{ label: '배포 중 모델', value: 3, sub: '최신 v2.1.0', color: '#10b981', icon: Rocket },
|
||||||
|
{ label: '진행 중 실험', value: 5, sub: '2건 완료 대기', color: '#3b82f6', icon: FlaskConical },
|
||||||
|
{ label: '등록 모델', value: 12, sub: '2건 승인 대기', color: '#8b5cf6', icon: GitBranch },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 실험 데이터 ──────────────────
|
||||||
|
const EXPERIMENTS = [
|
||||||
|
{ id: 'EXP-042', name: 'LSTM 서해A1 야간', template: 'AIS 위험도 예측', status: 'running', progress: 67, epoch: '34/50', f1: 0.891, time: '2h 14m' },
|
||||||
|
{ id: 'EXP-041', name: 'GNN 환적탐지 v3', template: '불법환적 네트워크', status: 'running', progress: 45, epoch: '23/50', f1: 0.823, time: '1h 50m' },
|
||||||
|
{ id: 'EXP-040', name: 'Transformer 궤적', template: '궤적 이상탐지', status: 'done', progress: 100, epoch: '50/50', f1: 0.912, time: '4h 30m' },
|
||||||
|
{ id: 'EXP-039', name: 'RF 어구분류 v2', template: '어구 자동분류', status: 'done', progress: 100, epoch: '100/100', f1: 0.876, time: '1h 12m' },
|
||||||
|
{ id: 'EXP-038', name: 'CNN 위성영상', template: '선박 탐지(SAR)', status: 'fail', progress: 23, epoch: '12/50', f1: 0, time: '0h 45m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATES = [
|
||||||
|
{ name: 'AIS 위험도 예측', icon: AlertTriangle, desc: 'LSTM+CNN 시계열 위험도' },
|
||||||
|
{ name: '궤적 이상탐지', icon: Activity, desc: 'Transformer 기반 경로 예측' },
|
||||||
|
{ name: '불법환적 네트워크', icon: Layers, desc: 'GNN 관계 분석' },
|
||||||
|
{ name: '어구 자동분류', icon: Search, desc: 'RF/XGBoost 앙상블' },
|
||||||
|
{ name: '선박 탐지(SAR)', icon: Globe, desc: 'CNN 위성영상 분석' },
|
||||||
|
{ name: 'Dark Vessel', icon: Eye, desc: 'SAR+RF 융합 탐지' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 모델 레지스트리 ──────────────────
|
||||||
|
const MODELS = [
|
||||||
|
{ name: '불법조업 위험도 예측', ver: 'v2.1.0', status: 'DEPLOYED', accuracy: 93.2, f1: 92.3, recall: 91.5, precision: 93.1, falseAlarm: 7.8, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pass', 'pass'] },
|
||||||
|
{ name: '경비함정 경로추천', ver: 'v1.5.2', status: 'DEPLOYED', accuracy: 89.7, f1: 88.4, recall: 87.2, precision: 89.6, falseAlarm: 10.3, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pass', 'pass'] },
|
||||||
|
{ name: '불법어선 어망탐지', ver: 'v1.2.0', status: 'DEPLOYED', accuracy: 87.5, f1: 86.1, recall: 85.0, precision: 87.2, falseAlarm: 12.5, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'run', 'pend'] },
|
||||||
|
{ name: 'Transformer 궤적', ver: 'v0.9.0', status: 'APPROVED', accuracy: 91.2, f1: 90.5, recall: 89.8, precision: 91.2, falseAlarm: 8.8, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'pass', 'pass', 'pend', 'pend'] },
|
||||||
|
{ name: 'GNN 환적탐지 v3', ver: 'v0.3.0', status: 'TESTING', accuracy: 82.3, f1: 80.1, recall: 78.5, precision: 81.8, falseAlarm: 17.7, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pass', 'run', 'pend', 'pend', 'pend'] },
|
||||||
|
{ name: 'CNN 위성영상', ver: 'v0.1.0', status: 'DRAFT', accuracy: 0, f1: 0, recall: 0, precision: 0, falseAlarm: 0, gates: ['데이터검증', '성능검증', '편향검사', 'A/B테스트', '보안감사'], gateStatus: ['pend', 'pend', 'pend', 'pend', 'pend'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 배포 센터 ──────────────────
|
||||||
|
const DEPLOYS = [
|
||||||
|
{ model: '불법조업 위험도', ver: 'v2.1.0', endpoint: '/v1/infer/risk', traffic: 80, latency: '23ms', falseAlarm: '7.8%', rps: 142, status: '정상', date: '04-01' },
|
||||||
|
{ model: '불법조업 위험도', ver: 'v2.0.3', endpoint: '/v1/infer/risk', traffic: 20, latency: '25ms', falseAlarm: '9.9%', rps: 36, status: '카나리', date: '03-28' },
|
||||||
|
{ model: '경비함정 경로추천', ver: 'v1.5.2', endpoint: '/v1/infer/patrol', traffic: 100, latency: '45ms', falseAlarm: '10.3%', rps: 28, status: '정상', date: '03-15' },
|
||||||
|
{ model: '불법어선 어망탐지', ver: 'v1.2.0', endpoint: '/v1/infer/gear', traffic: 100, latency: '31ms', falseAlarm: '12.5%', rps: 15, status: '정상', date: '03-01' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── LLMOps 데이터 ──────────────────
|
||||||
|
const LLM_MODELS = [
|
||||||
|
{ name: 'Llama-3-8B', icon: Brain, sub: 'Meta 오픈소스' },
|
||||||
|
{ name: 'Qwen-2-7B', icon: Brain, sub: 'Alibaba' },
|
||||||
|
{ name: 'SOLAR-10.7B', icon: Brain, sub: 'Upstage' },
|
||||||
|
{ name: 'Gemma-2-9B', icon: Brain, sub: 'Google' },
|
||||||
|
{ name: 'Phi-3-mini', icon: Brain, sub: 'Microsoft 3.8B' },
|
||||||
|
{ name: 'Custom Upload', icon: GitBranch, sub: '직접 업로드' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRAIN_JOBS = [
|
||||||
|
{ id: 'TRN-018', model: 'Llama-3-8B', status: 'running', progress: 72, elapsed: '3h 28m' },
|
||||||
|
{ id: 'TRN-017', model: 'Qwen-2-7B', status: 'done', progress: 100, elapsed: '5h 12m' },
|
||||||
|
{ id: 'TRN-016', model: 'SOLAR-10.7B', status: 'done', progress: 100, elapsed: '8h 45m' },
|
||||||
|
{ id: 'TRN-015', model: 'Llama-3-8B', status: 'fail', progress: 34, elapsed: '1h 20m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HPS_TRIALS = [
|
||||||
|
{ trial: 1, lr: '3.2e-4', batch: 64, dropout: 0.2, hidden: 256, f1: 0.891, best: false },
|
||||||
|
{ trial: 2, lr: '1.0e-3', batch: 32, dropout: 0.3, hidden: 128, f1: 0.867, best: false },
|
||||||
|
{ trial: 3, lr: '5.5e-4', batch: 64, dropout: 0.1, hidden: 256, f1: 0.912, best: true },
|
||||||
|
{ trial: 4, lr: '2.1e-4', batch: 128, dropout: 0.2, hidden: 512, f1: 0.903, best: false },
|
||||||
|
{ trial: 5, lr: '8.0e-4', batch: 32, dropout: 0.4, hidden: 128, f1: 0.845, best: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKERS = [
|
||||||
|
{ name: 'infer-risk-prod-01', model: '위험도 v2.1.0', gpu: 'Blackwell x2', status: 'ok', rps: 142, latency: '23ms', vram: '78%' },
|
||||||
|
{ name: 'infer-risk-canary-01', model: '위험도 v2.0.3', gpu: 'Blackwell x1', status: 'ok', rps: 36, latency: '25ms', vram: '45%' },
|
||||||
|
{ name: 'infer-patrol-01', model: '경로추천 v1.5.2', gpu: 'Blackwell x1', status: 'ok', rps: 28, latency: '45ms', vram: '52%' },
|
||||||
|
{ name: 'llm-serving-01', model: '해경LLM v1.0', gpu: 'H200 x2', status: 'ok', rps: 12, latency: '820ms', vram: '85%' },
|
||||||
|
{ name: 'llm-serving-02', model: '법령QA v1.0', gpu: 'Blackwell x1', status: 'warn', rps: 8, latency: '1.2s', vram: '92%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ──────────────────
|
||||||
|
|
||||||
|
export function MLOpsPage() {
|
||||||
|
const { t } = useTranslation('ai');
|
||||||
|
const [tab, setTab] = useState<Tab>('dashboard');
|
||||||
|
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
|
||||||
|
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')}</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'dashboard' as Tab, icon: BarChart3, label: '대시보드' },
|
||||||
|
{ key: 'experiment' as Tab, icon: FlaskConical, label: 'Experiment Studio' },
|
||||||
|
{ key: 'registry' as Tab, icon: GitBranch, label: 'Model Registry' },
|
||||||
|
{ key: 'deploy' as Tab, icon: Rocket, label: 'Deploy Center' },
|
||||||
|
{ key: 'api' as Tab, icon: Zap, label: 'API Playground' },
|
||||||
|
{ 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)}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ① 대시보드 ── */}
|
||||||
|
{tab === 'dashboard' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{DASH_KPI.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card" style={{ borderLeftColor: k.color, borderLeftWidth: 3 }}>
|
||||||
|
<k.icon className="w-5 h-5" style={{ color: k.color }} />
|
||||||
|
<div><div className="text-xl font-bold" style={{ color: k.color }}>{k.value}</div><div className="text-[9px] text-hint">{k.label}</div><div className="text-[8px] text-hint">{k.sub}</div></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② Experiment Studio ── */}
|
||||||
|
{tab === 'experiment' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">실험 템플릿 선택</div>
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{TEMPLATES.map((t, i) => (
|
||||||
|
<div key={t.name} onClick={() => setSelectedTmpl(i)}
|
||||||
|
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
|
||||||
|
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" />
|
||||||
|
<div className="text-[10px] font-bold text-heading">{t.name}</div>
|
||||||
|
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||||
|
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ Model Registry ── */}
|
||||||
|
{tab === 'registry' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{MODELS.map(m => (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/* 성능 지표 */}
|
||||||
|
{m.accuracy > 0 && (
|
||||||
|
<div className="grid grid-cols-5 gap-1 mb-3">
|
||||||
|
{[['Acc', m.accuracy], ['F1', m.f1], ['Recall', m.recall], ['Prec', m.precision], ['FPR', m.falseAlarm]].map(([l, v]) => (
|
||||||
|
<div key={l as string} className="bg-background rounded px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[11px] font-bold text-heading">{v as number}%</div>
|
||||||
|
<div className="text-[7px] text-hint">{l as string}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 게이트 */}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ Deploy Center ── */}
|
||||||
|
{tab === 'deploy' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-0">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead><tr className="border-b border-border text-hint">
|
||||||
|
{['모델명', '버전', 'Endpoint', '트래픽%', '지연p95', '오탐율', 'RPS', '상태', '배포일'].map(h => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left font-medium">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{DEPLOYS.map((d, i) => (
|
||||||
|
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||||||
|
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
|
||||||
|
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
|
||||||
|
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
|
||||||
|
<td className="px-3 py-2 text-label">{d.latency}</td>
|
||||||
|
<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 text-hint">{d.date}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<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>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-2">승인 대기 → 배포 가능</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ API Playground ── */}
|
||||||
|
{tab === 'api' && (
|
||||||
|
<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={`{
|
||||||
|
"mmsi": "412345678",
|
||||||
|
"lat": 37.12,
|
||||||
|
"lon": 124.63,
|
||||||
|
"speed": 3.2,
|
||||||
|
"course": 225,
|
||||||
|
"timestamp": "2026-04-03T09:00:00Z",
|
||||||
|
"model": "fishing_illegal_risk",
|
||||||
|
"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>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||||||
|
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
|
||||||
|
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
|
||||||
|
<span className="text-muted-foreground">상태 <span className="text-green-400 font-bold">200 OK</span></span>
|
||||||
|
<span className="text-muted-foreground">지연 <span className="text-green-400 font-bold">23ms</span></span>
|
||||||
|
</div>
|
||||||
|
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
|
||||||
|
"risk_score": 87.5,
|
||||||
|
"risk_level": "HIGH",
|
||||||
|
"illegal_prob": 0.92,
|
||||||
|
"codes": [
|
||||||
|
{"code": "EEZ_VIOLATION", "weight": 0.35, "desc": "배타적경제수역 침범"},
|
||||||
|
{"code": "DARK_VESSEL", "weight": 0.28, "desc": "AIS 신호 비정상"},
|
||||||
|
{"code": "MMSI_SPOOF", "weight": 0.18, "desc": "MMSI 변조 이력"}
|
||||||
|
],
|
||||||
|
"model": "fishing_illegal_risk",
|
||||||
|
"version": "v2.1.0",
|
||||||
|
"inference_time_ms": 23,
|
||||||
|
"trace_id": "trc-a4f8c2e1"
|
||||||
|
}`}</pre>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑥ LLMOps ── */}
|
||||||
|
{tab === 'llmops' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-0 border-b border-border mb-2">
|
||||||
|
{([
|
||||||
|
{ key: 'train' as LLMSubTab, label: '학습 생성' },
|
||||||
|
{ key: 'hps' as LLMSubTab, label: '하이퍼파라미터 검색' },
|
||||||
|
{ key: 'log' as LLMSubTab, label: '학습 로그' },
|
||||||
|
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
|
||||||
|
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
||||||
|
]).map(t => (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 학습 생성 */}
|
||||||
|
{llmSub === 'train' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">Built-in 모델 선택</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{LLM_MODELS.map((m, i) => (
|
||||||
|
<div key={m.name} onClick={() => setSelectedLLM(i)}
|
||||||
|
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
||||||
|
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" />
|
||||||
|
<div className="text-[10px] font-bold text-heading">{m.name}</div>
|
||||||
|
<div className="text-[8px] text-hint">{m.sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">학습 파라미터</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
||||||
|
{[['데이터셋', 'AIS_서해A1_30D (128,450행)'], ['GPU', '2 × Blackwell'], ['Epochs', '50'], ['Batch', '64'], ['Learning Rate', '0.001'], ['Early Stop', '5 에포크']].map(([k, v]) => (
|
||||||
|
<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>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">학습 작업 현황</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{TRAIN_JOBS.map(j => (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 하이퍼파라미터 검색 */}
|
||||||
|
{llmSub === 'hps' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">검색 설정</div>
|
||||||
|
<div className="space-y-2 text-[10px]">
|
||||||
|
{[['검색 방법', 'Bayesian Optimization'], ['Target Metric', 'val_f1'], ['최대 시도', '20']].map(([k, v]) => (
|
||||||
|
<div key={k}><span className="text-[9px] text-hint block mb-1">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-[9px] font-bold text-hint mb-2">파라미터 범위</div>
|
||||||
|
<div className="space-y-1 text-[9px]">
|
||||||
|
{[['learning_rate', '1e-4 ~ 1e-2'], ['batch_size', '16 ~ 128'], ['dropout', '0.1 ~ 0.5'], ['hidden_dim', '64 ~ 512']].map(([k, v]) => (
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
|
||||||
|
<tbody>{HPS_TRIALS.map(t => (
|
||||||
|
<tr key={t.trial} className={`border-b border-border ${t.best ? 'bg-green-500/5' : ''}`}>
|
||||||
|
<td className="py-2 px-2 text-heading font-bold">#{t.trial}</td>
|
||||||
|
<td className="py-2 px-2 text-muted-foreground font-mono">{t.lr}</td>
|
||||||
|
<td className="py-2 px-2 text-muted-foreground">{t.batch}</td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 학습 로그 */}
|
||||||
|
{llmSub === 'log' && (
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">학습 로그 (TRN-018 Llama-3-8B)</div>
|
||||||
|
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] text-cyan-300 font-mono h-64 overflow-auto leading-relaxed">{`[09:20:01] Loading model: Llama-3-8B (8B params)
|
||||||
|
[09:20:15] Dataset: AIS_서해A1_30D — 128,450 rows loaded
|
||||||
|
[09:20:16] Train/Val/Test split: 80% / 10% / 10%
|
||||||
|
[09:20:18] GPU: 2 × NVIDIA Blackwell (48GB VRAM each)
|
||||||
|
[09:20:19] Training started — Epochs: 50, Batch: 64, LR: 0.001
|
||||||
|
[09:20:19] ──────────────────────────────────────
|
||||||
|
[09:21:42] Epoch 1/50 | loss: 2.341 | val_loss: 2.218 | val_f1: 0.312 | 83s
|
||||||
|
[09:23:05] Epoch 2/50 | loss: 1.876 | val_loss: 1.745 | val_f1: 0.456 | 83s
|
||||||
|
[09:24:28] Epoch 3/50 | loss: 1.523 | val_loss: 1.402 | val_f1: 0.578 | 83s
|
||||||
|
...
|
||||||
|
[12:15:33] Epoch 35/50 | loss: 0.087 | val_loss: 0.092 | val_f1: 0.891 | 83s
|
||||||
|
[12:16:56] Epoch 36/50 | loss: 0.084 | val_loss: 0.089 | val_f1: 0.894 | 83s ★ Best
|
||||||
|
[12:18:19] ⏳ Training in progress... (72% complete)`}</pre>
|
||||||
|
</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 배포 워커 */}
|
||||||
|
{llmSub === 'worker' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{WORKERS.map(w => (
|
||||||
|
<Card key={w.name} className="bg-surface-raised border-border"><CardContent className="p-3 flex items-center gap-4">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${w.status === 'ok' ? 'bg-green-500 shadow-[0_0_6px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_6px_#eab308]'}`} />
|
||||||
|
<div className="w-44"><div className="text-[11px] font-bold text-heading">{w.name}</div><div className="text-[9px] text-hint">{w.model}</div></div>
|
||||||
|
<div className="flex gap-3 text-[9px]">
|
||||||
|
{[['GPU', w.gpu], ['RPS', String(w.rps)], ['Latency', w.latency], ['VRAM', w.vram]].map(([k, v]) => (
|
||||||
|
<span key={k} className="px-2 py-1 bg-surface-overlay border border-border rounded text-muted-foreground"><span className="text-hint">{k}</span> <span className="text-heading font-bold">{v}</span></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LLM 테스트 */}
|
||||||
|
{llmSub === 'llmtest' && (
|
||||||
|
<div className="flex gap-3" style={{ height: 'calc(100vh - 280px)' }}>
|
||||||
|
<Card className="w-52 shrink-0 bg-surface-raised border-border"><CardContent className="p-3">
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground mb-2">모델 선택</div>
|
||||||
|
{['해경 LLM v1.0', '법령 QA v1.0'].map(m => (
|
||||||
|
<div key={m} className="px-2 py-1.5 rounded text-[10px] text-label hover:bg-surface-overlay cursor-pointer">{m}</div>
|
||||||
|
))}
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground mt-3 mb-2">프롬프트 프리셋</div>
|
||||||
|
{['불법조업 판단', '법령 해석', '단속 보고서'].map(p => (
|
||||||
|
<div key={p} className="px-2 py-1.5 rounded text-[10px] text-muted-foreground hover:bg-surface-overlay cursor-pointer">{p}</div>
|
||||||
|
))}
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card className="flex-1 bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||||||
|
<div className="flex-1 space-y-3 overflow-auto mb-3">
|
||||||
|
<div className="flex justify-end"><div className="bg-blue-600/15 border border-blue-500/20 rounded-xl rounded-br-sm px-4 py-2.5 max-w-[70%] text-[11px] text-foreground">서해 NLL 인근에서 AIS 신호가 소실된 중국어선의 불법조업 판별 기준은?</div></div>
|
||||||
|
<div className="flex"><div className="bg-surface-overlay border border-border rounded-xl rounded-bl-sm px-4 py-2.5 max-w-[70%] text-[11px] text-foreground leading-relaxed">
|
||||||
|
<p className="mb-2">AIS 신호 소실 중국어선의 불법조업 판별은 다음 기준으로 수행됩니다:</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">1. **AIS 소실 시간**: 6시간 이상 미수신 시 Dark Vessel로 분류</p>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑦ 플랫폼 관리 ── */}
|
||||||
|
{tab === 'platform' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">GPU 리소스 현황</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[{ name: 'Blackwell #1', usage: 78, mem: '38/48GB', temp: '62°C' }, { name: 'Blackwell #2', usage: 52, mem: '25/48GB', temp: '55°C' }, { name: 'H200 #1', usage: 85, mem: '68/80GB', temp: '71°C' }, { name: 'H200 #2', usage: 45, mem: '36/80GB', temp: '48°C' }].map(g => (
|
||||||
|
<div key={g.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-[10px] text-heading font-medium w-24">{g.name}</span>
|
||||||
|
<div className="flex-1 h-2 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${g.usage > 80 ? 'bg-red-500' : g.usage > 60 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${g.usage}%` }} /></div>
|
||||||
|
<span className="text-[10px] text-heading font-bold w-8">{g.usage}%</span>
|
||||||
|
<span className="text-[9px] text-hint">{g.mem}</span>
|
||||||
|
<span className="text-[9px] text-hint">{g.temp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">서비스 상태</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[{ name: 'API Gateway', status: 'ok', rps: 221 }, { name: 'Model Serving', status: 'ok', rps: 186 }, { name: 'Feature Store', status: 'ok', rps: 45 }, { name: 'Vector DB (Milvus)', status: 'ok', rps: 32 }, { name: 'Kafka Cluster', status: 'warn', rps: 1250 }, { name: 'PostgreSQL', status: 'ok', rps: 890 }].map(s => (
|
||||||
|
<div key={s.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${s.status === 'ok' ? 'bg-green-500 shadow-[0_0_4px_#22c55e]' : 'bg-yellow-500 shadow-[0_0_4px_#eab308]'}`} />
|
||||||
|
<span className="text-[10px] text-heading font-medium flex-1">{s.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{s.rps} req/s</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">보안 및 접근제어</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[['인증 방식', 'API Key + JWT 토큰'], ['접근제어', 'RBAC (역할기반)'], ['민감정보', '마스킹 적용'], ['Rate Limiting', '100 req/min'], ['감사 로그', 'User→LLM→MCP 전체 추적'], ['Kill Switch', '활성 (긴급 차단 가능)']].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">데이터 파이프라인</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
{[['Feature Store', 'v3.2 (20개 피처) · 2.4TB'], ['학습 데이터', '1,456,200건 (04-03 갱신)'], ['벡터 DB', '1.2M 문서 · 3.6M 벡터'], ['CI/CD', '마지막 빌드 성공 (09:15)'], ['드리프트 점수', '0.012 (정상)'], ['재학습 트리거', '비활성']].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between px-2 py-1.5 bg-surface-overlay rounded"><span className="text-hint">{k}</span><span className="text-label">{v}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/features/ai-operations/index.ts
Normal file
3
src/features/ai-operations/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { AIModelManagement } from './AIModelManagement';
|
||||||
|
export { MLOpsPage } from './MLOpsPage';
|
||||||
|
export { AIAssistant } from './AIAssistant';
|
||||||
331
src/features/auth/LoginPage.tsx
Normal file
331
src/features/auth/LoginPage.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
|
||||||
|
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-01: 시스템 로그인 및 권한 관리
|
||||||
|
* - 해양경찰 SSO·공무원증·GPKI 등 기존 인증체계 로그인 연동
|
||||||
|
* - 역할 기반 권한 관리(RBAC)
|
||||||
|
* - 비밀번호 정책, 계정 잠금 정책
|
||||||
|
* - 감사 로그 기록
|
||||||
|
* - 5회 연속 실패 시 계정 잠금(30분)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AuthMethod = 'password' | 'gpki' | 'sso';
|
||||||
|
|
||||||
|
// SFR-01: 시뮬레이션 계정 (역할별)
|
||||||
|
const DEMO_ACCOUNTS: Record<string, { pw: string; name: string; rank: string; org: string; role: UserRole }> = {
|
||||||
|
admin: { pw: 'admin1234!', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: 'ADMIN' },
|
||||||
|
operator: { pw: 'oper12345!', name: '이상호', rank: '경위', org: '서해지방해경청', role: 'OPERATOR' },
|
||||||
|
analyst: { pw: 'anal12345!', name: '정해진', rank: '주무관', org: '남해지방해경청', role: 'ANALYST' },
|
||||||
|
field: { pw: 'field1234!', name: '박민수', rank: '경사', org: '5001함 삼봉', role: 'FIELD' },
|
||||||
|
viewer: { pw: 'view12345!', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: 'VIEWER' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30분
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, login } = useAuth();
|
||||||
|
const [authMethod, setAuthMethod] = useState<AuthMethod>('password');
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [failCount, setFailCount] = useState(0);
|
||||||
|
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// user 상태가 확정된 후 대시보드로 이동
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate('/dashboard', { replace: true });
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
const doLogin = (method: AuthMethod, account?: typeof DEMO_ACCOUNTS[string]) => {
|
||||||
|
const u = account || DEMO_ACCOUNTS['operator'];
|
||||||
|
login({
|
||||||
|
id: userId || u.role,
|
||||||
|
name: u.name,
|
||||||
|
rank: u.rank,
|
||||||
|
org: u.org,
|
||||||
|
role: u.role,
|
||||||
|
authMethod: method,
|
||||||
|
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// SFR-01: 계정 잠금 확인
|
||||||
|
if (lockedUntil && Date.now() < lockedUntil) {
|
||||||
|
const remainMin = Math.ceil((lockedUntil - Date.now()) / 60000);
|
||||||
|
setError(t('error.locked', { minutes: remainMin }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMethod === 'password') {
|
||||||
|
if (!userId.trim()) { setError(t('error.emptyId')); return; }
|
||||||
|
if (!password.trim()) { setError(t('error.emptyPassword')); return; }
|
||||||
|
if (password.length < 9) { setError(t('error.invalidPassword')); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// SFR-01: ID/PW 인증 시 계정 검증
|
||||||
|
const account = DEMO_ACCOUNTS[userId.toLowerCase()];
|
||||||
|
if (authMethod === 'password' && (!account || account.pw !== password)) {
|
||||||
|
const newCount = failCount + 1;
|
||||||
|
setFailCount(newCount);
|
||||||
|
if (newCount >= MAX_LOGIN_ATTEMPTS) {
|
||||||
|
setLockedUntil(Date.now() + LOCKOUT_DURATION);
|
||||||
|
setError(t('error.maxFailed', { max: MAX_LOGIN_ATTEMPTS }));
|
||||||
|
} else {
|
||||||
|
setError(t('error.wrongCredentials', { count: newCount, max: MAX_LOGIN_ATTEMPTS }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFailCount(0);
|
||||||
|
setLockedUntil(null);
|
||||||
|
doLogin(authMethod, account);
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_ROLE_LABELS: Record<UserRole, string> = {
|
||||||
|
ADMIN: t('demo.admin'),
|
||||||
|
OPERATOR: t('demo.operator'),
|
||||||
|
ANALYST: t('demo.analyst'),
|
||||||
|
FIELD: t('demo.field'),
|
||||||
|
VIEWER: t('demo.viewer'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const authMethods: { key: AuthMethod; icon: React.ElementType; label: string; desc: string }[] = [
|
||||||
|
{ key: 'password', icon: Lock, label: t('authMethod.password'), desc: t('authMethod.passwordDesc') },
|
||||||
|
{ key: 'gpki', icon: Fingerprint, label: t('authMethod.gpki'), desc: t('authMethod.gpkiDesc') },
|
||||||
|
{ key: 'sso', icon: KeyRound, label: t('authMethod.sso'), desc: t('authMethod.ssoDesc') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center relative overflow-hidden">
|
||||||
|
{/* 배경 그리드 */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.03]" style={{
|
||||||
|
backgroundImage: 'linear-gradient(#3b82f6 1px, transparent 1px), linear-gradient(90deg, #3b82f6 1px, transparent 1px)',
|
||||||
|
backgroundSize: '60px 60px',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 로그인 카드 */}
|
||||||
|
<div className="relative z-10 w-full max-w-md mx-4">
|
||||||
|
{/* 로고 영역 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
|
||||||
|
<Shield className="w-8 h-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
|
||||||
|
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border border-border rounded-2xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||||
|
{/* 인증 방식 선택 탭 */}
|
||||||
|
<div className="flex border-b border-border">
|
||||||
|
{authMethods.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => { setAuthMethod(m.key); setError(''); }}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
|
||||||
|
authMethod === m.key
|
||||||
|
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
|
||||||
|
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<m.icon className="w-4 h-4" />
|
||||||
|
<span className="text-[9px] font-medium whitespace-nowrap">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* ID/PW 로그인 */}
|
||||||
|
{authMethod === 'password' && (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block whitespace-nowrap">{t('form.userId')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder={t('form.userIdPlaceholder')}
|
||||||
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-10 pr-4 py-2.5 text-sm text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted-foreground font-medium mb-1 block whitespace-nowrap">{t('form.password')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
|
||||||
|
<input
|
||||||
|
type={showPw ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t('form.passwordPlaceholder')}
|
||||||
|
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-10 pr-10 py-2.5 text-sm text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPw(!showPw)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
|
||||||
|
>
|
||||||
|
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 정책 안내 */}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 space-y-1">
|
||||||
|
<div className="text-[9px] text-hint font-medium">{t('passwordPolicy.title')}</div>
|
||||||
|
<ul className="text-[9px] text-hint space-y-0.5">
|
||||||
|
<li>{t('passwordPolicy.minLength')}</li>
|
||||||
|
<li>{t('passwordPolicy.changeInterval')}</li>
|
||||||
|
<li>{t('passwordPolicy.lockout')}</li>
|
||||||
|
<li>{t('passwordPolicy.reuse')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
{t('button.authenticating')}
|
||||||
|
</>
|
||||||
|
) : t('button.login')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 데모 퀵로그인 */}
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<div className="text-[9px] text-hint text-center mb-2">{t('demo.title')}</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
|
{Object.entries(DEMO_ACCOUNTS).map(([key, acct]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => doLogin('password', acct)}
|
||||||
|
className="py-1.5 rounded-md text-[9px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:bg-switch-background/60 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{DEMO_ROLE_LABELS[acct.role]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPKI 인증 */}
|
||||||
|
{authMethod === 'gpki' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
|
||||||
|
<p className="text-[10px] text-hint mt-1">{t('gpki.desc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-4 border border-dashed border-slate-700/50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[10px] text-hint mb-2">{t('gpki.certStatus')}</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||||
|
<span className="text-[11px] text-yellow-400">{t('gpki.certWaiting')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('gpki', DEMO_ACCOUNTS['operator']); }, 1500); }}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('gpki.authenticating')}</>
|
||||||
|
) : t('gpki.start')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-hint text-center">
|
||||||
|
{t('gpki.internalOnly')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SSO 연동 */}
|
||||||
|
{authMethod === 'sso' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
|
||||||
|
<p className="text-[10px] text-hint mt-1">{t('sso.desc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-green-900/20 border border-green-700/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-green-400">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
{t('sso.tokenDetected')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setLoading(true); setTimeout(() => { setLoading(false); doLogin('sso', DEMO_ACCOUNTS['operator']); }, 800); }}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('sso.authenticating')}</>
|
||||||
|
) : t('sso.autoLogin')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-hint text-center">
|
||||||
|
{t('sso.sessionNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 정보 */}
|
||||||
|
<div className="px-6 py-3 bg-background border-t border-border">
|
||||||
|
<div className="flex items-center justify-between text-[8px] text-hint">
|
||||||
|
<span>{t('footer.version')}</span>
|
||||||
|
<span>{t('footer.org')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접근 권한 안내 */}
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<p className="text-[9px] text-hint">
|
||||||
|
{t('accessNotice')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/features/auth/index.ts
Normal file
1
src/features/auth/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { LoginPage } from './LoginPage';
|
||||||
630
src/features/dashboard/Dashboard.tsx
Normal file
630
src/features/dashboard/Dashboard.tsx
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { HeatPoint, MarkerData } from '@lib/map';
|
||||||
|
import {
|
||||||
|
AlertTriangle, Ship, Anchor, Eye, Navigation,
|
||||||
|
Crosshair, Shield, Waves, Wind, Thermometer, MapPin,
|
||||||
|
ChevronRight, Activity, Zap, Target, ArrowUpRight, ArrowDownRight,
|
||||||
|
Radar, TrendingUp, BarChart3
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { AreaChart, PieChart } from '@lib/charts';
|
||||||
|
import { useKpiStore } from '@stores/kpiStore';
|
||||||
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
import { usePatrolStore } from '@stores/patrolStore';
|
||||||
|
import { useVesselStore } from '@stores/vesselStore';
|
||||||
|
|
||||||
|
// ─── 작전 경보 등급 ─────────────────────
|
||||||
|
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 매핑 (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: Crosshair, color: '#06b6d4' },
|
||||||
|
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const AREA_RISK_DATA = [
|
||||||
|
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
|
||||||
|
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
|
||||||
|
{ area: '서해 5도', vessels: 11, risk: 88, trend: 'stable' },
|
||||||
|
{ area: 'EEZ 서부', vessels: 6, risk: 72, trend: 'down' },
|
||||||
|
{ area: '동해 중부', vessels: 4, risk: 58, trend: 'up' },
|
||||||
|
{ area: 'EEZ 남부', vessels: 3, risk: 45, trend: 'down' },
|
||||||
|
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOURLY_DETECTION = [
|
||||||
|
{ hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 },
|
||||||
|
{ hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 },
|
||||||
|
{ hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VESSEL_TYPE_DATA = [
|
||||||
|
{ name: 'EEZ 침범', value: 18, color: '#ef4444' },
|
||||||
|
{ name: '다크베셀', value: 12, color: '#f97316' },
|
||||||
|
{ name: '불법환적', value: 8, color: '#a855f7' },
|
||||||
|
{ name: 'MMSI변조', value: 5, color: '#eab308' },
|
||||||
|
{ name: '고속도주', value: 4, color: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEATHER_DATA = {
|
||||||
|
wind: { speed: 12, direction: 'NW', gust: 18 },
|
||||||
|
wave: { height: 1.8, period: 6 },
|
||||||
|
temp: { air: 8, water: 11 },
|
||||||
|
visibility: 12,
|
||||||
|
seaState: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 서브 컴포넌트 ─────────────────────
|
||||||
|
|
||||||
|
function PulsingDot({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${color} opacity-60`} />
|
||||||
|
<span className={`relative inline-flex rounded-full h-2.5 w-2.5 ${color}`} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`${barW} h-1.5 bg-switch-background/60 rounded-full overflow-hidden`}>
|
||||||
|
<div className={`h-full ${color} rounded-full transition-all duration-700`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-bold tabular-nums ${textColor}`}>{pct.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiCardProps { label: string; value: number; prev: number; icon: LucideIcon; color: string; desc: string }
|
||||||
|
function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps) {
|
||||||
|
const diff = value - prev;
|
||||||
|
const isUp = diff > 0;
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border bg-surface-raised p-4 hover:bg-surface-overlay transition-colors group">
|
||||||
|
<div className="absolute top-0 right-0 w-20 h-20 rounded-full opacity-[0.04] group-hover:opacity-[0.08] transition-opacity" style={{ background: color, filter: 'blur(20px)' }} />
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
|
||||||
|
<Icon className="w-4 h-4" style={{ color }} />
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||||
|
{Math.abs(diff)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-heading tabular-nums mb-0.5">{value}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium">{label}</div>
|
||||||
|
<div className="text-[9px] text-hint mt-0.5">{desc}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
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">
|
||||||
|
<PulsingDot color={c.dot} />
|
||||||
|
<span className="text-[9px] text-hint tabular-nums">{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>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FuelGauge({ percent }: { percent: number }) {
|
||||||
|
const color = percent > 60 ? 'bg-green-500' : percent > 30 ? 'bg-yellow-500' : 'bg-red-500';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-12 h-1 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${color} rounded-full`} style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-hint tabular-nums">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 해역 위협 미니맵 (Leaflet) ───────────────────
|
||||||
|
|
||||||
|
const THREAT_AREAS = [
|
||||||
|
{ name: '서해 NLL', lat: 37.80, lng: 124.90, risk: 95, vessels: 8 },
|
||||||
|
{ name: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 91, vessels: 14 },
|
||||||
|
{ name: '서해 5도', lat: 37.50, lng: 124.60, risk: 88, vessels: 11 },
|
||||||
|
{ name: '서해 중부', lat: 36.50, lng: 124.80, risk: 65, vessels: 6 },
|
||||||
|
{ name: 'EEZ 서부', lat: 36.00, lng: 123.80, risk: 72, vessels: 6 },
|
||||||
|
{ name: '동해 중부', lat: 37.00, lng: 130.50, risk: 58, vessels: 4 },
|
||||||
|
{ name: 'EEZ 남부', lat: 34.50, lng: 127.50, risk: 45, vessels: 3 },
|
||||||
|
{ name: '남해 서부', lat: 34.20, lng: 126.00, risk: 22, vessels: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 히트맵용 위협 포인트 생성
|
||||||
|
function generateThreatHeat(): [number, number, number][] {
|
||||||
|
const pts: [number, number, number][] = [];
|
||||||
|
THREAT_AREAS.forEach((a) => {
|
||||||
|
const count = Math.round(a.vessels * 2.5);
|
||||||
|
const intensity = a.risk / 100;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
pts.push([
|
||||||
|
a.lat + (Math.random() - 0.5) * 0.8,
|
||||||
|
a.lng + (Math.random() - 0.5) * 1.0,
|
||||||
|
intensity * (0.6 + Math.random() * 0.4),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
const THREAT_HEAT = generateThreatHeat();
|
||||||
|
|
||||||
|
// 모듈 스코프: static mock data → 마커도 module-level
|
||||||
|
const THREAT_MARKERS: MarkerData[] = THREAT_AREAS.map((a) => ({
|
||||||
|
lat: a.lat,
|
||||||
|
lng: a.lng,
|
||||||
|
color: a.risk > 85 ? '#ef4444' : a.risk > 60 ? '#f97316' : a.risk > 40 ? '#eab308' : '#3b82f6',
|
||||||
|
radius: Math.max(a.vessels, 5) * 150,
|
||||||
|
label: a.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function SeaAreaMap() {
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
|
||||||
|
createMarkerLayer('threat-markers', THREAT_MARKERS),
|
||||||
|
], []);
|
||||||
|
|
||||||
|
useMapLayers(mapRef, buildLayers, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full min-h-[300px]">
|
||||||
|
<BaseMap
|
||||||
|
ref={mapRef}
|
||||||
|
center={[35.8, 127.0]}
|
||||||
|
zoom={7}
|
||||||
|
height={300}
|
||||||
|
className="rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
|
||||||
|
<div className="text-[8px] text-muted-foreground font-bold mb-1">위협 등급</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[7px] text-blue-400">낮음</span>
|
||||||
|
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
|
||||||
|
<span className="text-[7px] text-red-400">높음</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* LIVE 인디케이터 */}
|
||||||
|
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<Radar className="w-3 h-3 text-blue-500" />
|
||||||
|
<span className="text-[9px] text-blue-400 font-medium">실시간 해역 위협도</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 실시간 시계 (격리된 리렌더) ─────────────────
|
||||||
|
/** 실시간 시계 — React setState/render 완전 우회, DOM 직접 조작 */
|
||||||
|
function LiveClock() {
|
||||||
|
const spanRef = useRef<HTMLSpanElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const fmt: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
};
|
||||||
|
const update = () => {
|
||||||
|
if (spanRef.current) {
|
||||||
|
spanRef.current.textContent = new Date().toLocaleString('ko-KR', fmt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const timer = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
return <span ref={spanRef} className="tabular-nums" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 해역 미니맵 (memo로 불필요 리렌더 방지) ─────
|
||||||
|
const MemoSeaAreaMap = memo(SeaAreaMap);
|
||||||
|
|
||||||
|
// ─── 메인 대시보드 ─────────────────────
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const { t } = useTranslation('dashboard');
|
||||||
|
const [defconLevel] = useState(2);
|
||||||
|
|
||||||
|
const kpiStore = useKpiStore();
|
||||||
|
const eventStore = useEventStore();
|
||||||
|
const vesselStore = useVesselStore();
|
||||||
|
const patrolStore = usePatrolStore();
|
||||||
|
|
||||||
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||||
|
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
||||||
|
useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
|
||||||
|
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
||||||
|
|
||||||
|
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => ({
|
||||||
|
label: m.label,
|
||||||
|
value: m.value,
|
||||||
|
prev: m.prev ?? 0,
|
||||||
|
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
||||||
|
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
||||||
|
desc: m.description ?? '',
|
||||||
|
})), [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,
|
||||||
|
level: e.level,
|
||||||
|
title: e.title,
|
||||||
|
detail: e.detail,
|
||||||
|
vessel: e.vesselName ?? '-',
|
||||||
|
area: e.area ?? '-',
|
||||||
|
})), [eventStore.events]);
|
||||||
|
|
||||||
|
const TOP_RISK_VESSELS = useMemo(() => vesselStore.suspects.slice(0, 8).map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
risk: v.risk / 100,
|
||||||
|
type: v.pattern ?? v.type,
|
||||||
|
flag: v.flag === 'CN' ? '중국' : v.flag === 'KR' ? '한국' : '미상',
|
||||||
|
tonnage: v.tonnage ?? null,
|
||||||
|
speed: v.speed != null ? `${v.speed}kt` : '-',
|
||||||
|
heading: v.heading != null ? `${v.heading}°` : '-',
|
||||||
|
lastAIS: v.lastSignal ?? '-',
|
||||||
|
location: `N${v.lat.toFixed(2)} E${v.lng.toFixed(2)}`,
|
||||||
|
pattern: v.status,
|
||||||
|
})), [vesselStore.suspects]);
|
||||||
|
|
||||||
|
const PATROL_SHIPS = useMemo(() => patrolStore.ships.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
class: s.shipClass,
|
||||||
|
status: s.status,
|
||||||
|
target: s.target ?? '-',
|
||||||
|
area: s.zone ?? '-',
|
||||||
|
speed: `${s.speed}kt`,
|
||||||
|
fuel: s.fuel,
|
||||||
|
})), [patrolStore.ships]);
|
||||||
|
|
||||||
|
const defconColors = ['', 'bg-red-600', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-blue-500'];
|
||||||
|
const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ── 상단 헤더 바 ── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-blue-500" />
|
||||||
|
{t('dashboard.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">{t('dashboard.desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${defconColors[defconLevel]} px-3 py-1 rounded-md flex items-center gap-2`}>
|
||||||
|
<span className="text-xs font-bold text-heading">{defconLabels[defconLevel]}</span>
|
||||||
|
<span className="text-[9px] text-heading/70">경계강화</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<PulsingDot color="bg-green-500" />
|
||||||
|
<span>AI 감시체계 정상</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Activity className="w-3.5 h-3.5 text-blue-500" />
|
||||||
|
<LiveClock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI 카드 6개 ── */}
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
{KPI_DATA.map((kpi) => (
|
||||||
|
<KpiCard key={kpi.label} {...kpi} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 메인 3단 레이아웃 ── */}
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
|
||||||
|
{/* ── 좌측: 해역 미니맵 + 해역별 위험도 ── */}
|
||||||
|
<div className="col-span-4 space-y-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<MemoSeaAreaMap />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<BarChart3 className="w-3.5 h-3.5 text-orange-500" />
|
||||||
|
해역별 위험 선박 분포
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{AREA_RISK_DATA.map((area) => (
|
||||||
|
<div key={area.area} className="flex items-center gap-3">
|
||||||
|
<span className="text-[10px] text-muted-foreground w-16 shrink-0 truncate">{area.area}</span>
|
||||||
|
<div className="flex-1 h-4 bg-secondary rounded-sm overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm transition-all duration-700"
|
||||||
|
style={{
|
||||||
|
width: `${area.risk}%`,
|
||||||
|
background: area.risk > 85 ? 'linear-gradient(90deg, #ef4444, #dc2626)'
|
||||||
|
: area.risk > 60 ? 'linear-gradient(90deg, #f97316, #ea580c)'
|
||||||
|
: area.risk > 40 ? 'linear-gradient(90deg, #eab308, #ca8a04)'
|
||||||
|
: 'linear-gradient(90deg, #3b82f6, #2563eb)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[8px] text-heading/80 font-bold tabular-nums">{area.vessels}척</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
|
||||||
|
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
|
||||||
|
}}>{area.risk}</span>
|
||||||
|
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
|
||||||
|
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
|
||||||
|
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]">—</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 중앙: 이벤트 타임라인 ── */}
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Card className="bg-surface-raised border-border h-full">
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||||
|
실시간 상황 타임라인
|
||||||
|
</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">
|
||||||
|
긴급 {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">
|
||||||
|
경고 {TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-2">
|
||||||
|
<div className="space-y-1.5 max-h-[520px] overflow-y-auto pr-1">
|
||||||
|
{TIMELINE_EVENTS.map((event, i) => (
|
||||||
|
<TimelineItem key={i} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 우측: 함정 배치 + 기상 + 유형별 차트 ── */}
|
||||||
|
<div className="col-span-4 space-y-3">
|
||||||
|
{/* 함정 배치 현황 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<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">
|
||||||
|
{PATROL_SHIPS.length}척 운용 중
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 pb-3 pt-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{PATROL_SHIPS.map((ship) => (
|
||||||
|
<div key={ship.name} className="flex items-center gap-2 p-2 rounded-lg bg-surface-overlay hover:bg-secondary/70 transition-colors">
|
||||||
|
<div className="w-14 shrink-0">
|
||||||
|
<div className="text-[11px] text-heading font-bold">{ship.name}</div>
|
||||||
|
<div className="text-[8px] text-hint">{ship.class}</div>
|
||||||
|
</div>
|
||||||
|
<PatrolStatusBadge status={ship.status} />
|
||||||
|
<div className="flex-1 min-w-0 text-[9px] text-muted-foreground truncate">
|
||||||
|
{ship.target !== '-' ? ship.target : ship.area}
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-hint tabular-nums shrink-0">{ship.speed}</span>
|
||||||
|
<FuelGauge percent={ship.fuel} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 기상/해상 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<Waves className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
해상 기상 현황
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3 pt-2">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
|
<Wind className="w-3.5 h-3.5 text-muted-foreground mx-auto mb-1" />
|
||||||
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wind.speed}m/s</div>
|
||||||
|
<div className="text-[8px] text-hint">{WEATHER_DATA.wind.direction} 풍</div>
|
||||||
|
<div className="text-[8px] text-hint">돌풍 {WEATHER_DATA.wind.gust}m/s</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
|
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
|
||||||
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
|
||||||
|
<div className="text-[8px] text-hint">파고</div>
|
||||||
|
<div className="text-[8px] text-hint">주기 {WEATHER_DATA.wave.period}s</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
|
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
|
||||||
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
|
||||||
|
<div className="text-[8px] text-hint">기온</div>
|
||||||
|
<div className="text-[8px] text-hint">수온 {WEATHER_DATA.temp.water}°C</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-lg bg-surface-overlay">
|
||||||
|
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
|
||||||
|
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
|
||||||
|
<div className="text-[8px] text-hint">시정</div>
|
||||||
|
<div className="text-[8px] text-hint">해상{WEATHER_DATA.seaState}급</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 유형별 탐지 비율 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<Target className="w-3.5 h-3.5 text-purple-500" />
|
||||||
|
위반 유형별 탐지 현황
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3 pt-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<PieChart data={VESSEL_TYPE_DATA} height={100} innerRadius={25} outerRadius={42} />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
{VESSEL_TYPE_DATA.map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: item.color }} />
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-1">{item.name}</span>
|
||||||
|
<span className="text-[10px] text-heading font-bold tabular-nums">{item.value}건</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 시간대별 탐지 추이 차트 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-blue-500" />
|
||||||
|
금일 시간대별 탐지 추이
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-3 text-[9px]">
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-1 rounded bg-blue-500" />전체 탐지</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-1 rounded bg-red-500" />EEZ 침범</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3 pt-1">
|
||||||
|
<AreaChart data={HOURLY_DETECTION} xKey="hour" height={140} series={[{ key: 'count', name: '전체 탐지', color: '#3b82f6' }, { key: 'eez', name: 'EEZ 침범', color: '#ef4444' }]} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 고위험 선박 추적 테이블 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 pt-3 pb-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 pt-2">
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||||
|
<span>#</span>
|
||||||
|
<span>선박명 / ID</span>
|
||||||
|
<span>위반 유형</span>
|
||||||
|
<span>국적/지역</span>
|
||||||
|
<span>속력/침로</span>
|
||||||
|
<span>AIS 상태</span>
|
||||||
|
<span>행동패턴</span>
|
||||||
|
<span>위치</span>
|
||||||
|
<span>위험도</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{TOP_RISK_VESSELS.map((vessel, index) => (
|
||||||
|
<div
|
||||||
|
key={vessel.id}
|
||||||
|
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] 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>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.8 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||||
|
<span className="text-heading text-[11px] font-bold">{vessel.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] text-hint ml-5">{vessel.id}</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-[8px] px-1.5 py-0 border ${
|
||||||
|
vessel.type.includes('침범') || vessel.type.includes('선단') ? 'bg-red-500/15 text-red-400 border-red-500/30'
|
||||||
|
: vessel.type.includes('다크') ? 'bg-orange-500/15 text-orange-400 border-orange-500/30'
|
||||||
|
: vessel.type.includes('환적') ? 'bg-purple-500/15 text-purple-400 border-purple-500/30'
|
||||||
|
: vessel.type.includes('MMSI') ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
|
||||||
|
: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
||||||
|
}`}>{vessel.type}</Badge>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{vessel.flag}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-heading">{vessel.speed}</div>
|
||||||
|
<div className="text-[8px] text-hint">{vessel.heading}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] ${vessel.lastAIS === '소실' ? 'text-red-400 font-bold' : 'text-muted-foreground'}`}>{vessel.lastAIS}</span>
|
||||||
|
<Badge className={`text-[8px] px-1 py-0 border-0 ${
|
||||||
|
vessel.pattern === '도주' ? 'bg-red-500/20 text-red-400'
|
||||||
|
: vessel.pattern === '조업 중' ? 'bg-orange-500/20 text-orange-400'
|
||||||
|
: vessel.pattern === '정박/접현' ? 'bg-purple-500/20 text-purple-400'
|
||||||
|
: vessel.pattern === '고속이동' ? 'bg-cyan-500/20 text-cyan-400'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}>{vessel.pattern}</Badge>
|
||||||
|
<span className="text-[9px] text-hint tabular-nums">{vessel.location}</span>
|
||||||
|
<RiskBar value={vessel.risk} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/features/dashboard/index.ts
Normal file
1
src/features/dashboard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Dashboard } from './Dashboard';
|
||||||
706
src/features/detection/ChinaFishing.tsx
Normal file
706
src/features/detection/ChinaFishing.tsx
Normal file
@ -0,0 +1,706 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||||
|
Eye, AlertTriangle, ShieldCheck, Radio, Anchor, RotateCcw,
|
||||||
|
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { GearIdentification } from './GearIdentification';
|
||||||
|
import { BaseChart, PieChart as EcPieChart } from '@lib/charts';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { useTransferStore } from '@stores/transferStore';
|
||||||
|
|
||||||
|
// ─── 센서 카운터 (시안 2행) ─────────────
|
||||||
|
const COUNTERS_ROW1 = [
|
||||||
|
{ label: '통합', count: 1350, color: '#6b7280', icon: '🔵' },
|
||||||
|
{ label: 'AIS', count: 2212, color: '#3b82f6', icon: '🟢' },
|
||||||
|
{ label: 'E-Nav', count: 745, color: '#8b5cf6', icon: '🔷' },
|
||||||
|
{ label: '여객선', count: 1, color: '#10b981', icon: '🟡' },
|
||||||
|
];
|
||||||
|
const COUNTERS_ROW2 = [
|
||||||
|
{ label: '중국어선', count: 20, color: '#f97316', icon: '🟠' },
|
||||||
|
{ label: 'V-PASS', count: 465, color: '#06b6d4', icon: '🟢' },
|
||||||
|
{ label: '함정', count: 2, color: '#6b7280', icon: '🔵' },
|
||||||
|
{ label: '위험물', count: 0, color: '#6b7280', icon: '⚪' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 특이운항 선박 리스트 ────────────────
|
||||||
|
type VesselStatus = '의심' | '양호' | '경고';
|
||||||
|
interface VesselItem {
|
||||||
|
id: string;
|
||||||
|
mmsi: string;
|
||||||
|
callSign: string;
|
||||||
|
channel: string;
|
||||||
|
source: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
country: string;
|
||||||
|
status: VesselStatus;
|
||||||
|
riskPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VESSEL_LIST: VesselItem[] = [
|
||||||
|
{ id: '1', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||||
|
{ id: '2', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '양호', riskPct: 70 },
|
||||||
|
{ id: '3', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 24 },
|
||||||
|
{ id: '4', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '경고', riskPct: 84 },
|
||||||
|
{ id: '5', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||||
|
{ id: '6', mmsi: '440162980', callSign: '122@', channel: '', source: 'AIS', name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)', status: '의심', riskPct: 44 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 월별 불법조업 통계 ──────────────────
|
||||||
|
const MONTHLY_DATA = [
|
||||||
|
{ month: 'JAN', 범장망: 45, 쌍끌이: 30, 외끌이: 20, 트롤: 10 },
|
||||||
|
{ month: 'FEB', 범장망: 55, 쌍끌이: 35, 외끌이: 25, 트롤: 15 },
|
||||||
|
{ month: 'MAR', 범장망: 70, 쌍끌이: 45, 외끌이: 30, 트롤: 20 },
|
||||||
|
{ month: 'APR', 범장망: 85, 쌍끌이: 50, 외끌이: 35, 트롤: 25 },
|
||||||
|
{ month: 'MAY', 범장망: 95, 쌍끌이: 55, 외끌이: 40, 트롤: 30 },
|
||||||
|
{ month: 'JUN', 범장망: 80, 쌍끌이: 45, 외끌이: 35, 트롤: 22 },
|
||||||
|
{ month: 'JUL', 범장망: 60, 쌍끌이: 35, 외끌이: 25, 트롤: 18 },
|
||||||
|
{ month: 'AUG', 범장망: 50, 쌍끌이: 30, 외끌이: 20, 트롤: 12 },
|
||||||
|
{ month: 'SEP', 범장망: 65, 쌍끌이: 40, 외끌이: 28, 트롤: 20 },
|
||||||
|
{ month: 'OCT', 범장망: 75, 쌍끌이: 48, 외끌이: 32, 트롤: 22 },
|
||||||
|
{ month: 'NOV', 범장망: 90, 쌍끌이: 52, 외끌이: 38, 트롤: 28 },
|
||||||
|
{ month: 'DEC', 범장망: 100, 쌍끌이: 60, 외끌이: 42, 트롤: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── VTS 연계 항목 ─────────────────────
|
||||||
|
const VTS_ITEMS = [
|
||||||
|
{ name: '경인연안', active: true },
|
||||||
|
{ name: '평택항', active: false },
|
||||||
|
{ name: '경인항', active: false },
|
||||||
|
{ name: '대산항', active: true },
|
||||||
|
{ name: '인천항', active: true },
|
||||||
|
{ name: '태안연안', active: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 환적 탐지 데이터: useTransferStore().transfers 사용 ───
|
||||||
|
|
||||||
|
// ─── 서브 컴포넌트 ─────────────────────
|
||||||
|
|
||||||
|
function SemiGauge({ value, label, color }: { value: number; label: string; color: string }) {
|
||||||
|
const angle = (value / 100) * 180;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative w-28 h-16 overflow-hidden">
|
||||||
|
<svg viewBox="0 0 120 65" className="w-full h-full">
|
||||||
|
{/* 배경 호 */}
|
||||||
|
<path d="M 10 60 A 50 50 0 0 1 110 60" fill="none" stroke="#1e293b" strokeWidth="10" strokeLinecap="round" />
|
||||||
|
{/* 값 호 */}
|
||||||
|
<path
|
||||||
|
d="M 10 60 A 50 50 0 0 1 110 60"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="10"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${(angle / 180) * 157} 157`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 text-center">
|
||||||
|
<span className="text-xl font-extrabold text-heading">{value.toFixed(2)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-0.5">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircleGauge({ value, label }: { value: number; label: string }) {
|
||||||
|
const circumference = 2 * Math.PI * 42;
|
||||||
|
const offset = circumference - (value / 100) * circumference;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative w-24 h-24">
|
||||||
|
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="50" cy="50" r="42" fill="none" stroke="#1e293b" strokeWidth="8" />
|
||||||
|
<circle
|
||||||
|
cx="50" cy="50" r="42" fill="none"
|
||||||
|
stroke="#10b981" strokeWidth="8" strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-2xl font-extrabold text-heading">{value}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 circumference = 2 * Math.PI * 18;
|
||||||
|
const offset = circumference - (riskPct / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-12 h-12 shrink-0">
|
||||||
|
<svg viewBox="0 0 44 44" className="w-full h-full -rotate-90">
|
||||||
|
<circle cx="22" cy="22" r="18" fill="none" stroke="#1e293b" strokeWidth="3" />
|
||||||
|
<circle cx="22" cy="22" r="18" fill="none" stroke={c.ring} strokeWidth="3" strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference} strokeDashoffset={offset} />
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className={`text-[8px] font-bold ${c.text}`}>{status}</span>
|
||||||
|
<span className="text-[9px] font-bold text-heading">{riskPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 페이지 ──────────────────────
|
||||||
|
|
||||||
|
// ─── 환적 탐지 뷰 ─────────────────────
|
||||||
|
|
||||||
|
function TransferView() {
|
||||||
|
const { transfers, load } = useTransferStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const TRANSFER_DATA = useMemo(
|
||||||
|
() =>
|
||||||
|
transfers.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
time: t.time,
|
||||||
|
a: t.vesselA,
|
||||||
|
b: t.vesselB,
|
||||||
|
dist: t.distance,
|
||||||
|
dur: t.duration,
|
||||||
|
spd: t.speed,
|
||||||
|
score: t.score,
|
||||||
|
loc: t.location,
|
||||||
|
})),
|
||||||
|
[transfers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-heading">환적·접촉 탐지</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탐지 조건 */}
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-2">탐지 조건</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
||||||
|
<div className="text-[9px] text-hint mb-1">거리</div>
|
||||||
|
<div className="text-lg font-bold text-heading">≤ 100m</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
||||||
|
<div className="text-[9px] text-hint mb-1">시간</div>
|
||||||
|
<div className="text-lg font-bold text-heading">≥ 30분</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
|
||||||
|
<div className="text-[9px] text-hint mb-1">속도</div>
|
||||||
|
<div className="text-lg font-bold text-heading">≤ 3kn</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 환적 이벤트 */}
|
||||||
|
{TRANSFER_DATA.map((tr) => (
|
||||||
|
<Card key={tr.id} className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-heading font-bold">{tr.id}</h3>
|
||||||
|
<div className="text-[10px] text-hint">{tr.time}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-red-500/20 text-red-400 border border-red-500/30 text-[10px]">환적 의심도: {tr.score}%</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{/* 선박 A & B */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 mb-1"><Ship className="w-3 h-3" />선박 A</div>
|
||||||
|
<div className="text-sm text-heading font-bold">{tr.a.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">MMSI: {tr.a.mmsi}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-teal-400 mb-1"><Ship className="w-3 h-3" />선박 B</div>
|
||||||
|
<div className="text-sm text-heading font-bold">{tr.b.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">MMSI: {tr.b.mmsi}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 */}
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-2">접촉 타임라인</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-[10px]">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-blue-400">접근 시작</span>
|
||||||
|
<span className="text-hint">거리: 500m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px]">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
<span className="text-yellow-400">근접 유지</span>
|
||||||
|
<span className="text-hint">거리: {tr.dist}m, 지속: {tr.dur}분</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[10px]">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
<span className="text-red-400">의심 행위 감지</span>
|
||||||
|
<span className="text-hint">평균 속도: {tr.spd}kn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 정보 */}
|
||||||
|
<div className="w-44 shrink-0 space-y-2">
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3 space-y-1.5 text-[10px]">
|
||||||
|
<div className="text-muted-foreground mb-1">접촉 정보</div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">최소 거리</span><span className="text-heading font-medium">{tr.dist}m</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">접촉 시간</span><span className="text-heading font-medium">{tr.dur}분</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">평균 속도</span><span className="text-heading font-medium">{tr.spd}kn</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-overlay rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground mb-1"><MapPin className="w-3 h-3" />위치</div>
|
||||||
|
<div className="text-heading text-sm font-medium">{tr.loc}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-950/30 border border-purple-900/30 rounded-lg p-3">
|
||||||
|
<div className="text-[10px] text-purple-400 mb-1.5">환적 의심도</div>
|
||||||
|
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden mb-1.5">
|
||||||
|
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" style={{ width: `${tr.score}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-heading">{tr.score}%</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-[11px] py-2 rounded-lg transition-colors">
|
||||||
|
상세 분석 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 페이지 ──────────────────────
|
||||||
|
|
||||||
|
export function ChinaFishing() {
|
||||||
|
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||||
|
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||||
|
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||||
|
|
||||||
|
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||||
|
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||||
|
|
||||||
|
const modeTabs = [
|
||||||
|
{ key: 'dashboard' as const, icon: Brain, label: 'AI 감시 대시보드' },
|
||||||
|
{ key: 'transfer' as const, icon: RefreshCw, label: '환적·접촉 탐지' },
|
||||||
|
{ key: 'gear' as const, icon: CrosshairIcon, label: '어구/어망 판별' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* ── 모드 탭 (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
|
||||||
|
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'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon className="w-3.5 h-3.5" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 환적 탐지 모드 */}
|
||||||
|
{mode === 'transfer' && <TransferView />}
|
||||||
|
|
||||||
|
{/* 어구/어망 판별 모드 */}
|
||||||
|
{mode === 'gear' && <GearIdentification />}
|
||||||
|
|
||||||
|
{/* AI 대시보드 모드 */}
|
||||||
|
{mode === 'dashboard' && <>
|
||||||
|
|
||||||
|
{/* ── 상단 바: 기준일 + 검색 ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] text-label">기준 : 2023-09-25 14:56</span>
|
||||||
|
</div>
|
||||||
|
<button className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</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
|
||||||
|
placeholder="해역 또는 해구 번호 검색"
|
||||||
|
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 상단 영역: 통항량 + 안전도 분석 + 관심영역 ── */}
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
|
||||||
|
{/* 해역별 통항량 */}
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-bold text-heading">해역별 통항량</span>
|
||||||
|
<div className="flex items-center gap-2 text-[10px]">
|
||||||
|
<span className="text-hint">해구번호</span>
|
||||||
|
<span className="text-heading font-bold font-mono">123-456</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||||
|
<span>해역 전체 통항량</span>
|
||||||
|
<span className="text-lg font-extrabold text-heading">12,454</span>
|
||||||
|
<span className="text-hint">(척)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카운터 Row 1 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-2">
|
||||||
|
{COUNTERS_ROW1.map((c) => (
|
||||||
|
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
|
||||||
|
<div className="text-[9px] text-hint mb-1">{c.label}</div>
|
||||||
|
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 카운터 Row 2 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{COUNTERS_ROW2.map((c) => (
|
||||||
|
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
|
||||||
|
<div className="text-[9px] text-hint mb-1">{c.label}</div>
|
||||||
|
<div className="text-lg font-extrabold font-mono" style={{ color: c.count > 0 ? '#e5e7eb' : '#4b5563' }}>
|
||||||
|
{c.count > 0 ? c.count.toLocaleString() : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 안전도 분석 */}
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<span className="text-sm font-bold text-heading">안전도 분석</span>
|
||||||
|
<div className="flex items-center justify-around mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
|
<span className="text-orange-400 font-medium">종합</span> 위험지수
|
||||||
|
</div>
|
||||||
|
<SemiGauge value={5.21} label="" color="#f97316" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-2 text-center">
|
||||||
|
종합 <span className="text-blue-400 font-medium">안전지수</span>
|
||||||
|
</div>
|
||||||
|
<SemiGauge value={5.21} label="" color="#3b82f6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관심영역 안전도 */}
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||||
|
<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">
|
||||||
|
<option>영역 A</option>
|
||||||
|
<option>영역 B</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-hint mb-3">설정한 관심 영역을 선택후 조회를 눌러주세요.</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<Eye className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span className="text-muted-foreground">특이운항</span>
|
||||||
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
<span className="text-muted-foreground">불법조업</span>
|
||||||
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<Radio className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span className="text-muted-foreground">비허가</span>
|
||||||
|
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CircleGauge value={90.2} label="" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 하단 영역: 선박 리스트 + 통계 ── */}
|
||||||
|
<div className="grid grid-cols-12 gap-3">
|
||||||
|
|
||||||
|
{/* 좌: 선박 리스트 (탭) */}
|
||||||
|
<div className="col-span-5">
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* 탭 헤더 */}
|
||||||
|
<div className="flex border-b border-slate-700/30">
|
||||||
|
{vesselTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setVesselTab(tab)}
|
||||||
|
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||||
|
vesselTab === tab
|
||||||
|
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||||
|
: 'text-hint hover:text-label'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선박 목록 */}
|
||||||
|
<div className="max-h-[420px] overflow-y-auto">
|
||||||
|
{VESSEL_LIST.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
|
||||||
|
>
|
||||||
|
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||||
|
<span>ID | <span className="text-label">{v.mmsi}</span></span>
|
||||||
|
<span>호출부호 | <span className="text-label">{v.callSign}</span></span>
|
||||||
|
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||||
|
<span>🇰🇷</span>
|
||||||
|
<span>{v.country}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 통계 + 하단 카드 3개 */}
|
||||||
|
<div className="col-span-7 space-y-3">
|
||||||
|
|
||||||
|
{/* 통계 차트 */}
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex border-b border-slate-700/30">
|
||||||
|
{statsTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setStatsTab(tab)}
|
||||||
|
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||||
|
statsTab === tab
|
||||||
|
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
||||||
|
: 'text-hint hover:text-label'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex gap-4">
|
||||||
|
{/* 바 차트 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<BaseChart height={220} option={{
|
||||||
|
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: MONTHLY_DATA.map(d => d.month) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{ name: '범장망', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.범장망), itemStyle: { color: '#22c55e' } },
|
||||||
|
{ name: '쌍끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.쌍끌이), itemStyle: { color: '#f97316' } },
|
||||||
|
{ name: '외끌이', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.외끌이), itemStyle: { color: '#60a5fa' } },
|
||||||
|
{ name: '트롤', type: 'bar', stack: 'a', data: MONTHLY_DATA.map(d => d.트롤), itemStyle: { color: '#6b7280', borderRadius: [2, 2, 0, 0] } },
|
||||||
|
],
|
||||||
|
} as EChartsOption} />
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-2">
|
||||||
|
{[
|
||||||
|
{ label: '범장망 선박', color: '#22c55e' },
|
||||||
|
{ label: '쌍끌이 선박', color: '#f97316' },
|
||||||
|
{ label: '외끌이 선박', color: '#60a5fa' },
|
||||||
|
{ label: '트롤 선박', color: '#6b7280' },
|
||||||
|
].map((l) => (
|
||||||
|
<span key={l.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: l.color }} />
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도넛 2개 */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 w-24">
|
||||||
|
<div className="relative w-[80px] h-[80px]">
|
||||||
|
<EcPieChart
|
||||||
|
data={[
|
||||||
|
{ name: 'active', value: 70, color: '#22c55e' },
|
||||||
|
{ name: 'rest', value: 30, color: '#1e293b' },
|
||||||
|
]}
|
||||||
|
height={80}
|
||||||
|
innerRadius={24}
|
||||||
|
outerRadius={34}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-sm font-extrabold text-heading">356</span>
|
||||||
|
<span className="text-[7px] text-hint">TOTAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-[80px] h-[80px]">
|
||||||
|
<EcPieChart
|
||||||
|
data={[
|
||||||
|
{ name: 'active', value: 60, color: '#22c55e' },
|
||||||
|
{ name: 'rest', value: 40, color: '#1e293b' },
|
||||||
|
]}
|
||||||
|
height={80}
|
||||||
|
innerRadius={24}
|
||||||
|
outerRadius={34}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-sm font-extrabold text-heading">356</span>
|
||||||
|
<span className="text-[7px] text-hint">TOTAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다운로드 버튼 */}
|
||||||
|
<div className="px-4 pb-3 flex justify-end">
|
||||||
|
<button className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 하단 카드 3개 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
|
||||||
|
{/* 최근 위성영상 분석 */}
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-[10px]">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-hint shrink-0">VIIRS</span>
|
||||||
|
<span className="text-label">| 2023-08-11 02:00:00</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-hint shrink-0">이미지</span>
|
||||||
|
<span className="text-label truncate">| BSG-117-20230194-orho.tif</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-hint shrink-0">CSV</span>
|
||||||
|
<span className="text-label truncate">| 2023.07.17_ship_dection.clustog.csv</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 기상 예보 */}
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-center">
|
||||||
|
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-muted-foreground">전남서부남해앞바다</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-extrabold text-heading">28.1</span>
|
||||||
|
<span className="text-sm text-muted-foreground">°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint">흐림 남~남서 🌊 0.5~0.5m</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* VTS연계 현황 */}
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{VTS_ITEMS.map((vts) => (
|
||||||
|
<div
|
||||||
|
key={vts.name}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
|
||||||
|
vts.active
|
||||||
|
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
|
||||||
|
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
|
||||||
|
{vts.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2">
|
||||||
|
<button className="text-hint hover:text-heading transition-colors">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="text-hint hover:text-heading transition-colors">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/features/detection/DarkVesselDetection.tsx
Normal file
150
src/features/detection/DarkVesselDetection.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Eye, EyeOff, AlertTriangle, Ship, Radar, Radio, Target, Shield, Tag } from 'lucide-react';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { MarkerData } from '@lib/map';
|
||||||
|
import { useVesselStore } from '@stores/vesselStore';
|
||||||
|
|
||||||
|
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||||
|
|
||||||
|
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; }
|
||||||
|
|
||||||
|
const FLAG_MAP: Record<string, string> = { CN: '중국', KR: '한국', UNKNOWN: '미상' };
|
||||||
|
|
||||||
|
const PATTERN_COLORS: Record<string, string> = {
|
||||||
|
'AIS 완전차단': '#ef4444',
|
||||||
|
'MMSI 3회 변경': '#f97316',
|
||||||
|
'급격 속력변화': '#eab308',
|
||||||
|
'신호 간헐송출': '#a855f7',
|
||||||
|
'비정기 신호': '#3b82f6',
|
||||||
|
'국적 위장 의심': '#ec4899',
|
||||||
|
};
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
'추적중': '#ef4444',
|
||||||
|
'감시중': '#eab308',
|
||||||
|
'확인중': '#3b82f6',
|
||||||
|
'정상': '#22c55e',
|
||||||
|
};
|
||||||
|
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 { suspects, loaded, load } = useVesselStore();
|
||||||
|
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||||
|
|
||||||
|
// Map VesselData to local Suspect shape
|
||||||
|
const DATA: Suspect[] = useMemo(
|
||||||
|
() =>
|
||||||
|
suspects.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
mmsi: v.mmsi,
|
||||||
|
name: v.name,
|
||||||
|
flag: FLAG_MAP[v.flag] ?? v.flag,
|
||||||
|
pattern: v.pattern ?? '-',
|
||||||
|
risk: v.risk,
|
||||||
|
lastAIS: v.lastSignal ?? '-',
|
||||||
|
status: v.status,
|
||||||
|
label: v.risk >= 90 ? (v.status === '추적중' ? '불법' : '-') : v.status === '정상' ? '정상' : '-',
|
||||||
|
lat: v.lat,
|
||||||
|
lng: v.lng,
|
||||||
|
})),
|
||||||
|
[suspects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
// 경보 반경 (고위험만)
|
||||||
|
createRadiusLayer(
|
||||||
|
'dv-radius',
|
||||||
|
DATA.filter(d => d.risk > 80).map(d => ({
|
||||||
|
lat: d.lat,
|
||||||
|
lng: d.lng,
|
||||||
|
radius: 10000,
|
||||||
|
color: PATTERN_COLORS[d.pattern] || '#ef4444',
|
||||||
|
})),
|
||||||
|
0.08,
|
||||||
|
),
|
||||||
|
// 탐지 선박 마커
|
||||||
|
createMarkerLayer(
|
||||||
|
'dv-markers',
|
||||||
|
DATA.map(d => ({
|
||||||
|
lat: d.lat,
|
||||||
|
lng: d.lng,
|
||||||
|
color: PATTERN_COLORS[d.pattern] || '#ef4444',
|
||||||
|
radius: d.risk > 80 ? 1200 : 800,
|
||||||
|
label: `${d.id} ${d.name}`,
|
||||||
|
} as MarkerData)),
|
||||||
|
),
|
||||||
|
], [DATA]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '의심 선박', v: DATA.filter(d => d.risk > 50).length, c: 'text-red-400', i: AlertTriangle },
|
||||||
|
{ l: 'Dark Vessel', v: DATA.filter(d => d.pattern.includes('차단')).length, c: 'text-orange-400', i: EyeOff },
|
||||||
|
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern.includes('MMSI')).length, c: 'text-yellow-400', i: Radio },
|
||||||
|
{ l: '라벨링 완료', v: DATA.filter(d => d.label !== '-').length + '/' + DATA.length, c: 'text-cyan-400', i: Tag },
|
||||||
|
].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">
|
||||||
|
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
|
||||||
|
|
||||||
|
{/* 탐지 위치 지도 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
|
||||||
|
{/* 범례 */}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] text-red-400 font-bold">{DATA.filter(d => d.risk > 80).length}척</span>
|
||||||
|
<span className="text-[9px] text-hint">고위험 Dark Vessel 탐지</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/features/detection/GearDetection.tsx
Normal file
123
src/features/detection/GearDetection.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Anchor, MapPin, AlertTriangle, CheckCircle, Clock, Ship, Filter } from 'lucide-react';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { MarkerData } from '@lib/map';
|
||||||
|
import { useGearStore } from '@stores/gearStore';
|
||||||
|
|
||||||
|
/* 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',
|
||||||
|
'안전': '#22c55e',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GEAR_ICONS: Record<string, string> = {
|
||||||
|
'저층트롤': '🔴',
|
||||||
|
'유자망': '🟠',
|
||||||
|
'유자망(대형)': '🔴',
|
||||||
|
'통발': '🟢',
|
||||||
|
'선망': '🟡',
|
||||||
|
'연승': '🔵',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { items, loaded, load } = useGearStore();
|
||||||
|
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||||
|
|
||||||
|
// GearRecord from the store matches the local Gear shape exactly
|
||||||
|
const DATA: Gear[] = items;
|
||||||
|
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
// 어구 설치 영역 (고위험만)
|
||||||
|
createRadiusLayer(
|
||||||
|
'gear-radius',
|
||||||
|
DATA.filter(g => g.risk === '고위험').map(g => ({
|
||||||
|
lat: g.lat,
|
||||||
|
lng: g.lng,
|
||||||
|
radius: 6000,
|
||||||
|
color: RISK_COLORS[g.risk] || '#64748b',
|
||||||
|
})),
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
// 어구 마커
|
||||||
|
createMarkerLayer(
|
||||||
|
'gear-markers',
|
||||||
|
DATA.map(g => ({
|
||||||
|
lat: g.lat,
|
||||||
|
lng: g.lng,
|
||||||
|
color: RISK_COLORS[g.risk] || '#64748b',
|
||||||
|
radius: g.risk === '고위험' ? 1200 : 800,
|
||||||
|
label: `${g.id} ${g.type}`,
|
||||||
|
} as MarkerData)),
|
||||||
|
),
|
||||||
|
], [DATA]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '전체 어구', v: DATA.length, c: 'text-heading' }, { l: '불법 의심', v: DATA.filter(d => d.status.includes('불법')).length, c: 'text-red-400' }, { l: '확인 중', v: DATA.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' }, { l: '정상', v: DATA.filter(d => d.status === '정상').length, c: 'text-green-400' }].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">
|
||||||
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="어구유형, 소유선박, 해역 검색..." searchKeys={['type', 'owner', 'zone']} exportFilename="어구탐지" />
|
||||||
|
|
||||||
|
{/* 어구 탐지 위치 지도 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} center={[36.5, 127.0]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
|
||||||
|
{/* 범례 */}
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-500" /><span className="text-[8px] text-muted-foreground">고위험 (불법 의심/확정)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-yellow-500" /><span className="text-[8px] text-muted-foreground">중위험 (확인 중)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground">안전 (정상)</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>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<Anchor className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}건</span>
|
||||||
|
<span className="text-[9px] text-hint">어구 탐지 위치</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1003
src/features/detection/GearIdentification.tsx
Normal file
1003
src/features/detection/GearIdentification.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
4
src/features/detection/index.ts
Normal file
4
src/features/detection/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { DarkVesselDetection } from './DarkVesselDetection';
|
||||||
|
export { GearDetection } from './GearDetection';
|
||||||
|
export { ChinaFishing } from './ChinaFishing';
|
||||||
|
export { GearIdentification } from './GearIdentification';
|
||||||
48
src/features/enforcement/EnforcementHistory.tsx
Normal file
48
src/features/enforcement/EnforcementHistory.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { FileText, Ship, MapPin, Calendar, Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||||
|
|
||||||
|
/* SFR-11: 단속·탐지 이력 관리 */
|
||||||
|
|
||||||
|
interface Record { id: string; date: string; zone: string; vessel: string; violation: string; action: string; aiMatch: string; result: string; [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 { records, load } = useEnforcementStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '총 단속', v: DATA.length, c: 'text-heading' }, { l: '처벌', v: DATA.filter(d => d.result.includes('처벌')).length, c: 'text-red-400' }, { l: 'AI 일치', v: DATA.filter(d => d.aiMatch === '일치').length, c: 'text-green-400' }, { l: '오탐', v: DATA.filter(d => d.result.includes('오탐')).length, c: 'text-yellow-400' }].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">
|
||||||
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박명, 해역, 위반내용 검색..." searchKeys={['vessel', 'zone', 'violation', 'result']} exportFilename="단속이력" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/features/enforcement/EventList.tsx
Normal file
211
src/features/enforcement/EventList.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { FileUpload } from '@shared/components/common/FileUpload';
|
||||||
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
|
import {
|
||||||
|
AlertTriangle, Ship, Eye, Anchor, Radar, Crosshair,
|
||||||
|
Filter, Upload, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
||||||
|
* DataTable(검색+정렬+페이징+엑셀내보내기+출력), FileUpload
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||||
|
|
||||||
|
interface EventRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
type: string;
|
||||||
|
vesselName: string;
|
||||||
|
mmsi: string;
|
||||||
|
area: string;
|
||||||
|
lat: string;
|
||||||
|
lng: string;
|
||||||
|
speed: string;
|
||||||
|
status: string;
|
||||||
|
assignee: string;
|
||||||
|
[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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── EventRecord is now loaded from useEventStore ───
|
||||||
|
|
||||||
|
const columns: DataColumn<EventRecord>[] = [
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
const color = s === '완료' || s === '확인 완료' || s === '경고 완료' ? 'bg-green-500/20 text-green-400'
|
||||||
|
: s.includes('추적') || s.includes('나포') ? 'bg-red-500/20 text-red-400'
|
||||||
|
: s.includes('감시') || s.includes('확인') ? 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
: 'bg-blue-500/20 text-blue-400';
|
||||||
|
return <Badge className={`border-0 text-[9px] ${color}`}>{s}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'assignee', label: '담당', width: '70px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EventList() {
|
||||||
|
const { t } = useTranslation('enforcement');
|
||||||
|
const { events: storeEvents, loaded, load } = useEventStore();
|
||||||
|
useEffect(() => { if (!loaded) load(); }, [loaded, load]);
|
||||||
|
|
||||||
|
// Map store EventRecord to local EventRecord shape (string lat/lng/speed)
|
||||||
|
const EVENTS: EventRecord[] = useMemo(
|
||||||
|
() =>
|
||||||
|
storeEvents.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
time: e.time,
|
||||||
|
level: e.level,
|
||||||
|
type: e.type,
|
||||||
|
vesselName: e.vesselName ?? '-',
|
||||||
|
mmsi: e.mmsi ?? '-',
|
||||||
|
area: e.area ?? '-',
|
||||||
|
lat: e.lat != null ? String(e.lat) : '-',
|
||||||
|
lng: e.lng != null ? String(e.lng) : '-',
|
||||||
|
speed: e.speed != null ? `${e.speed}kt` : '미상',
|
||||||
|
status: e.status ?? '-',
|
||||||
|
assignee: e.assignee ?? '-',
|
||||||
|
})),
|
||||||
|
[storeEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
const filtered = levelFilter
|
||||||
|
? EVENTS.filter((e) => e.level === levelFilter)
|
||||||
|
: EVENTS;
|
||||||
|
|
||||||
|
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)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* KPI 요약 */}
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '전체', count: EVENTS.length, icon: Radar, color: 'text-label', bg: 'bg-muted' },
|
||||||
|
{ label: 'CRITICAL', count: EVENTS.filter((e) => e.level === 'CRITICAL').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||||
|
{ label: 'HIGH', count: EVENTS.filter((e) => e.level === 'HIGH').length, icon: Eye, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
|
{ label: 'MEDIUM', count: EVENTS.filter((e) => e.level === 'MEDIUM').length, icon: Anchor, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
|
{ label: 'LOW', count: EVENTS.filter((e) => e.level === 'LOW').length, icon: Crosshair, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
].map((kpi) => (
|
||||||
|
<div
|
||||||
|
key={kpi.label}
|
||||||
|
onClick={() => setLevelFilter(kpi.label === '전체' ? '' : kpi.label)}
|
||||||
|
className={`flex items-center gap-2.5 p-3 rounded-xl border cursor-pointer transition-colors ${
|
||||||
|
(kpi.label === '전체' && !levelFilter) || kpi.label === levelFilter
|
||||||
|
? 'bg-card border-blue-500/30'
|
||||||
|
: 'bg-card border-border hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
|
<kpi.icon className={`w-4 h-4 ${kpi.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-lg font-bold ${kpi.color}`}>{kpi.count}</div>
|
||||||
|
<div className="text-[9px] text-hint">{kpi.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 업로드 영역 */}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<FileUpload
|
||||||
|
accept=".xlsx,.csv"
|
||||||
|
multiple
|
||||||
|
maxSizeMB={20}
|
||||||
|
onFilesSelected={(files) => console.log('Uploaded:', files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DataTable — 검색+정렬+페이징+엑셀+출력 */}
|
||||||
|
<DataTable
|
||||||
|
data={filtered}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={10}
|
||||||
|
searchPlaceholder="이벤트ID, 선박명, MMSI, 해역 검색..."
|
||||||
|
searchKeys={['id', 'vesselName', 'mmsi', 'area', 'type']}
|
||||||
|
exportFilename="이벤트목록"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/features/enforcement/index.ts
Normal file
2
src/features/enforcement/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { EnforcementHistory } from './EnforcementHistory';
|
||||||
|
export { EventList } from './EventList';
|
||||||
61
src/features/field-ops/AIAlert.tsx
Normal file
61
src/features/field-ops/AIAlert.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Bell, Send, CheckCircle, XCircle, Clock, MapPin, AlertTriangle, Ship } from 'lucide-react';
|
||||||
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
|
||||||
|
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
||||||
|
|
||||||
|
interface Alert { id: string; time: string; type: string; location: string; confidence: number; target: string; status: string; received: string; [key: string]: unknown; }
|
||||||
|
const cols: DataColumn<Alert>[] = [
|
||||||
|
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'time', label: '탐지 시각', width: '80px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'type', label: '탐지 유형', width: '80px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||||
|
{ key: 'location', label: '위치좌표', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'confidence', label: '신뢰도', width: '60px', align: 'center', sortable: true,
|
||||||
|
render: v => { const n = v as number; return <span className={`font-bold ${n > 90 ? 'text-red-400' : n > 80 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}%</span>; } },
|
||||||
|
{ key: 'target', label: '수신 대상', render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||||
|
{ key: 'status', label: '발송 상태', width: '80px', 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-red-500/20 text-red-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||||
|
{ key: 'received', label: '수신 시각', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AIAlert() {
|
||||||
|
const { t } = useTranslation('fieldOps');
|
||||||
|
const { alerts: storeAlerts, load } = useEventStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const DATA: Alert[] = useMemo(
|
||||||
|
() =>
|
||||||
|
storeAlerts.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
time: a.time,
|
||||||
|
type: a.type,
|
||||||
|
location: a.location,
|
||||||
|
confidence: a.confidence,
|
||||||
|
target: a.target,
|
||||||
|
status: a.status,
|
||||||
|
received: a.status === '수신확인' ? a.time.replace(/:\d{2}$/, (m) => `:${String(Number(m.slice(1)) + 3).padStart(2, '0')}`) : '-',
|
||||||
|
})),
|
||||||
|
[storeAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '총 발송', 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' }].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">
|
||||||
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="유형, 대상, 좌표 검색..." searchKeys={['type', 'target', 'location']} exportFilename="AI알림이력" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/features/field-ops/MobileService.tsx
Normal file
146
src/features/field-ops/MobileService.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
||||||
|
|
||||||
|
const PUSH_SETTINGS = [
|
||||||
|
{ name: 'EEZ 침범 경보', enabled: true }, { name: '다크베셀 탐지', enabled: true },
|
||||||
|
{ name: '불법환적 의심', enabled: true }, { name: '순찰경로 업데이트', enabled: false },
|
||||||
|
{ name: '기상 특보', enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모바일 지도에 표시할 마커
|
||||||
|
const MOBILE_MARKERS = [
|
||||||
|
{ lat: 37.20, lng: 124.63, name: '鲁荣渔56555', type: 'alert', color: '#ef4444' },
|
||||||
|
{ lat: 37.75, lng: 125.02, name: '미상선박-A', type: 'dark', color: '#f97316' },
|
||||||
|
{ lat: 36.80, lng: 124.37, name: '冀黄港渔05001', type: 'suspect', color: '#eab308' },
|
||||||
|
{ lat: 37.45, lng: 125.30, name: '3009함(아군)', type: 'patrol', color: '#a855f7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileService() {
|
||||||
|
const { t } = useTranslation('fieldOps');
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
const { alerts: storeAlerts, load } = useEventStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
createPolylineLayer('eez-simple', [
|
||||||
|
[38.5, 124.0], [37.0, 123.0], [36.0, 122.5], [35.0, 123.0],
|
||||||
|
], { color: '#ef4444', width: 1, opacity: 0.3, dashArray: [4, 4] }),
|
||||||
|
createMarkerLayer('mobile-markers', MOBILE_MARKERS.map(m => ({
|
||||||
|
lat: m.lat, lng: m.lng, color: m.color,
|
||||||
|
} as MarkerData)), '#3b82f6', 800),
|
||||||
|
], []);
|
||||||
|
useMapLayers(mapRef, buildLayers, []);
|
||||||
|
|
||||||
|
const ALERTS = useMemo(
|
||||||
|
() =>
|
||||||
|
storeAlerts.slice(0, 3).map((a) => ({
|
||||||
|
time: a.time.slice(0, 5),
|
||||||
|
title: a.type === 'EEZ 침범' ? `[긴급] ${a.type} 탐지` : a.type,
|
||||||
|
detail: `${a.location}`,
|
||||||
|
level: a.confidence >= 95 ? 'CRITICAL' : a.confidence >= 90 ? 'HIGH' : 'MEDIUM',
|
||||||
|
})),
|
||||||
|
[storeAlerts],
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* 모바일 프리뷰 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 flex justify-center">
|
||||||
|
<div className="w-[220px] h-[420px] bg-background border-2 border-slate-600 rounded-[24px] overflow-hidden relative">
|
||||||
|
{/* 상태바 */}
|
||||||
|
<div className="h-6 bg-secondary flex items-center justify-center"><span className="text-[8px] text-hint">해경 모바일 앱</span></div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{/* 긴급 경보 */}
|
||||||
|
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
|
||||||
|
<div className="text-[9px] text-red-400 font-bold">[긴급] EEZ 침범 탐지</div>
|
||||||
|
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
|
||||||
|
</div>
|
||||||
|
{/* 지도 영역 — MapLibre GL */}
|
||||||
|
<div className="rounded-lg overflow-hidden relative" style={{ height: 128 }}>
|
||||||
|
<BaseMap
|
||||||
|
ref={mapRef}
|
||||||
|
center={[37.2, 125.0]}
|
||||||
|
zoom={8}
|
||||||
|
height={128}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
|
{/* 미니 범례 */}
|
||||||
|
<div className="absolute bottom-1 right-1 z-[1000] bg-black/70 rounded px-1.5 py-0.5 flex items-center gap-1.5">
|
||||||
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-[6px] text-muted-foreground">침범</span></span>
|
||||||
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-[6px] text-muted-foreground">다크</span></span>
|
||||||
|
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-purple-500" /><span className="text-[6px] text-muted-foreground">아군</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* KPI 그리드 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{[['위험도', '87점'], ['의심선박', '3척'], ['추천경로', '2건'], ['경보', '5건']].map(([k, v]) => (
|
||||||
|
<div key={k} className="bg-surface-overlay rounded p-1.5 text-center">
|
||||||
|
<div className="text-[10px] text-heading font-bold">{v}</div>
|
||||||
|
<div className="text-[7px] text-hint">{k}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 최근 알림 */}
|
||||||
|
<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'}`} />
|
||||||
|
<span className="text-[8px] text-label truncate">{a.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 홈바 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-5 bg-secondary rounded-b-[22px]" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 기능 설명 + 푸시 설정 */}
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">모바일 주요 기능</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[{ icon: AlertTriangle, name: '예측 정보 수신', desc: '불법행위 위험도, 의심 선박·어구 정보' },
|
||||||
|
{ icon: Navigation, name: '경로 추천', desc: 'AI 순찰 경로 수신 및 네비게이션' },
|
||||||
|
{ icon: MapPin, name: '지도 조회', desc: '해상 위치 확인·해양환경정보 간단 조회' },
|
||||||
|
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
|
||||||
|
].map(f => (
|
||||||
|
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
|
||||||
|
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||||
|
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" />푸시 알림 설정</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PUSH_SETTINGS.map(p => (
|
||||||
|
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-[11px] text-label">{p.name}</span>
|
||||||
|
<div className={`w-9 h-5 rounded-full relative ${p.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
|
||||||
|
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 shadow-sm" style={{ left: p.enabled ? '18px' : '2px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/features/field-ops/ShipAgent.tsx
Normal file
47
src/features/field-ops/ShipAgent.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/* SFR-16: 함정용 단말에서 이용가능한 Agent 개발 */
|
||||||
|
|
||||||
|
interface Agent { id: string; ship: string; version: string; status: string; sync: string; lastSync: string; tasks: number; [key: string]: unknown; }
|
||||||
|
const DATA: Agent[] = [
|
||||||
|
{ id: 'AGT-101', ship: '3001함', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:20:05', tasks: 3 },
|
||||||
|
{ id: 'AGT-102', ship: '3005함', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:20:03', tasks: 2 },
|
||||||
|
{ id: 'AGT-103', ship: '3009함', version: 'v1.1.8', status: '온라인', sync: '동기화 중', lastSync: '09:15:22', tasks: 1 },
|
||||||
|
{ id: 'AGT-104', ship: '5001함', version: 'v1.2.0', status: '오프라인', sync: '미동기화', lastSync: '08:45:00', tasks: 0 },
|
||||||
|
{ 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');
|
||||||
|
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')}</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="함정명, Agent ID 검색..." searchKeys={['ship', 'id']} exportFilename="함정Agent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/features/field-ops/index.ts
Normal file
3
src/features/field-ops/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { MobileService } from './MobileService';
|
||||||
|
export { ShipAgent } from './ShipAgent';
|
||||||
|
export { AIAlert } from './AIAlert';
|
||||||
104
src/features/monitoring/MonitoringDashboard.tsx
Normal file
104
src/features/monitoring/MonitoringDashboard.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/* 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 TREND = Array.from({ length: 24 }, (_, i) => ({ h: `${i}시`, risk: 30 + Math.floor(Math.random() * 50), alarms: Math.floor(Math.random() * 8) }));
|
||||||
|
// 위반 유형 → 차트 색상 매핑
|
||||||
|
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' };
|
||||||
|
|
||||||
|
export function MonitoringDashboard() {
|
||||||
|
const { t } = useTranslation('dashboard');
|
||||||
|
const kpiStore = useKpiStore();
|
||||||
|
const eventStore = useEventStore();
|
||||||
|
|
||||||
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||||
|
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
||||||
|
|
||||||
|
// KPI: store metrics + UI 매핑
|
||||||
|
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',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PIE: store violationTypes → 차트 데이터 변환
|
||||||
|
const PIE = kpiStore.violationTypes.map((v) => ({
|
||||||
|
name: v.type,
|
||||||
|
value: v.pct,
|
||||||
|
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 이벤트: store events → 첫 6개, time 포맷 변환
|
||||||
|
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
|
||||||
|
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : 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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{KPI.map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${k.color}15` }}><k.icon className="w-4 h-4" style={{ color: k.color }} /></div>
|
||||||
|
<span className="text-lg font-bold text-heading">{k.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">24시간 위험도·경보 추이</div>
|
||||||
|
<AreaChart data={TREND} xKey="h" height={200} series={[{ key: 'risk', name: '위험도', color: '#ef4444' }, { key: 'alarms', name: '경보', color: '#3b82f6' }]} />
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">탐지 유형 분포</div>
|
||||||
|
<PieChart data={PIE} height={140} innerRadius={30} outerRadius={55} />
|
||||||
|
<div className="space-y-1 mt-2">{PIE.map(d => (
|
||||||
|
<div key={d.name} className="flex justify-between text-[10px]"><div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} /><span className="text-muted-foreground">{d.name}</span></div><span className="text-heading font-bold">{d.value}%</span></div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-red-400" />실시간 이벤트 타임라인</div>
|
||||||
|
<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-[11px] text-heading font-medium flex-1">{e.title}</span>
|
||||||
|
<span className="text-[10px] text-hint">{e.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/features/monitoring/index.ts
Normal file
1
src/features/monitoring/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MonitoringDashboard } from './MonitoringDashboard';
|
||||||
217
src/features/patrol/FleetOptimization.tsx
Normal file
217
src/features/patrol/FleetOptimization.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
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 { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react';
|
||||||
|
import { usePatrolStore } from '@stores/patrolStore';
|
||||||
|
|
||||||
|
/* SFR-08: AI 경비함정 다함정 협력형 경로 최적화 서비스 */
|
||||||
|
|
||||||
|
const FLEET_SHIP_IDS = ['P-3001', 'P-3005', 'P-3009', 'P-5001', 'P-1503'];
|
||||||
|
const FLEET_COLORS: Record<string, string> = {
|
||||||
|
'P-3001': '#ef4444',
|
||||||
|
'P-3005': '#f97316',
|
||||||
|
'P-3009': '#eab308',
|
||||||
|
'P-5001': '#22c55e',
|
||||||
|
'P-1503': '#64748b',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function FleetOptimization() {
|
||||||
|
const { t } = useTranslation('patrol');
|
||||||
|
const { ships, coverage: COVERAGE, fleetRoutes: FLEET_ROUTES, load } = usePatrolStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
const [simRunning, setSimRunning] = useState(false);
|
||||||
|
const [approved, setApproved] = useState(false);
|
||||||
|
|
||||||
|
const FLEET = useMemo(
|
||||||
|
() =>
|
||||||
|
ships
|
||||||
|
.filter((s) => FLEET_SHIP_IDS.includes(s.id))
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
zone: s.zone ?? '-',
|
||||||
|
status: s.status === '정비중' ? '정비중' : ['추적중', '검문중', '초계중'].includes(s.status) ? '출동중' : '가용',
|
||||||
|
speed: s.speed > 0 ? `${s.speed}kt` : '-',
|
||||||
|
fuel: s.fuel,
|
||||||
|
eta: s.status === '정비중' ? '-' : ['추적중', '검문중', '초계중'].includes(s.status) ? '2h' : '즉시',
|
||||||
|
lat: s.lat,
|
||||||
|
lng: s.lng,
|
||||||
|
color: FLEET_COLORS[s.id] ?? '#64748b',
|
||||||
|
})),
|
||||||
|
[ships],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => {
|
||||||
|
// 커버리지 영역 (최적화 후)
|
||||||
|
const coverageZones = COVERAGE.map((c) => ({
|
||||||
|
name: c.zone,
|
||||||
|
lat: c.lat,
|
||||||
|
lng: c.lng,
|
||||||
|
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
|
||||||
|
radiusM: c.radius,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 커버리지 라벨 마커
|
||||||
|
const coverageLabels = COVERAGE.map((c) => ({
|
||||||
|
lat: c.lat,
|
||||||
|
lng: c.lng,
|
||||||
|
color: c.optimized > 90 ? '#06b6d4' : c.optimized > 70 ? '#3b82f6' : '#64748b',
|
||||||
|
radius: 500,
|
||||||
|
label: `${c.zone} ${c.optimized}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 함정별 순찰 경로
|
||||||
|
const routeLayers = FLEET
|
||||||
|
.filter((f) => FLEET_ROUTES[f.id] && f.status !== '정비중')
|
||||||
|
.map((f) =>
|
||||||
|
createPolylineLayer(`route-${f.id}`, FLEET_ROUTES[f.id], {
|
||||||
|
color: f.color,
|
||||||
|
width: 2.5,
|
||||||
|
opacity: 0.7,
|
||||||
|
dashArray: f.status === '출동중' ? [6, 4] : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 함정 마커
|
||||||
|
const fleetMarkers = FLEET.map((f) => ({
|
||||||
|
lat: f.lat,
|
||||||
|
lng: f.lng,
|
||||||
|
color: f.color,
|
||||||
|
radius: f.status !== '정비중' ? 1200 : 600,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createZoneLayer('coverage', coverageZones, 30000, 0.12),
|
||||||
|
createMarkerLayer('coverage-labels', coverageLabels),
|
||||||
|
...routeLayers,
|
||||||
|
createMarkerLayer('fleet-markers', fleetMarkers),
|
||||||
|
];
|
||||||
|
}, [FLEET, COVERAGE, FLEET_ROUTES]);
|
||||||
|
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')}</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>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '투입 가능', v: `${FLEET.filter(f => f.status === '가용').length}척`, c: 'text-green-400', i: Ship },
|
||||||
|
{ l: '커버리지(현재)', v: '52%', c: 'text-yellow-400', i: Target },
|
||||||
|
{ l: '커버리지(최적화)', v: '88%', c: 'text-cyan-400', i: Layers },
|
||||||
|
{ l: '효율 개선', v: '+36%p', c: 'text-purple-400', i: BarChart3 },
|
||||||
|
].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">
|
||||||
|
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* 함정 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">투입 가능 함정</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{FLEET.map(f => (
|
||||||
|
<div key={f.id} className={`px-3 py-2 rounded-lg ${f.status === '가용' ? 'bg-surface-overlay' : 'bg-surface-overlay opacity-50'}`}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-[9px] text-hint">
|
||||||
|
<span>구역: {f.zone}</span><span>속력: {f.speed}</span><span>연료: {f.fuel}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 커버리지 비교 */}
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">해역 커버리지 비교 (현재 vs AI 최적화)</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{COVERAGE.map(c => (
|
||||||
|
<div key={c.zone} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-[10px]">
|
||||||
|
<span className="text-muted-foreground">{c.zone}</span>
|
||||||
|
<span className="text-hint">{c.ships > 0 ? `${c.ships}척 배치` : '미배치'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className="text-[9px] text-hint w-8">현재</span>
|
||||||
|
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-yellow-500 rounded-full" style={{ width: `${c.current}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-yellow-400 w-8 text-right">{c.current}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className="text-[9px] text-hint w-8">최적</span>
|
||||||
|
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-cyan-500 rounded-full" style={{ width: `${c.optimized}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-cyan-400 w-8 text-right">{c.optimized}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{approved && (
|
||||||
|
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-[11px] text-green-400 font-bold">최종 적용 승인 완료 (Human in the loop)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 해역 커버리지 지도 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} center={[36.0, 127.0]} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" />
|
||||||
|
{/* 범례 */}
|
||||||
|
<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">
|
||||||
|
{FLEET.filter(f => f.status !== '정비중').map(f => (
|
||||||
|
<div key={f.id} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-0 border-t-2" style={{ borderColor: f.color }} />
|
||||||
|
<span className="text-[8px] text-muted-foreground">{f.name} ({f.zone})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-cyan-500/20 border border-cyan-500/40" /><span className="text-[8px] text-muted-foreground">커버리지 영역 (90%+)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-blue-500/15 border border-blue-500/30" /><span className="text-[8px] text-muted-foreground">커버리지 영역 (70~90%)</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full border border-dashed border-slate-500/40" /><span className="text-[8px] text-muted-foreground">미배치 영역</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<Users className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span className="text-[10px] text-purple-400 font-bold">{FLEET.filter(f => f.status !== '정비중').length}척 투입</span>
|
||||||
|
<span className="text-[9px] text-hint">최적화 커버리지 88%</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/features/patrol/PatrolRoute.tsx
Normal file
214
src/features/patrol/PatrolRoute.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { Navigation, Ship, MapPin, Clock, Wind, Anchor, Play, BarChart3, Target, Settings, CheckCircle, Share2 } from 'lucide-react';
|
||||||
|
import { usePatrolStore } from '@stores/patrolStore';
|
||||||
|
|
||||||
|
/* SFR-07: AI 경비함정 단일 함정 순찰·경로 추천 */
|
||||||
|
|
||||||
|
const RANGE_MAP: Record<string, string> = {
|
||||||
|
'태극급': '3,500NM',
|
||||||
|
'참수리급': '800NM',
|
||||||
|
'삼봉급': '5,000NM',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTE_SHIP_IDS = ['P-3001', 'P-3005', 'P-3009', 'P-5001'];
|
||||||
|
|
||||||
|
|
||||||
|
export function PatrolRoute() {
|
||||||
|
const { t } = useTranslation('patrol');
|
||||||
|
const { ships, routes, scenarios, load } = usePatrolStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const [selectedShip, setSelectedShip] = useState('P-3001');
|
||||||
|
const [selectedScenario, setSelectedScenario] = useState(1);
|
||||||
|
|
||||||
|
const SHIPS = useMemo(
|
||||||
|
() =>
|
||||||
|
ships
|
||||||
|
.filter((s) => ROUTE_SHIP_IDS.includes(s.id))
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
class: s.shipClass,
|
||||||
|
speed: `${s.speed}kt`,
|
||||||
|
range: RANGE_MAP[s.shipClass] ?? '-',
|
||||||
|
status: ['추적중', '검문중', '초계중'].includes(s.status) ? '출동중' : s.status === '정비중' ? '정비중' : '가용',
|
||||||
|
})),
|
||||||
|
[ships],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
const route = routes[selectedShip] || routes['P-3001'];
|
||||||
|
const currentShip = SHIPS.find(s => s.id === selectedShip) ?? SHIPS[0];
|
||||||
|
|
||||||
|
const wps = route?.waypoints ?? [];
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => {
|
||||||
|
if (wps.length === 0) return [...STATIC_LAYERS];
|
||||||
|
|
||||||
|
const routeCoords: [number, number][] = wps.map(w => [w.lat, w.lng]);
|
||||||
|
const midMarkers = [];
|
||||||
|
for (let i = 0; i < wps.length - 1; i++) {
|
||||||
|
midMarkers.push({
|
||||||
|
lat: (wps[i].lat + wps[i + 1].lat) / 2,
|
||||||
|
lng: (wps[i].lng + wps[i + 1].lng) / 2,
|
||||||
|
color: '#06b6d4',
|
||||||
|
radius: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const waypointMarkers = wps.map((wp, i) => {
|
||||||
|
const isStart = i === 0;
|
||||||
|
const isEnd = i === wps.length - 1;
|
||||||
|
const color = isStart || isEnd ? '#22c55e' : '#06b6d4';
|
||||||
|
return { lat: wp.lat, lng: wp.lng, color, radius: isStart || isEnd ? 1400 : 1000, label: `WP${i + 1}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createPolylineLayer('patrol-route', routeCoords, { color: '#06b6d4', width: 3, opacity: 0.8 }),
|
||||||
|
createMarkerLayer('route-midpoints', midMarkers, '#06b6d4', 500),
|
||||||
|
createMarkerLayer('waypoint-markers', waypointMarkers),
|
||||||
|
];
|
||||||
|
}, [wps]);
|
||||||
|
useMapLayers(mapRef, buildLayers, [wps]);
|
||||||
|
|
||||||
|
const handleMapReady = useCallback((map: maplibregl.Map) => {
|
||||||
|
if (wps.length === 0) return;
|
||||||
|
const lngs = wps.map(w => w.lng);
|
||||||
|
const lats = wps.map(w => w.lat);
|
||||||
|
map.fitBounds(
|
||||||
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
||||||
|
{ padding: 40 },
|
||||||
|
);
|
||||||
|
}, [wps]);
|
||||||
|
|
||||||
|
const mapCenter: [number, number] = wps.length > 0
|
||||||
|
? [wps.reduce((s, w) => s + w.lat, 0) / wps.length, wps.reduce((s, w) => s + w.lng, 0) / wps.length]
|
||||||
|
: [36.0, 126.0];
|
||||||
|
|
||||||
|
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')}</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>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{/* 함정 선택 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Ship className="w-4 h-4 text-cyan-400" />함정 선택</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SHIPS.map(s => (
|
||||||
|
<div key={s.id} onClick={() => s.status === '가용' && setSelectedShip(s.id)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-hint mt-0.5">{s.class} · {s.speed} · {s.range}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 경로 Waypoint */}
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><MapPin className="w-4 h-4 text-green-400" />추천 순찰 경로 ({currentShip.name})</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{route.waypoints.map((wp, i) => (
|
||||||
|
<div key={wp.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${i === 0 || i === route.waypoints.length - 1 ? 'bg-green-500/15 border border-green-500/30' : 'bg-cyan-500/15 border border-cyan-500/30'}`}>
|
||||||
|
<span className={`text-[9px] font-bold ${i === 0 || i === route.waypoints.length - 1 ? 'text-green-400' : 'text-cyan-400'}`}>{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[11px] text-heading font-medium">{wp.name}</div>
|
||||||
|
<div className="text-[9px] text-hint">{wp.lat.toFixed(2)}°N, {wp.lng.toFixed(2)}°E</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono">ETA {wp.eta}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 mt-3 pt-3 border-t border-border text-[10px]">
|
||||||
|
{[['총 거리', route.summary.dist], ['예상 시간', route.summary.time], ['연료 소모', route.summary.fuel], ['커버 격자', route.summary.grids]].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex-1 text-center"><div className="text-hint">{k}</div><div className="text-heading font-bold">{v}</div></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 시나리오 가중치 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Settings className="w-4 h-4 text-yellow-400" />시나리오 가중치</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scenarios.map((sc, i) => (
|
||||||
|
<div key={sc.name} onClick={() => setSelectedScenario(i)}
|
||||||
|
className={`px-3 py-2 rounded-lg cursor-pointer ${selectedScenario === i ? 'bg-yellow-500/10 border border-yellow-500/30' : 'bg-surface-overlay border border-transparent'}`}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[11px] text-heading font-medium">{sc.name}</span>
|
||||||
|
<span className="text-[10px] text-yellow-400 font-bold">{sc.score}점</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-1 text-[9px]">
|
||||||
|
<span className="text-red-400">위험 {sc.weight.risk}%</span>
|
||||||
|
<span className="text-green-400">연료 {sc.weight.fuel}%</span>
|
||||||
|
<span className="text-blue-400">시간 {sc.weight.time}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-border">
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">기존 방식 대비 효율성</div>
|
||||||
|
<div className="flex justify-between text-[10px]">
|
||||||
|
<span className="text-hint">커버리지</span><span className="text-green-400 font-bold">+32%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px]">
|
||||||
|
<span className="text-hint">연료 절감</span><span className="text-green-400 font-bold">-18%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 순찰 경로 지도 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} key={selectedShip} center={mapCenter} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" 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.5">순찰 경로</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-green-500" /><span className="text-[8px] text-muted-foreground">출발/귀항</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-cyan-500" /><span className="text-[8px] text-muted-foreground">경유 초계점</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-6 h-0 border-t-2 border-cyan-500" /><span className="text-[8px] text-muted-foreground">순찰 경로</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>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 함정 정보 */}
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<Ship className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-[10px] text-cyan-400 font-bold">{currentShip.name}</span>
|
||||||
|
<span className="text-[9px] text-hint">{currentShip.class} · {route.waypoints.length} waypoints · {route.summary.dist}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/features/patrol/index.ts
Normal file
2
src/features/patrol/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { PatrolRoute } from './PatrolRoute';
|
||||||
|
export { FleetOptimization } from './FleetOptimization';
|
||||||
133
src/features/risk-assessment/EnforcementPlan.tsx
Normal file
133
src/features/risk-assessment/EnforcementPlan.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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 { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Shield, AlertTriangle, Clock, MapPin, Ship, Bell, Plus, Target, Calendar, Users } from 'lucide-react';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { MarkerData } from '@lib/map';
|
||||||
|
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||||
|
|
||||||
|
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
||||||
|
|
||||||
|
interface Plan { id: string; zone: string; lat: number; lng: number; risk: number; period: string; ships: string; crew: number; status: string; alert: string; [key: string]: unknown; }
|
||||||
|
|
||||||
|
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>; } },
|
||||||
|
{ 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 === '확정' ? 'bg-green-500/20 text-green-400' : s === '계획중' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||||
|
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
||||||
|
render: v => { const a = v as string; return a === '경보 발령' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EnforcementPlan() {
|
||||||
|
const { t } = useTranslation('enforcement');
|
||||||
|
const { plans: storePlans, load } = useEnforcementStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const PLANS: Plan[] = useMemo(
|
||||||
|
() => storePlans.map((p) => ({ ...p } as Plan)),
|
||||||
|
[storePlans],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createRadiusLayer(
|
||||||
|
'ep-radius-confirmed',
|
||||||
|
PLANS.filter(p => p.status === '확정').map(p => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lng: p.lng,
|
||||||
|
radius: 20000,
|
||||||
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
||||||
|
})),
|
||||||
|
0.15,
|
||||||
|
),
|
||||||
|
createRadiusLayer(
|
||||||
|
'ep-radius-planned',
|
||||||
|
PLANS.filter(p => p.status !== '확정').map(p => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lng: p.lng,
|
||||||
|
radius: 20000,
|
||||||
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
||||||
|
})),
|
||||||
|
0.06,
|
||||||
|
),
|
||||||
|
createMarkerLayer(
|
||||||
|
'ep-markers',
|
||||||
|
PLANS.map(p => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lng: p.lng,
|
||||||
|
color: p.risk > 80 ? '#ef4444' : p.risk > 60 ? '#f97316' : '#eab308',
|
||||||
|
radius: 1000,
|
||||||
|
label: `${p.id} ${p.zone}`,
|
||||||
|
} as MarkerData)),
|
||||||
|
),
|
||||||
|
], [PLANS]);
|
||||||
|
|
||||||
|
useMapLayers(mapRef, buildLayers, [PLANS]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[{ l: '오늘 계획', v: '3건', c: 'text-heading', i: Calendar }, { l: '경보 발령', v: '1건', c: 'text-red-400', i: AlertTriangle }, { l: '투입 함정', v: '4척', c: 'text-cyan-400', i: Ship }, { l: '투입 인력', v: '90명', c: 'text-green-400', i: Users }].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">
|
||||||
|
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3">경보 임계값 설정</div>
|
||||||
|
<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>
|
||||||
|
<span className="text-muted-foreground">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<DataTable data={PLANS} columns={cols} pageSize={10} searchPlaceholder="구역, 함정명 검색..." searchKeys={['zone', 'ships']} exportFilename="단속계획" />
|
||||||
|
|
||||||
|
{/* 단속 구역 지도 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} center={[36.2, 126.0]} zoom={7} height={420} className="rounded-lg overflow-hidden" />
|
||||||
|
{/* 범례 */}
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-red-500/70 border border-red-500" /><span className="text-[8px] text-muted-foreground">위험도 80+</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-orange-500/70 border border-orange-500" /><span className="text-[8px] text-muted-foreground">위험도 60~80</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded-full bg-yellow-500/70 border border-yellow-500" /><span className="text-[8px] text-muted-foreground">위험도 40~60</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-6 h-3 rounded-sm border-2 border-orange-500/50 bg-orange-500/15" /><span className="text-[7px] text-hint">확정</span></div>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-6 h-3 rounded-sm border border-dashed border-orange-500/40 bg-orange-500/5" /><span className="text-[7px] text-hint">계획중</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<span className="text-[10px] text-orange-400 font-bold">{PLANS.length}개</span>
|
||||||
|
<span className="text-[9px] text-hint ml-1">단속 구역 배치</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
481
src/features/risk-assessment/RiskMap.tsx
Normal file
481
src/features/risk-assessment/RiskMap.tsx
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
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 { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Map, Layers, Filter, Clock, BarChart3, Target, AlertTriangle, Eye, RefreshCw, Printer, Download, Ship, Anchor, Calendar, TrendingUp } from 'lucide-react';
|
||||||
|
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart, BaseChart } from '@lib/charts';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SFR-05: 격자 기반 불법조업 위험도 지도 생성·시각화
|
||||||
|
* + MTIS 해양사고 통계 연계 (중앙해양안전심판원)
|
||||||
|
* ① 년도별 통계 ② 선박 특성별 ③ 사고종류별 ④ 시간적 특성별 ⑤ 사고율
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'heatmap' | 'yearly' | 'shipProp' | 'accType' | 'timeStat' | 'accRate';
|
||||||
|
|
||||||
|
// ─── 위험도 격자 데이터 ──────────────────
|
||||||
|
const RISK_LEVELS = [
|
||||||
|
{ level: 5, label: '매우높음', color: '#ef4444', count: 42, pct: 2.3 },
|
||||||
|
{ level: 4, label: '높음', color: '#f97316', count: 128, pct: 6.9 },
|
||||||
|
{ level: 3, label: '보통', color: '#eab308', count: 356, pct: 19.3 },
|
||||||
|
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
|
||||||
|
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
|
||||||
|
];
|
||||||
|
const GRID_ROWS = 10;
|
||||||
|
const GRID_COLS = 18;
|
||||||
|
const generateGrid = () => Array.from({ length: GRID_ROWS }, () =>
|
||||||
|
Array.from({ length: GRID_COLS }, () => Math.floor(Math.random() * 5) + 1)
|
||||||
|
);
|
||||||
|
const ZONE_SUMMARY = [
|
||||||
|
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
|
||||||
|
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
|
||||||
|
{ zone: '서해 5도', risk: 68, trend: '-2', vessels: 12 },
|
||||||
|
{ zone: 'EEZ 서부', risk: 55, trend: '+1', vessels: 31 },
|
||||||
|
{ zone: '남해 연안', risk: 32, trend: '-4', vessels: 45 },
|
||||||
|
{ zone: '동해 EEZ', risk: 28, trend: '0', vessels: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ① 년도별 통계 (MTIS) ──────────────────
|
||||||
|
const YEARLY = [
|
||||||
|
{ year: '2018', accidents: 2671, casualties: 449, deaths: 77, missing: 40, ships: 3120 },
|
||||||
|
{ year: '2019', accidents: 2620, casualties: 419, deaths: 72, missing: 35, ships: 3050 },
|
||||||
|
{ year: '2020', accidents: 2307, casualties: 368, deaths: 65, missing: 28, ships: 2680 },
|
||||||
|
{ year: '2021', accidents: 2319, casualties: 392, deaths: 71, missing: 32, ships: 2700 },
|
||||||
|
{ year: '2022', accidents: 2478, casualties: 412, deaths: 68, missing: 30, ships: 2890 },
|
||||||
|
{ year: '2023', accidents: 2541, casualties: 398, deaths: 62, missing: 27, ships: 2960 },
|
||||||
|
{ year: '2024', accidents: 2380, casualties: 371, deaths: 58, missing: 25, ships: 2770 },
|
||||||
|
{ year: '2025', accidents: 2290, casualties: 345, deaths: 52, missing: 22, ships: 2650 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ② 선박 특성별 ──────────────────────
|
||||||
|
const BY_SHIP_TYPE = [
|
||||||
|
{ type: '어선', count: 1542, pct: 64.2, color: '#ef4444' },
|
||||||
|
{ type: '화물선', count: 312, pct: 13.0, color: '#f97316' },
|
||||||
|
{ type: '여객선', count: 89, pct: 3.7, color: '#eab308' },
|
||||||
|
{ type: '유조선', count: 67, pct: 2.8, color: '#8b5cf6' },
|
||||||
|
{ type: '예인선', count: 145, pct: 6.0, color: '#3b82f6' },
|
||||||
|
{ type: '레저', count: 178, pct: 7.4, color: '#06b6d4' },
|
||||||
|
{ type: '기타', count: 67, pct: 2.8, color: '#6b7280' },
|
||||||
|
];
|
||||||
|
const BY_TONNAGE = [
|
||||||
|
{ range: '5톤 미만', count: 892, pct: 37.2 },
|
||||||
|
{ range: '5~20톤', count: 534, pct: 22.3 },
|
||||||
|
{ range: '20~100톤', count: 412, pct: 17.2 },
|
||||||
|
{ range: '100~500톤', count: 298, pct: 12.4 },
|
||||||
|
{ range: '500~3000톤', count: 156, pct: 6.5 },
|
||||||
|
{ range: '3000톤 이상', count: 108, pct: 4.5 },
|
||||||
|
];
|
||||||
|
const BY_AGE = [
|
||||||
|
{ range: '5년 미만', count: 189, pct: 7.9 },
|
||||||
|
{ range: '5~10년', count: 312, pct: 13.0 },
|
||||||
|
{ range: '10~20년', count: 578, pct: 24.1 },
|
||||||
|
{ range: '20~30년', count: 687, pct: 28.6 },
|
||||||
|
{ range: '30~40년', count: 423, pct: 17.6 },
|
||||||
|
{ range: '40년 이상', count: 211, pct: 8.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ③ 사고종류별 ──────────────────────
|
||||||
|
const ACC_TYPES = [
|
||||||
|
{ type: '충돌', count: 687, pct: 28.6, color: '#ef4444' },
|
||||||
|
{ type: '좌초', count: 312, pct: 13.0, color: '#f97316' },
|
||||||
|
{ type: '전복', count: 245, pct: 10.2, color: '#eab308' },
|
||||||
|
{ type: '침몰', count: 178, pct: 7.4, color: '#a855f7' },
|
||||||
|
{ type: '화재·폭발', count: 156, pct: 6.5, color: '#ef4444' },
|
||||||
|
{ type: '기관손상', count: 423, pct: 17.6, color: '#3b82f6' },
|
||||||
|
{ type: '안전사고', count: 234, pct: 9.7, color: '#06b6d4' },
|
||||||
|
{ type: '기타', count: 165, pct: 6.9, color: '#6b7280' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ④ 시간적 특성별 ──────────────────────
|
||||||
|
const BY_MONTH = [
|
||||||
|
{ m: '1월', count: 178 }, { m: '2월', count: 156 }, { m: '3월', count: 198 },
|
||||||
|
{ m: '4월', count: 234 }, { m: '5월', count: 267 }, { m: '6월', count: 245 },
|
||||||
|
{ m: '7월', count: 289 }, { m: '8월', count: 312 }, { m: '9월', count: 256 },
|
||||||
|
{ m: '10월', count: 223 }, { m: '11월', count: 189 }, { m: '12월', count: 165 },
|
||||||
|
];
|
||||||
|
const BY_HOUR = [
|
||||||
|
{ h: '00~04시', count: 189 }, { h: '04~08시', count: 234 }, { h: '08~12시', count: 567 },
|
||||||
|
{ h: '12~16시', count: 612 }, { h: '16~20시', count: 489 }, { h: '20~24시', count: 309 },
|
||||||
|
];
|
||||||
|
const BY_DAY = [
|
||||||
|
{ d: '월', count: 356 }, { d: '화', count: 378 }, { d: '수', count: 345 },
|
||||||
|
{ d: '목', count: 334 }, { d: '금', count: 389 }, { d: '토', count: 312 }, { d: '일', count: 286 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── ⑤ 사고율 ──────────────────────────
|
||||||
|
const ACC_RATE = [
|
||||||
|
{ type: '어선', registered: 62345, accidents: 1542, rate: 2.47 },
|
||||||
|
{ type: '여객선', registered: 1234, accidents: 89, rate: 7.21 },
|
||||||
|
{ type: '화물선', registered: 4567, accidents: 312, rate: 6.83 },
|
||||||
|
{ type: '유조선', registered: 890, accidents: 67, rate: 7.53 },
|
||||||
|
{ type: '예인선', registered: 3456, accidents: 145, rate: 4.20 },
|
||||||
|
{ type: '레저', registered: 28900, accidents: 178, rate: 0.62 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 히트맵 포인트 데이터 (한반도 주변 해역) ──────
|
||||||
|
// [lat, lng, intensity] — 서해 NLL·EEZ 위주 고위험 분포
|
||||||
|
function generateHeatPoints(): [number, number, number][] {
|
||||||
|
const points: [number, number, number][] = [];
|
||||||
|
// 서해 NLL 인근 (최고 위험)
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
points.push([37.6 + Math.random() * 0.6, 124.3 + Math.random() * 1.2, 0.7 + Math.random() * 0.3]);
|
||||||
|
}
|
||||||
|
// EEZ 북부 (고위험)
|
||||||
|
for (let i = 0; i < 90; i++) {
|
||||||
|
points.push([36.5 + Math.random() * 1.0, 123.5 + Math.random() * 1.5, 0.5 + Math.random() * 0.4]);
|
||||||
|
}
|
||||||
|
// 서해 5도 수역 (고위험)
|
||||||
|
for (let i = 0; i < 70; i++) {
|
||||||
|
points.push([37.0 + Math.random() * 0.8, 124.5 + Math.random() * 1.0, 0.6 + Math.random() * 0.3]);
|
||||||
|
}
|
||||||
|
// EEZ 서부 (중위험)
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
points.push([35.5 + Math.random() * 1.0, 123.0 + Math.random() * 1.5, 0.3 + Math.random() * 0.4]);
|
||||||
|
}
|
||||||
|
// 남해 연안 (저위험)
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
points.push([34.0 + Math.random() * 0.8, 126.0 + Math.random() * 2.0, 0.1 + Math.random() * 0.3]);
|
||||||
|
}
|
||||||
|
// 동해 EEZ (저위험)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
points.push([36.0 + Math.random() * 1.5, 130.0 + Math.random() * 1.5, 0.1 + Math.random() * 0.25]);
|
||||||
|
}
|
||||||
|
// 제주 남방 (중위험)
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
points.push([32.5 + Math.random() * 1.0, 125.5 + Math.random() * 2.0, 0.2 + Math.random() * 0.35]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEAT_POINTS = generateHeatPoints();
|
||||||
|
|
||||||
|
type SelectedGrid = { row: number; col: number } | null;
|
||||||
|
|
||||||
|
export function RiskMap() {
|
||||||
|
const [grid] = useState(generateGrid);
|
||||||
|
const [selectedGrid, setSelectedGrid] = useState<SelectedGrid>(null);
|
||||||
|
const [tab, setTab] = useState<Tab>('heatmap');
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const riskColor = (level: number) => RISK_LEVELS.find(r => r.level === level)?.color || '#6b7280';
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => {
|
||||||
|
if (tab !== 'heatmap') return [];
|
||||||
|
return [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createHeatmapLayer('risk-heat', HEAT_POINTS as HeatPoint[], { radiusPixels: 25 }),
|
||||||
|
];
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
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" />격자 기반 불법조업 위험도 지도</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>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'heatmap' as Tab, icon: Layers, label: '위험도 히트맵' },
|
||||||
|
{ key: 'yearly' as Tab, icon: TrendingUp, label: '년도별 통계' },
|
||||||
|
{ key: 'shipProp' as Tab, icon: Ship, label: '선박 특성별' },
|
||||||
|
{ key: 'accType' as Tab, icon: AlertTriangle, label: '사고종류별' },
|
||||||
|
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
|
||||||
|
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
|
||||||
|
]).map(t => (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 위험도 히트맵 (지도 기반) ── */}
|
||||||
|
{tab === 'heatmap' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 위험도 등급 요약 카드 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RISK_LEVELS.map(r => (
|
||||||
|
<div key={r.level} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: r.color }} />
|
||||||
|
<span className="text-sm font-bold text-heading">{r.count}</span>
|
||||||
|
<span className="text-[9px] text-hint">{r.label} ({r.pct}%)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{/* 지도 히트맵 */}
|
||||||
|
<Card className="col-span-2 bg-surface-raised border-border">
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap
|
||||||
|
ref={mapRef}
|
||||||
|
center={[35.8, 127.0]}
|
||||||
|
zoom={7}
|
||||||
|
height={480}
|
||||||
|
className="w-full rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
{/* 범례 오버레이 */}
|
||||||
|
<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="flex items-center gap-1">
|
||||||
|
<span className="text-[8px] text-blue-400">낮음</span>
|
||||||
|
<div className="w-32 h-2.5 rounded-full" style={{
|
||||||
|
background: 'linear-gradient(to right, #1e40af, #3b82f6, #22c55e, #eab308, #f97316, #ef4444)',
|
||||||
|
}} />
|
||||||
|
<span className="text-[8px] text-red-400">높음</span>
|
||||||
|
</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/60" /><span className="text-[8px] text-hint">EEZ</span></div>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/80" /><span className="text-[8px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 해역별 위험도 사이드 패널 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-2">해역별 위험도</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ZONE_SUMMARY.map(z => (
|
||||||
|
<div key={z.zone} className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-muted-foreground w-16 truncate">{z.zone}</span>
|
||||||
|
<div className="flex-1 h-2 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${z.risk}%`, backgroundColor: z.risk > 70 ? '#ef4444' : z.risk > 50 ? '#f97316' : '#22c55e' }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-heading font-bold w-6 text-right">{z.risk}</span>
|
||||||
|
<span className={`text-[9px] w-6 ${z.trend.startsWith('+') ? 'text-red-400' : 'text-green-400'}`}>{z.trend}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-2">위험도 격자 요약</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{RISK_LEVELS.map(r => (
|
||||||
|
<div key={r.level} className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: r.color }} />
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-1">{r.label}</span>
|
||||||
|
<span className="text-[10px] text-heading font-bold">{r.count}격자</span>
|
||||||
|
<span className="text-[9px] text-hint">{r.pct}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="text-[9px] text-hint">
|
||||||
|
격자 단위: 1km × 1km<br/>
|
||||||
|
갱신 주기: 6시간<br/>
|
||||||
|
분석 기반: AIS·SAR·광학위성·VMS 융합
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ① 년도별 통계 ── */}
|
||||||
|
{tab === 'yearly' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">해양사고 추세</div>
|
||||||
|
<EcAreaChart
|
||||||
|
data={YEARLY}
|
||||||
|
xKey="year"
|
||||||
|
series={[
|
||||||
|
{ key: 'accidents', name: '사고건수', color: '#3b82f6' },
|
||||||
|
{ key: 'ships', name: '관련선박', color: '#8b5cf6' },
|
||||||
|
]}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">인명피해 추세</div>
|
||||||
|
<EcLineChart
|
||||||
|
data={YEARLY}
|
||||||
|
xKey="year"
|
||||||
|
series={[
|
||||||
|
{ key: 'casualties', name: '인명피해', color: '#ef4444' },
|
||||||
|
{ key: 'deaths', name: '사망', color: '#f97316' },
|
||||||
|
{ key: 'missing', name: '실종', color: '#eab308' },
|
||||||
|
]}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
<Card><CardContent className="p-0">
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead><tr className="border-b border-border text-hint">
|
||||||
|
{['년도', '사고건수', '관련선박', '인명피해', '사망', '실종'].map(h => <th key={h} className="px-3 py-2 text-left font-medium">{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{YEARLY.map(y => (
|
||||||
|
<tr key={y.year} className="border-b border-border hover:bg-surface-overlay">
|
||||||
|
<td className="px-3 py-1.5 text-heading font-bold">{y.year}</td>
|
||||||
|
<td className="px-3 py-1.5 text-blue-400 font-bold">{y.accidents.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-1.5 text-label">{y.ships.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-1.5 text-red-400">{y.casualties}</td>
|
||||||
|
<td className="px-3 py-1.5 text-orange-400">{y.deaths}</td>
|
||||||
|
<td className="px-3 py-1.5 text-yellow-400">{y.missing}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ② 선박 특성별 ── */}
|
||||||
|
{tab === 'shipProp' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">선박용도별</div>
|
||||||
|
<EcPieChart
|
||||||
|
data={BY_SHIP_TYPE.map(d => ({ name: d.type, value: d.count, color: d.color }))}
|
||||||
|
height={180}
|
||||||
|
innerRadius={35}
|
||||||
|
outerRadius={70}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 mt-2">{BY_SHIP_TYPE.map(d => (
|
||||||
|
<div key={d.type} className="flex justify-between text-[10px]"><div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} /><span className="text-muted-foreground">{d.type}</span></div><span className="text-heading font-bold">{d.count}건 ({d.pct}%)</span></div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">총톤수별</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={BY_TONNAGE}
|
||||||
|
xKey="range"
|
||||||
|
series={[{ key: 'count', name: '사고건수', color: '#3b82f6' }]}
|
||||||
|
height={180}
|
||||||
|
horizontal
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">선박연령별</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={BY_AGE}
|
||||||
|
xKey="range"
|
||||||
|
series={[{ key: 'count', name: '사고건수', color: '#f97316' }]}
|
||||||
|
height={180}
|
||||||
|
horizontal
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ③ 사고종류별 ── */}
|
||||||
|
{tab === 'accType' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">사고종류별 분포</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={ACC_TYPES}
|
||||||
|
xKey="type"
|
||||||
|
series={[{ key: 'count', name: '사고건수' }]}
|
||||||
|
height={220}
|
||||||
|
itemColors={ACC_TYPES.map(d => d.color)}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">비율</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ACC_TYPES.map(a => (
|
||||||
|
<div key={a.type} className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-muted-foreground w-20">{a.type}</span>
|
||||||
|
<div className="flex-1 h-3 bg-switch-background/60 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${a.pct}%`, backgroundColor: a.color }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-heading font-bold w-14 text-right">{a.count}건</span>
|
||||||
|
<span className="text-[9px] text-hint w-10 text-right">{a.pct}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ④ 시간적 특성별 ── */}
|
||||||
|
{tab === 'timeStat' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS)</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">월별 사고건수</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={BY_MONTH}
|
||||||
|
xKey="m"
|
||||||
|
series={[{ key: 'count', name: '건수' }]}
|
||||||
|
height={180}
|
||||||
|
itemColors={BY_MONTH.map(d => d.count > 270 ? '#ef4444' : d.count > 220 ? '#f97316' : '#3b82f6')}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">시간대별 사고건수</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={BY_HOUR}
|
||||||
|
xKey="h"
|
||||||
|
series={[{ key: 'count', name: '건수' }]}
|
||||||
|
height={180}
|
||||||
|
itemColors={BY_HOUR.map(d => d.count > 500 ? '#ef4444' : d.count > 300 ? '#f97316' : '#3b82f6')}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">요일별 사고건수</div>
|
||||||
|
<EcBarChart
|
||||||
|
data={BY_DAY}
|
||||||
|
xKey="d"
|
||||||
|
series={[{ key: 'count', name: '건수', color: '#8b5cf6' }]}
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ⑤ 사고율 ── */}
|
||||||
|
{tab === 'accRate' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 해양사고 통계 (MTIS) · 사고율 = (사고건수 / 등록척수) × 100</div>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">선박용도별 사고율</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<EcBarChart
|
||||||
|
data={ACC_RATE}
|
||||||
|
xKey="type"
|
||||||
|
series={[{ key: 'rate', name: '사고율 %' }]}
|
||||||
|
height={220}
|
||||||
|
itemColors={ACC_RATE.map(d => d.rate > 5 ? '#ef4444' : d.rate > 2 ? '#f97316' : '#22c55e')}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ACC_RATE.map(r => (
|
||||||
|
<div key={r.type} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||||
|
<span className="text-[10px] text-heading font-medium w-16">{r.type}</span>
|
||||||
|
<div className="flex-1 text-[9px] text-hint">등록 {r.registered.toLocaleString()}척</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">사고 {r.accidents}건</span>
|
||||||
|
<span className={`text-[11px] font-bold ${r.rate > 5 ? 'text-red-400' : r.rate > 2 ? 'text-orange-400' : 'text-green-400'}`}>{r.rate}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/features/risk-assessment/index.ts
Normal file
2
src/features/risk-assessment/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { RiskMap } from './RiskMap';
|
||||||
|
export { EnforcementPlan } from './EnforcementPlan';
|
||||||
49
src/features/statistics/ExternalService.tsx
Normal file
49
src/features/statistics/ExternalService.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock } from 'lucide-react';
|
||||||
|
|
||||||
|
/* SFR-14: 외부 서비스(예보·경보) 제공 결과 연계 */
|
||||||
|
|
||||||
|
interface Service { id: string; name: string; target: string; type: string; format: string; cycle: string; privacy: string; status: string; calls: string; [key: string]: unknown; }
|
||||||
|
const DATA: Service[] = [
|
||||||
|
{ id: 'EXT-01', name: '위험도 지도 제공', target: '해수부', type: 'API', format: 'JSON', cycle: '1시간', privacy: '비식별', status: '운영', calls: '12,450' },
|
||||||
|
{ id: 'EXT-02', name: '의심 선박 목록', target: '해수부', type: 'API', format: 'JSON', cycle: '실시간', privacy: '비식별', status: '운영', calls: '8,320' },
|
||||||
|
{ id: 'EXT-03', name: '단속 통계', target: '수협', type: '파일', format: 'Excel', cycle: '일 1회', privacy: '익명화', status: '운영', calls: '365' },
|
||||||
|
{ id: 'EXT-04', name: '어구 현황', target: '해양조사원', type: 'API', format: 'JSON', cycle: '6시간', privacy: '공개', status: '테스트', calls: '540' },
|
||||||
|
{ id: 'EXT-05', name: '경보 이력', target: '기상청', type: 'API', format: 'XML', cycle: '실시간', privacy: '비공개', status: '계획', calls: '-' },
|
||||||
|
];
|
||||||
|
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: '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>; } },
|
||||||
|
{ 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>; } },
|
||||||
|
{ 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')}</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="서비스명, 대상기관 검색..." searchKeys={['name', 'target']} exportFilename="외부서비스연계" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/features/statistics/ReportManagement.tsx
Normal file
186
src/features/statistics/ReportManagement.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
interface Report {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
statusColor: string;
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportManagement() {
|
||||||
|
const { t } = useTranslation('statistics');
|
||||||
|
const [selected, setSelected] = useState<Report>(reports[0]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
const filtered = reports.filter((r) =>
|
||||||
|
!search || r.name.includes(search) || r.id.includes(search) || r.type.includes(search)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-heading whitespace-nowrap">{t('reports.title')}</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>
|
||||||
|
|
||||||
|
{/* 증거 파일 업로드 */}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<SaveButton onClick={() => setShowUpload(false)} label="증거 저장" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{/* 보고서 목록 */}
|
||||||
|
<Card className="flex-1 bg-surface-overlay border-slate-700/40">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="text-sm text-label mb-3">보고서 목록</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => setSelected(r)}
|
||||||
|
className={`p-4 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
selected?.id === r.id
|
||||||
|
? 'bg-switch-background/40 border-blue-500/40'
|
||||||
|
: 'bg-surface-raised border-slate-700/30 hover:bg-surface-overlay'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-hint">{r.id}</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-hint mt-0.5">
|
||||||
|
<span>{r.type}</span>
|
||||||
|
{r.mmsiNote && <><span>·</span><span>{r.mmsiNote}</span></>}
|
||||||
|
<span>·</span>
|
||||||
|
<Clock className="w-3 h-3 inline" />
|
||||||
|
<span>{r.date}</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 보고서 미리보기 */}
|
||||||
|
{selected && (
|
||||||
|
<Card className="w-[460px] shrink-0 bg-surface-overlay border-slate-700/40">
|
||||||
|
<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">
|
||||||
|
<Download className="w-3.5 h-3.5" />다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">
|
||||||
|
{/* 제목 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-heading mb-0.5">불법조업 의심 사건 보고서</h3>
|
||||||
|
<div className="text-[11px] text-hint">보고서 번호: {selected.id}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사건 개요 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-label mb-2">사건 개요</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-2 text-[11px]">
|
||||||
|
<div className="text-hint">선박명:</div><div className="text-heading text-right">{selected.name}</div>
|
||||||
|
<div className="text-hint">의심 유형:</div><div className="text-heading text-right">{selected.mmsiNote || selected.type}</div>
|
||||||
|
<div className="text-hint">발생 일시:</div><div className="text-heading text-right">2026-01-20 14:23:15</div>
|
||||||
|
<div className="text-hint">위치:</div><div className="text-heading text-right">EEZ 북부 (37.56°N, 129.12°E)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 스냅샷 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-label mb-2">지도 스냅샷</div>
|
||||||
|
<div className="bg-green-950/10 border border-green-900/20 rounded-lg h-24 flex items-center justify-center">
|
||||||
|
<MapPin className="w-8 h-8 text-hint" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 판단 설명 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-label mb-2">AI 판단 설명</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{['MMSI 변조 패턴 감지', '3kn 이하 저속 42분간 지속', 'EEZ 1.2km 침범'].map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />{t}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조치 이력 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-label mb-2">조치 이력</div>
|
||||||
|
{['2026-01-20 14:30: 사건 등록', '2026-01-20 14:35: 증거 수집 완료', '2026-01-20 14:45: 보고서 생성'].map((t, i) => (
|
||||||
|
<div key={i} className="text-[11px] text-hint">- {t}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/features/statistics/Statistics.tsx
Normal file
78
src/features/statistics/Statistics.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { BarChart3, TrendingUp, Target, Calendar, Download, FileText } from 'lucide-react';
|
||||||
|
import { BarChart, AreaChart } from '@lib/charts';
|
||||||
|
import { useKpiStore } from '@stores/kpiStore';
|
||||||
|
|
||||||
|
/* SFR-13: 통계·지표·성과 분석 */
|
||||||
|
|
||||||
|
const KPI_DATA: { id: string; name: string; target: string; current: string; status: string; [key: string]: unknown }[] = [
|
||||||
|
{ id: 'KPI-01', name: 'AI 탐지 정확도', target: '90%', current: '93.2%', status: '달성' },
|
||||||
|
{ id: 'KPI-02', name: '오탐률', target: '≤10%', current: '7.8%', status: '달성' },
|
||||||
|
{ id: 'KPI-03', name: '평균 리드타임', target: '≤15분', current: '12분', status: '달성' },
|
||||||
|
{ id: 'KPI-04', name: '단속 성공률', target: '≥60%', current: '68%', status: '달성' },
|
||||||
|
{ id: 'KPI-05', name: '경보 응답시간', target: '≤5분', current: '3.2분', status: '달성' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const kpiCols: DataColumn<typeof KPI_DATA[0]>[] = [
|
||||||
|
{ 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', align: 'center' },
|
||||||
|
{ key: 'current', label: '현재', width: '80px', align: 'center', render: v => <span className="text-cyan-400 font-bold">{v as string}</span> },
|
||||||
|
{ key: 'status', label: '상태', width: '60px', align: 'center',
|
||||||
|
render: v => <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Statistics() {
|
||||||
|
const { t } = useTranslation('statistics');
|
||||||
|
const kpiStore = useKpiStore();
|
||||||
|
|
||||||
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||||
|
|
||||||
|
// MONTHLY: store monthly → xKey 'm'으로 필드명 매핑
|
||||||
|
const MONTHLY = kpiStore.monthly.map((t) => ({
|
||||||
|
m: t.month,
|
||||||
|
enforce: t.enforce,
|
||||||
|
detect: t.detect,
|
||||||
|
accuracy: t.accuracy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// BY_TYPE: store violationTypes 직접 사용
|
||||||
|
const BY_TYPE = kpiStore.violationTypes;
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">월별 단속·탐지 추이</div>
|
||||||
|
<BarChart data={MONTHLY} xKey="m" height={200} series={[{ key: 'enforce', name: '단속', color: '#3b82f6' }, { key: 'detect', name: '탐지', color: '#8b5cf6' }]} />
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">AI 정확도 추이</div>
|
||||||
|
<AreaChart data={MONTHLY} xKey="m" height={200} yAxisDomain={[75, 100]} series={[{ key: 'accuracy', name: '정확도 %', color: '#22c55e' }]} />
|
||||||
|
</CardContent></Card>
|
||||||
|
</div>
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-label mb-3">위반 유형별 분포</div>
|
||||||
|
<div className="flex gap-3">{BY_TYPE.map(t => (
|
||||||
|
<div key={t.type} className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg">
|
||||||
|
<div className="text-lg font-bold text-heading">{t.count}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t.type}</div>
|
||||||
|
<div className="text-[9px] text-hint">{t.pct}%</div>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
<DataTable data={KPI_DATA} columns={kpiCols} pageSize={10} title="핵심 성과 지표 (KPI)" searchPlaceholder="지표명 검색..." exportFilename="성과지표" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/features/statistics/index.ts
Normal file
3
src/features/statistics/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { Statistics } from './Statistics';
|
||||||
|
export { ExternalService } from './ExternalService';
|
||||||
|
export { ReportManagement } from './ReportManagement';
|
||||||
316
src/features/surveillance/LiveMapView.tsx
Normal file
316
src/features/surveillance/LiveMapView.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { MarkerData } from '@lib/map';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { AlertTriangle, Ship, Radio, Layers, Zap, Activity, Clock, Pin } from 'lucide-react';
|
||||||
|
import { useVesselStore } from '@stores/vesselStore';
|
||||||
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
|
||||||
|
interface MapEvent {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
mmsi: string;
|
||||||
|
nationality: string;
|
||||||
|
time: string;
|
||||||
|
vesselName: string;
|
||||||
|
risk: number;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const EVENT_COLORS: Record<string, string> = {
|
||||||
|
'EEZ 침범': '#ef4444',
|
||||||
|
'다크베셀': '#f97316',
|
||||||
|
'AIS 신호 소실': '#eab308',
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventIconMap: Record<string, typeof AlertTriangle> = {
|
||||||
|
'EEZ 침범': AlertTriangle,
|
||||||
|
'다크베셀': Ship,
|
||||||
|
'AIS 신호 소실': Radio,
|
||||||
|
};
|
||||||
|
|
||||||
|
function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' }) {
|
||||||
|
const pct = value * 100;
|
||||||
|
const h = size === 'sm' ? 'h-1' : 'h-1.5';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
|
||||||
|
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveMapView() {
|
||||||
|
const { vessels, loaded: vesselsLoaded, load: loadVessels } = useVesselStore();
|
||||||
|
const { events: storeEvents, loaded: eventsLoaded, load: loadEvents } = useEventStore();
|
||||||
|
|
||||||
|
useEffect(() => { if (!vesselsLoaded) loadVessels(); }, [vesselsLoaded, loadVessels]);
|
||||||
|
useEffect(() => { if (!eventsLoaded) loadEvents(); }, [eventsLoaded, loadEvents]);
|
||||||
|
|
||||||
|
// Map store events (first 3) into local MapEvent shape
|
||||||
|
const mapEvents: MapEvent[] = useMemo(
|
||||||
|
() =>
|
||||||
|
storeEvents.slice(0, 3).map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
type: e.type,
|
||||||
|
mmsi: e.mmsi ?? '미상',
|
||||||
|
nationality: e.mmsi?.startsWith('412') ? 'CN' : e.mmsi?.startsWith('440') ? 'KR' : '미상',
|
||||||
|
time: e.time.split(' ')[1] ?? e.time,
|
||||||
|
vesselName: e.vesselName ?? '미상',
|
||||||
|
risk: (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
|
||||||
|
lat: e.lat ?? 0,
|
||||||
|
lng: e.lng ?? 0,
|
||||||
|
})),
|
||||||
|
[storeEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map store vessels into AIS display list
|
||||||
|
const aisVessels = useMemo(
|
||||||
|
() =>
|
||||||
|
vessels.map((v) => ({
|
||||||
|
lat: v.lat,
|
||||||
|
lng: v.lng,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
speed: v.speed != null ? `${v.speed}kt` : '미상',
|
||||||
|
heading: v.heading ?? 0,
|
||||||
|
})),
|
||||||
|
[vessels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<MapEvent | null>(null);
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
const mapInstanceRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
|
||||||
|
// Auto-select first event once loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapEvents.length > 0 && !selectedEvent) {
|
||||||
|
setSelectedEvent(mapEvents[0]);
|
||||||
|
}
|
||||||
|
}, [mapEvents, selectedEvent]);
|
||||||
|
|
||||||
|
// deck.gl 레이어: 선택 이벤트에 따라 마커 크기 변경
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
// 일반 AIS 선박
|
||||||
|
createMarkerLayer(
|
||||||
|
'ais-vessels',
|
||||||
|
aisVessels.map((v): MarkerData => {
|
||||||
|
const isPatrol = v.type === '경비함' || v.type === '순찰선';
|
||||||
|
const isKorean = v.type === '한국어선';
|
||||||
|
return {
|
||||||
|
lat: v.lat,
|
||||||
|
lng: v.lng,
|
||||||
|
color: isPatrol ? '#a855f7' : isKorean ? '#3b82f6' : '#64748b',
|
||||||
|
radius: isPatrol ? 900 : 600,
|
||||||
|
label: v.name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// 이벤트 경보 반경 원
|
||||||
|
createRadiusLayer(
|
||||||
|
'alert-radius',
|
||||||
|
mapEvents.map(evt => ({
|
||||||
|
lat: evt.lat,
|
||||||
|
lng: evt.lng,
|
||||||
|
radius: 8000,
|
||||||
|
color: EVENT_COLORS[evt.type] || '#ef4444',
|
||||||
|
})),
|
||||||
|
0.08,
|
||||||
|
),
|
||||||
|
// 이벤트 마커 (선택 시 크기 강조)
|
||||||
|
createMarkerLayer(
|
||||||
|
'event-markers',
|
||||||
|
mapEvents.map((evt) => ({
|
||||||
|
lat: evt.lat,
|
||||||
|
lng: evt.lng,
|
||||||
|
color: EVENT_COLORS[evt.type] || '#ef4444',
|
||||||
|
radius: evt.id === selectedEvent?.id ? 1600 : 1100,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
], [selectedEvent, mapEvents, aisVessels]);
|
||||||
|
|
||||||
|
useMapLayers(mapRef, buildLayers, [selectedEvent, mapEvents, aisVessels]);
|
||||||
|
|
||||||
|
// deck.gl onClick → 이벤트 선택
|
||||||
|
const handleMapClick = useCallback((info: unknown) => {
|
||||||
|
const pickInfo = info as { layer?: { id: string }; index?: number };
|
||||||
|
if (pickInfo.layer?.id === 'event-markers' && pickInfo.index != null) {
|
||||||
|
const evt = mapEvents[pickInfo.index];
|
||||||
|
if (evt) setSelectedEvent(evt);
|
||||||
|
}
|
||||||
|
}, [mapEvents]);
|
||||||
|
|
||||||
|
// 지도 인스턴스 접근 (flyTo용)
|
||||||
|
const handleMapReady = useCallback((map: maplibregl.Map) => {
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
// 초기 선택 이벤트로 포커스
|
||||||
|
const first = mapEvents[0];
|
||||||
|
if (first) {
|
||||||
|
map.flyTo({ center: [first.lng, first.lat], zoom: 9, speed: 0.6 });
|
||||||
|
}
|
||||||
|
}, [mapEvents]);
|
||||||
|
|
||||||
|
// 선택 이벤트 변경 시 지도 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapInstanceRef.current;
|
||||||
|
if (!map || !selectedEvent) return;
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
map.flyTo({ center: [selectedEvent.lng, selectedEvent.lat], zoom: 9, speed: 0.6 });
|
||||||
|
}, [selectedEvent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-5 h-[calc(100vh-7rem)]">
|
||||||
|
{/* 좌측: 이벤트 목록 + 지도 */}
|
||||||
|
<div className="flex-1 flex gap-4 min-w-0">
|
||||||
|
{/* 이벤트 카드 목록 */}
|
||||||
|
<div className="w-[260px] shrink-0 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-heading">실시간 이벤트</h2>
|
||||||
|
<p className="text-[11px] text-hint mt-0.5">현재 진행 중인 의심 활동</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mapEvents.map((evt) => {
|
||||||
|
const IconComp = eventIconMap[evt.type] || AlertTriangle;
|
||||||
|
const isSelected = selectedEvent?.id === evt.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={evt.id}
|
||||||
|
onClick={() => setSelectedEvent(evt)}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-card border-blue-500/40'
|
||||||
|
: 'bg-card border-[#1F2F3E] hover:border-blue-600/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconComp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold text-heading">{evt.type}</span>
|
||||||
|
</div>
|
||||||
|
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
|
||||||
|
<RiskBar value={evt.risk} size="sm" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 영역 */}
|
||||||
|
<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} />
|
||||||
|
{/* 범례 */}
|
||||||
|
<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>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{[
|
||||||
|
{ color: '#ef4444', label: 'EEZ 침범' },
|
||||||
|
{ color: '#f97316', label: '다크베셀' },
|
||||||
|
{ color: '#eab308', label: 'AIS 소실' },
|
||||||
|
{ color: '#a855f7', label: '경비함정' },
|
||||||
|
{ color: '#3b82f6', label: '한국어선' },
|
||||||
|
{ color: '#64748b', label: '중국어선' },
|
||||||
|
].map(l => (
|
||||||
|
<div key={l.label} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color }} />
|
||||||
|
<span className="text-[8px] text-muted-foreground">{l.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 pt-1 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>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 실시간 표시 */}
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
|
||||||
|
<span className="text-[9px] text-hint">경보 {mapEvents.length}건 · AIS {aisVessels.length}척</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 이벤트 상세 패널 */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="w-[300px] shrink-0 space-y-3 overflow-y-auto">
|
||||||
|
<h3 className="text-base font-bold text-heading">이벤트 상세</h3>
|
||||||
|
|
||||||
|
{/* 선박 정보 카드 */}
|
||||||
|
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
|
||||||
|
<Ship className="w-4.5 h-4.5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{selectedEvent.id}</div>
|
||||||
|
<div className="text-[10px] text-hint">{selectedEvent.mmsi} · {selectedEvent.nationality} · {selectedEvent.time}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위험도 점수 */}
|
||||||
|
<Card className="bg-surface-overlay border-slate-700/40">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">위험도 점수</div>
|
||||||
|
<div className="flex items-baseline gap-1 mb-2">
|
||||||
|
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
|
||||||
|
<span className="text-sm text-hint">/100</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-2 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${selectedEvent.risk * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI 판단 근거 */}
|
||||||
|
<Card className="bg-surface-overlay border-slate-700/40">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Zap className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-heading font-medium">AI 판단 근거</span>
|
||||||
|
<Badge className="bg-red-500/20 text-red-400 text-[10px]">신뢰도: High</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border-l-2 border-red-500 pl-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-red-400" />
|
||||||
|
<span className="text-red-400 font-medium">EEZ 진입 침범</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">침투깊이: 13.5nm 침범 / 기준: 0km (정원 경계)</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-2 border-orange-500 pl-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Activity className="w-3 h-3 text-orange-400" />
|
||||||
|
<span className="text-orange-400 font-medium">저속 운항 지속</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">관측값: 42분 / 기준: > 30분</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-2 border-green-500 pl-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Clock className="w-3 h-3 text-green-400" />
|
||||||
|
<span className="text-green-400 font-medium">야간 활동</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-hint mt-0.5">관측값: 02:00-05:00 / 기준: 야간 조업 의심</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-hint mt-3">이 판단 근거는 AI 모델 분석 결과이며, 최종 판단은 관리자가 수행합니다.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
src/features/surveillance/MapControl.tsx
Normal file
427
src/features/surveillance/MapControl.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영
|
||||||
|
* Chart of Firing and Bombing Exercise Areas in the Coasts of Korea
|
||||||
|
* 출처: 국립해양조사원 (WGS-84)
|
||||||
|
*
|
||||||
|
* 구역 분류:
|
||||||
|
* - 해군 훈련 구역 (노란색)
|
||||||
|
* - 공군 훈련 구역 (분홍색)
|
||||||
|
* - 육군 훈련 구역 (초록색)
|
||||||
|
* - 국방과학연구소 훈련구역 (파란색)
|
||||||
|
* - 해양경찰청 훈련구역 (보라색)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'navy' | 'airforce' | 'army' | 'add' | 'kcg' | 'ntm';
|
||||||
|
|
||||||
|
// ─── 훈련구역 데이터 ──────────────────────
|
||||||
|
|
||||||
|
interface TrainingZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sea: string;
|
||||||
|
lat: string;
|
||||||
|
lng: string;
|
||||||
|
radius: string;
|
||||||
|
status: string;
|
||||||
|
schedule: string;
|
||||||
|
note: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAVY_ZONES: TrainingZone[] = [
|
||||||
|
{ id: 'R-77', name: 'R-77', type: '해군', sea: '동해', lat: '38°20\'N', lng: '128°40\'E', radius: '20NM', status: '활성', schedule: '수시', note: '동해 북부 사격' },
|
||||||
|
{ id: 'R-80', name: 'R-80', type: '해군', sea: '서해', lat: '35°10\'N', lng: '125°30\'E', radius: '30NM', status: '활성', schedule: '수시', note: '서해 중부 사격' },
|
||||||
|
{ id: 'R-84', name: 'R-84', type: '해군', sea: '서해', lat: '34°00\'N~33°00\'N', lng: '125°00\'E~126°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 남부 대규모 사격구역' },
|
||||||
|
{ id: 'R-88', name: 'R-88', type: '해군', sea: '서해', lat: '35°30\'N', lng: '125°00\'E', radius: '15NM', status: '활성', schedule: '수시', note: '서해 황해 사격' },
|
||||||
|
{ id: 'R-99', name: 'R-99', type: '해군', sea: '남해', lat: '34°50\'N', lng: '128°40\'E', radius: '10NM', status: '활성', schedule: '주간', note: '남해 동부' },
|
||||||
|
{ id: 'R-100', name: 'R-100', type: '해군', sea: '남해', lat: '35°00\'N', lng: '129°10\'E', radius: '8NM', status: '활성', schedule: '주간', note: '부산 근해' },
|
||||||
|
{ id: 'R-104', name: 'R-104', type: '해군', sea: '서해', lat: '34°30\'N', lng: '125°20\'E', radius: '12NM', status: '활성', schedule: '수시', note: '고군산군도' },
|
||||||
|
{ id: 'R-105', name: 'R-105', type: '해군', sea: '서해', lat: '34°40\'N', lng: '125°10\'E', radius: '10NM', status: '활성', schedule: '수시', note: '서해 남서' },
|
||||||
|
{ id: 'R-107', name: 'R-107', type: '해군', sea: '동해', lat: '38°10\'N', lng: '129°50\'E', radius: '15NM', status: '활성', schedule: '수시', note: '동해 북부' },
|
||||||
|
{ id: 'R-115', name: 'R-115', type: '해군', sea: '동해', lat: '37°30\'N', lng: '130°00\'E', radius: '12NM', status: '활성', schedule: '주간', note: '동해 중부' },
|
||||||
|
{ id: 'R-117', name: 'R-117', type: '해군', sea: '남해', lat: '34°30\'N', lng: '127°30\'E', radius: '10NM', status: '비활성', schedule: '-', note: '여수 근해' },
|
||||||
|
{ id: 'R-118', name: 'R-118', type: '해군', sea: '남해', lat: '33°50\'N', lng: '128°20\'E', radius: '15NM', status: '활성', schedule: '수시', note: '남해 외해' },
|
||||||
|
{ id: 'R-119', name: 'R-119', type: '해군', sea: '동해', lat: '36°10\'N', lng: '129°40\'E', radius: '8NM', status: '활성', schedule: '야간', note: '울진 근해' },
|
||||||
|
{ id: 'R-120', name: 'R-120', type: '해군', sea: '동해', lat: '36°30\'N', lng: '130°10\'E', radius: '20NM', status: '활성', schedule: '수시', note: '동해 중부 외해' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AIR_ZONES: TrainingZone[] = [
|
||||||
|
{ id: 'R-108A', name: 'R-108A', type: '공군', sea: '서해', lat: '38°30\'N', lng: '124°30\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 북부 공중사격' },
|
||||||
|
{ id: 'R-108B', name: 'R-108B', type: '공군', sea: '서해', lat: '38°10\'N', lng: '124°20\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 북부' },
|
||||||
|
{ id: 'R-108C', name: 'R-108C', type: '공군', sea: '서해', lat: '37°00\'N', lng: '124°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '서해 중부' },
|
||||||
|
{ id: 'R-108D', name: 'R-108D', type: '공군', sea: '서해', lat: '38°00\'N', lng: '124°50\'E', radius: '중형', status: '활성', schedule: '주간', note: '백령도 인근' },
|
||||||
|
{ id: 'R-108E', name: 'R-108E', type: '공군', sea: '서해', lat: '38°05\'N', lng: '124°40\'E', radius: '중형', status: '활성', schedule: '주간', note: '대청도 인근' },
|
||||||
|
{ id: 'R-108F', name: 'R-108F', type: '공군', sea: '서해', lat: '37°50\'N', lng: '124°30\'E', radius: '중형', status: '활성', schedule: '수시', note: '서해 5도' },
|
||||||
|
{ id: 'R-123', name: 'R-123', type: '공군', sea: '남해', lat: '34°20\'N', lng: '126°00\'E', radius: '15NM', status: '활성', schedule: '수시', note: '진도 남방' },
|
||||||
|
{ id: 'R-124', name: 'R-124', type: '공군', sea: '서해', lat: '35°40\'N', lng: '125°40\'E', radius: '10NM', status: '활성', schedule: '주간', note: '군산 서방' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ARMY_ZONES: TrainingZone[] = [
|
||||||
|
{ id: 'R-97A', name: 'R-97A', type: '육군', sea: '서해', lat: '34°20\'N', lng: '125°40\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
|
||||||
|
{ id: 'R-97B', name: 'R-97B', type: '육군', sea: '서해', lat: '34°25\'N', lng: '125°35\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
|
||||||
|
{ id: 'R-97C', name: 'R-97C', type: '육군', sea: '서해', lat: '34°00\'N', lng: '125°20\'E', radius: '8NM', status: '활성', schedule: '수시', note: '서해 남부' },
|
||||||
|
{ id: 'R-97D', name: 'R-97D', type: '육군', sea: '서해', lat: '34°05\'N', lng: '125°15\'E', radius: '5NM', status: '비활성', schedule: '-', note: '서해 남부' },
|
||||||
|
{ id: 'R-97E', name: 'R-97E', type: '육군', sea: '서해', lat: '34°30\'N', lng: '125°50\'E', radius: '5NM', status: '활성', schedule: '주간', note: '고군산 인근' },
|
||||||
|
{ id: 'R-97F', name: 'R-97F', type: '육군', sea: '서해', lat: '34°35\'N', lng: '125°45\'E', radius: '5NM', status: '활성', schedule: '주간', note: '서해안 포사격' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADD_ZONES: TrainingZone[] = [
|
||||||
|
{ id: 'R-121', name: 'R-121', type: '국과연', sea: '동해', lat: '38°20\'N', lng: '128°50\'E', radius: '대형', status: '활성', schedule: '수시', note: '국방과학연구소 시험구역' },
|
||||||
|
{ id: 'R-125', name: 'R-125', type: '국과연', sea: '남해', lat: '34°20\'N', lng: '127°20\'E', radius: '10NM', status: '활성', schedule: '수시', note: '남해 시험' },
|
||||||
|
{ id: 'R-126', name: 'R-126', type: '국과연', sea: '제주', lat: '33°40\'N', lng: '126°30\'E', radius: '15NM', status: '활성', schedule: '수시', note: '제주 해협' },
|
||||||
|
{ id: 'R-128', name: 'R-128', type: '국과연', sea: '남해', lat: '32°50\'N', lng: '127°00\'E', radius: '대형', status: '활성', schedule: '수시', note: '남해 외해 시험' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const KCG_ZONES: TrainingZone[] = [
|
||||||
|
{ id: 'R-131', name: 'R-131', type: '해경', sea: '서해', lat: '37°40\'N', lng: '125°20\'E', radius: '5NM', status: '활성', schedule: '주간', note: '해경 서해 훈련' },
|
||||||
|
{ id: 'R-132', name: 'R-132', type: '해경', sea: '서해', lat: '37°30\'N', lng: '125°50\'E', radius: '5NM', status: '활성', schedule: '주간', note: '해경 인천 훈련' },
|
||||||
|
{ id: 'R-133', name: 'R-133', type: '해경', sea: '남해', lat: '35°20\'N', lng: '126°20\'E', radius: '3NM', status: '활성', schedule: '주간', note: '해경 남해 훈련' },
|
||||||
|
{ id: 'R-134', name: 'R-134', type: '해경', sea: '남해', lat: '35°50\'N', lng: '125°50\'E', radius: '3NM', status: '활성', schedule: '주간', note: '해경 서남해' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_ZONES = [...NAVY_ZONES, ...AIR_ZONES, ...ARMY_ZONES, ...ADD_ZONES, ...KCG_ZONES];
|
||||||
|
|
||||||
|
// ─── 항행통보 데이터 (국립해양조사원 NtM) ──────
|
||||||
|
|
||||||
|
interface NtmRecord {
|
||||||
|
no: string;
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
sea: string;
|
||||||
|
title: string;
|
||||||
|
position: string;
|
||||||
|
status: string;
|
||||||
|
detail: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NTM_DATA: NtmRecord[] = [
|
||||||
|
{ no: 'NTM-2026-0412', date: '2026-04-03', category: '사격훈련', sea: '서해', title: '서해 R-84 구역 사격훈련 실시', position: 'N34°00\' E125°30\'', status: '발령중', detail: '04-03 09:00~18:00 실탄사격 실시. 선박 진입 금지.' },
|
||||||
|
{ no: 'NTM-2026-0411', date: '2026-04-03', category: '사격훈련', sea: '동해', title: '동해 R-120 구역 해군 사격', position: 'N36°30\' E130°10\'', status: '발령중', detail: '04-03~04-05 종일 사격훈련. 반경 20NM 진입금지.' },
|
||||||
|
{ no: 'NTM-2026-0410', date: '2026-04-02', category: '기뢰제거', sea: '남해', title: '여수항 인근 기뢰 제거 작업', position: 'N34°44\' E127°46\'', status: '발령중', detail: '04-02~04-06 기뢰 탐색 및 제거. 반경 3NM 항행주의.' },
|
||||||
|
{ no: 'NTM-2026-0409', date: '2026-04-02', category: '해양공사', sea: '남해', title: '부산 신항 준설작업', position: 'N35°04\' E128°49\'', status: '발령중', detail: '04-01~04-30 야간 준설. 항행선박 감속 운항.' },
|
||||||
|
{ no: 'NTM-2026-0408', date: '2026-04-01', category: '항로표지', sea: '서해', title: '인천항 서수도 등부표 소등', position: 'N37°26\' E126°34\'', status: '발령중', detail: '등부표 고장으로 소등 중. 수리 완료 시까지 항행주의.' },
|
||||||
|
{ no: 'NTM-2026-0407', date: '2026-04-01', category: '사격훈련', sea: '서해', title: '서해 R-80 공군 폭격훈련', position: 'N35°10\' E125°30\'', status: '해제', detail: '04-01 훈련 완료. 해제됨.' },
|
||||||
|
{ no: 'NTM-2026-0406', date: '2026-03-31', category: '해양오염', sea: '남해', title: '통영 해역 유류유출 경보', position: 'N34°50\' E128°25\'', status: '해제', detail: '방제 완료. 03-31 18:00 해제.' },
|
||||||
|
{ no: 'NTM-2026-0405', date: '2026-03-30', category: '수중작업', sea: '동해', title: '포항 해저케이블 설치', position: 'N36°02\' E129°24\'', status: '발령중', detail: '03-28~04-15 해저케이블 부설. 닻 투하 금지.' },
|
||||||
|
{ no: 'NTM-2026-0404', date: '2026-03-29', category: '사격훈련', sea: '동해', title: '동해 R-77 해군 사격', position: 'N38°20\' E128°40\'', status: '해제', detail: '03-29 훈련 완료.' },
|
||||||
|
{ no: 'NTM-2026-0403', date: '2026-03-28', category: '항로변경', sea: '서해', title: '평택항 입항항로 임시변경', position: 'N36°58\' E126°49\'', status: '발령중', detail: '항로 표지 공사로 임시 우회항로 지정 (~04-10).' },
|
||||||
|
{ no: 'NTM-2026-0402', date: '2026-03-27', category: '군사훈련', sea: '서해', title: '서해 R-108A 공군 훈련', position: 'N38°30\' E124°30\'', status: '해제', detail: '03-27 훈련 완료.' },
|
||||||
|
{ no: 'NTM-2026-0401', date: '2026-03-25', category: '사격훈련', sea: '서해', title: '서해 5도 해역 경비함정 훈련', position: 'N37°45\' E124°50\'', status: '해제', detail: '해경 해상사격 완료.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
|
||||||
|
|
||||||
|
const ntmColumns: DataColumn<NtmRecord>[] = [
|
||||||
|
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||||
|
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
|
||||||
|
render: v => {
|
||||||
|
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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 범례 색상 ──────────────────────────
|
||||||
|
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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>; } },
|
||||||
|
{ 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> },
|
||||||
|
{ key: 'schedule', label: '운용', width: '60px', align: 'center' },
|
||||||
|
{ key: 'note', label: '비고', render: v => <span className="text-hint">{v as string}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// DMS 좌표 → 십진수 변환
|
||||||
|
function parseDMS(dms: string): number | null {
|
||||||
|
// "38°20'N" → 38.333... / "34°00'N~33°00'N" → 중간값
|
||||||
|
if (dms.includes('~')) {
|
||||||
|
const parts = dms.split('~');
|
||||||
|
const a = parseDMS(parts[0]);
|
||||||
|
const b = parseDMS(parts[1]);
|
||||||
|
if (a !== null && b !== null) return (a + b) / 2;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const m = dms.match(/(\d+)°(\d+)?'?([NSEW])?/);
|
||||||
|
if (!m) return null;
|
||||||
|
let val = parseInt(m[1]) + (parseInt(m[2] || '0') / 60);
|
||||||
|
if (m[3] === 'S' || m[3] === 'W') val = -val;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반경 문자열 → 미터 변환 (1NM ≈ 1852m)
|
||||||
|
function parseRadius(r: string): number {
|
||||||
|
const nm = r.match(/(\d+)\s*NM/i);
|
||||||
|
if (nm) return parseInt(nm[1]) * 1852;
|
||||||
|
if (r === '대형') return 40000;
|
||||||
|
if (r === '중형') return 25000;
|
||||||
|
return 15000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function MapControl() {
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
|
const [seaFilter, setSeaFilter] = useState('');
|
||||||
|
const [ntmCatFilter, setNtmCatFilter] = useState('');
|
||||||
|
|
||||||
|
const getZones = () => {
|
||||||
|
const zones = tab === 'navy' ? NAVY_ZONES : tab === 'airforce' ? AIR_ZONES : tab === 'army' ? ARMY_ZONES : tab === 'add' ? ADD_ZONES : tab === 'kcg' ? KCG_ZONES : ALL_ZONES;
|
||||||
|
return seaFilter ? zones.filter(z => z.sea === seaFilter) : zones;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeCount = ALL_ZONES.filter(z => z.status === '활성').length;
|
||||||
|
|
||||||
|
// 현재 표시할 구역
|
||||||
|
const visibleZones = getZones();
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => {
|
||||||
|
// 훈련구역 원 + 중심 마커
|
||||||
|
const parsedZones: { lat: number; lng: number; color: string; radiusM: number; isActive: boolean; zone: TrainingZone }[] = [];
|
||||||
|
visibleZones.forEach((z) => {
|
||||||
|
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 radiusM = parseRadius(z.radius);
|
||||||
|
const isActive = z.status === '활성';
|
||||||
|
parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 활성/비활성 구역을 opacity별로 분리
|
||||||
|
const activeZones = parsedZones.filter(pz => pz.isActive);
|
||||||
|
const inactiveZones = parsedZones.filter(pz => !pz.isActive);
|
||||||
|
|
||||||
|
// 중심점 마커
|
||||||
|
const centerMarkers = parsedZones.map((pz) => ({
|
||||||
|
lat: pz.lat,
|
||||||
|
lng: pz.lng,
|
||||||
|
color: pz.color,
|
||||||
|
radius: 600,
|
||||||
|
label: pz.zone.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
createRadiusLayer(
|
||||||
|
'zone-circles-active',
|
||||||
|
activeZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),
|
||||||
|
0.15,
|
||||||
|
),
|
||||||
|
createRadiusLayer(
|
||||||
|
'zone-circles-inactive',
|
||||||
|
inactiveZones.map(pz => ({ lat: pz.lat, lng: pz.lng, radius: pz.radiusM, color: pz.color })),
|
||||||
|
0.05,
|
||||||
|
),
|
||||||
|
createMarkerLayer('zone-centers', centerMarkers),
|
||||||
|
];
|
||||||
|
}, [visibleZones]);
|
||||||
|
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" />해역 통제</h2>
|
||||||
|
<p className="text-[10px] text-hint mt-0.5">한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: '전체 구역', value: ALL_ZONES.length, color: 'text-heading' },
|
||||||
|
{ label: '활성', value: activeCount, color: 'text-green-400' },
|
||||||
|
{ label: '해군', value: NAVY_ZONES.length, color: 'text-yellow-400' },
|
||||||
|
{ label: '공군', value: AIR_ZONES.length, color: 'text-pink-400' },
|
||||||
|
{ label: '육군', value: ARMY_ZONES.length, color: 'text-green-400' },
|
||||||
|
{ label: '국과연', value: ADD_ZONES.length, color: 'text-blue-400' },
|
||||||
|
{ label: '해경', value: KCG_ZONES.length, color: 'text-purple-400' },
|
||||||
|
].map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 + 해역 필터 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-0 border-b border-border">
|
||||||
|
{([
|
||||||
|
{ key: 'overview' as Tab, label: '전체', icon: Layers },
|
||||||
|
{ key: 'navy' as Tab, label: '해군', icon: Ship },
|
||||||
|
{ key: 'airforce' as Tab, label: '공군', icon: Target },
|
||||||
|
{ key: 'army' as Tab, label: '육군', icon: Crosshair },
|
||||||
|
{ key: 'add' as Tab, label: '국과연', icon: Shield },
|
||||||
|
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
|
||||||
|
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
|
||||||
|
]).map(t => (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<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'}`}>
|
||||||
|
{s || '전체'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 항행통보 탭 ── */}
|
||||||
|
{tab === 'ntm' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 항행통보 KPI */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
|
||||||
|
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
|
||||||
|
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
|
||||||
|
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
|
||||||
|
].map(k => (
|
||||||
|
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
|
||||||
|
<span className="text-[9px] text-hint">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 필터 */}
|
||||||
|
<div className="flex items-center gap-1.5 px-4 py-2 rounded-xl border border-border bg-card">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 발령 중 통보 하이라이트 */}
|
||||||
|
<Card><CardContent className="p-4">
|
||||||
|
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400" />현재 발령 중 항행통보
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono">{n.position}</div>
|
||||||
|
<div className="text-[9px] text-hint mt-0.5">{n.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* 전체 통보 DataTable */}
|
||||||
|
<DataTable
|
||||||
|
data={ntmCatFilter ? NTM_DATA.filter(n => n.category === ntmCatFilter) : NTM_DATA}
|
||||||
|
columns={ntmColumns}
|
||||||
|
pageSize={10}
|
||||||
|
searchPlaceholder="통보번호, 제목, 해역 검색..."
|
||||||
|
searchKeys={['no', 'title', 'sea', 'category', 'position']}
|
||||||
|
exportFilename="항행통보"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-[9px] text-hint text-right">출처: 국립해양조사원 항행통보 (https://www.khoa.go.kr/nwb)</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 훈련구역 시각화 맵 (간략) — ntm 탭이 아닐 때 */}
|
||||||
|
{/* 훈련구역 (ntm 탭이 아닐 때만) */}
|
||||||
|
{tab !== 'ntm' && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0 relative">
|
||||||
|
<BaseMap ref={mapRef} key={`${tab}-${seaFilter}`} center={[35.8, 127.5]} zoom={7} height={480} className="w-full rounded-lg overflow-hidden" />
|
||||||
|
{/* 범례 오버레이 */}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-orange-500/70" /><span className="text-[8px] text-hint">NLL</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[7px] text-hint mt-1">구역 클릭 시 상세정보 표시</div>
|
||||||
|
</div>
|
||||||
|
{/* 표시 구역 수 */}
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||||
|
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}개</span>
|
||||||
|
<span className="text-[9px] text-hint ml-1">훈련구역 표시 중</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<DataTable
|
||||||
|
data={getZones()}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={12}
|
||||||
|
searchPlaceholder="구역번호, 해역, 비고 검색..."
|
||||||
|
searchKeys={['id', 'name', 'sea', 'note', 'type']}
|
||||||
|
exportFilename="해상사격훈련구역"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/features/surveillance/index.ts
Normal file
2
src/features/surveillance/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { LiveMapView } from './LiveMapView';
|
||||||
|
export { MapControl } from './MapControl';
|
||||||
153
src/features/vessel/TransferDetection.tsx
Normal file
153
src/features/vessel/TransferDetection.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import { Ship, MapPin } from 'lucide-react';
|
||||||
|
import { useTransferStore } from '@stores/transferStore';
|
||||||
|
|
||||||
|
export function TransferDetection() {
|
||||||
|
const { transfers, load } = useTransferStore();
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const transferData = useMemo(
|
||||||
|
() =>
|
||||||
|
transfers.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
time: t.time,
|
||||||
|
a: t.vesselA,
|
||||||
|
b: t.vesselB,
|
||||||
|
dist: t.distance,
|
||||||
|
dur: t.duration,
|
||||||
|
spd: t.speed,
|
||||||
|
score: t.score,
|
||||||
|
loc: t.location,
|
||||||
|
})),
|
||||||
|
[transfers],
|
||||||
|
);
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 탐지 조건 */}
|
||||||
|
<Card className="bg-surface-overlay border-slate-700/40">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="text-xs text-muted-foreground mb-3">탐지 조건</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-surface-raised rounded-lg p-4">
|
||||||
|
<div className="text-[11px] text-hint mb-1">거리</div>
|
||||||
|
<div className="text-xl font-bold text-heading">≤ 100m</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-raised rounded-lg p-4">
|
||||||
|
<div className="text-[11px] text-hint mb-1">시간</div>
|
||||||
|
<div className="text-xl font-bold text-heading">≥ 30분</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-raised rounded-lg p-4">
|
||||||
|
<div className="text-[11px] text-hint mb-1">속도</div>
|
||||||
|
<div className="text-xl font-bold text-heading">≤ 3kn</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 환적 이벤트 카드 */}
|
||||||
|
{transferData.map((tr) => (
|
||||||
|
<Card key={tr.id} className="bg-surface-overlay border-slate-700/40">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-heading font-bold text-lg">{tr.id}</h3>
|
||||||
|
<div className="text-[11px] text-hint">{tr.time}</div>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-red-500 text-heading text-xs">환적 의심도: {tr.score}%</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 선박 A & B + 타임라인 */}
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-blue-400 mb-2">
|
||||||
|
<Ship className="w-3.5 h-3.5" />선박 A
|
||||||
|
</div>
|
||||||
|
<div className="text-heading font-bold">{tr.a.name}</div>
|
||||||
|
<div className="text-[11px] text-hint">MMSI: {tr.a.mmsi}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-teal-400 mb-2">
|
||||||
|
<Ship className="w-3.5 h-3.5" />선박 B
|
||||||
|
</div>
|
||||||
|
<div className="text-heading font-bold">{tr.b.name}</div>
|
||||||
|
<div className="text-[11px] text-hint">MMSI: {tr.b.mmsi}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접촉 타임라인 */}
|
||||||
|
<div className="bg-surface-raised rounded-lg p-3">
|
||||||
|
<div className="text-[11px] text-muted-foreground mb-2">접촉 타임라인</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-blue-400">접근 시작</span>
|
||||||
|
<span className="text-hint">거리: 500m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
<span className="text-yellow-400">근접 유지</span>
|
||||||
|
<span className="text-hint">거리: {tr.dist}m, 지속: {tr.dur}분</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
<span className="text-red-400">의심 행위 감지</span>
|
||||||
|
<span className="text-hint">평균 속도: {tr.spd}kn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 접촉 정보 */}
|
||||||
|
<div className="w-[200px] shrink-0 space-y-3">
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="text-[11px] text-muted-foreground mb-2">접촉 정보</div>
|
||||||
|
<div className="space-y-1.5 text-[11px]">
|
||||||
|
<div className="flex justify-between"><span className="text-hint">최소 거리</span><span className="text-heading font-medium">{tr.dist}m</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">접촉 시간</span><span className="text-heading font-medium">{tr.dur}분</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-hint">평균 속도</span><span className="text-heading font-medium">{tr.spd}kn</span></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface-raised border-slate-700/30">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground mb-1">
|
||||||
|
<MapPin className="w-3 h-3" />위치
|
||||||
|
</div>
|
||||||
|
<div className="text-heading text-sm font-medium">{tr.loc}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="bg-purple-950/30 border border-purple-900/30 rounded-xl p-3">
|
||||||
|
<div className="text-[11px] text-purple-400 mb-1.5">환적 의심도</div>
|
||||||
|
<div className="h-2 bg-switch-background rounded-full overflow-hidden mb-1.5">
|
||||||
|
<div
|
||||||
|
className="h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||||
|
style={{ width: `${tr.score}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-heading">{tr.score}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-sm py-2.5 rounded-lg transition-colors">
|
||||||
|
상세 분석 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
src/features/vessel/VesselDetail.tsx
Normal file
393
src/features/vessel/VesselDetail.tsx
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Search, ChevronDown, ChevronUp, ChevronRight, Plus, X,
|
||||||
|
Ship, AlertTriangle, Radar, Anchor, MapPin, Printer,
|
||||||
|
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
|
import type { MarkerData } from '@lib/map';
|
||||||
|
|
||||||
|
// TODO: 향후 store 통합 시 교체 — VesselDetail의 VesselTrack 형상(callSign, source, detail 등)이
|
||||||
|
// useVesselStore().vessels(VesselData)와 구조가 달라 현재는 인라인 데이터 유지
|
||||||
|
// ─── 선박 데이터 ──────────────────────
|
||||||
|
interface VesselTrack {
|
||||||
|
id: string;
|
||||||
|
mmsi: string;
|
||||||
|
callSign: string;
|
||||||
|
source: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
country: string;
|
||||||
|
detail: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VESSELS: VesselTrack[] = [
|
||||||
|
{
|
||||||
|
id: '1', mmsi: '440162980', callSign: '122@', source: 'AIS',
|
||||||
|
name: '504 FAREKIMHO', type: 'Fishing', country: 'Korea(Republic of)',
|
||||||
|
detail: {
|
||||||
|
'청코드': '부산', '호출부호': '951554', '입항횟수': '006', '전송구분': '최종',
|
||||||
|
'선명': '태평양호', '선박종류': '어선', '총톤수': '30', '국제톤수': '30',
|
||||||
|
'입항일시': '2023-03-28 16:00', '계선장소': '기타 남항 사설조선소',
|
||||||
|
'전출항지': '2023-03-28 16:00', '전출항지항구명': '김천', '위험물톤수': '-',
|
||||||
|
'외내항구분': '내항', '입항수리일자': '2023-03-24',
|
||||||
|
'한국인선원수': '5', '외국인선원수': '9', '예선': 'N', '도선': 'N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', mmsi: '440162923', callSign: '122@', source: 'AIS',
|
||||||
|
name: 'ZZ', type: 'V-Pass', country: 'Korea(Republic of)',
|
||||||
|
detail: {
|
||||||
|
'청코드': '인천', '호출부호': '862331', '입항횟수': '012', '전송구분': '최종',
|
||||||
|
'선명': '금강호', '선박종류': '어선', '총톤수': '45', '국제톤수': '45',
|
||||||
|
'입항일시': '2023-04-15 09:00', '계선장소': '인천항 제2부두',
|
||||||
|
'전출항지': '2023-04-15 09:00', '전출항지항구명': '인천', '위험물톤수': '-',
|
||||||
|
'외내항구분': '내항', '입항수리일자': '2023-04-10',
|
||||||
|
'한국인선원수': '3', '외국인선원수': '7', '예선': 'N', '도선': 'N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 특이운항 / 비허가 선박 ──────────────
|
||||||
|
const ALERT_VESSELS = [
|
||||||
|
{ name: '제303 대양호', highlight: true },
|
||||||
|
{ name: '제609 한일호', highlight: false },
|
||||||
|
{ name: '한진아일랜드 고속훼리', highlight: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── AI 조업 분석 데이터 ─────────────────
|
||||||
|
interface FishingAnalysis {
|
||||||
|
no: number;
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
eezPermit: '허가' | '무허가';
|
||||||
|
vesselType: '어선' | '어구';
|
||||||
|
gearType: string;
|
||||||
|
gearIcon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FISHING_ANALYSIS: FishingAnalysis[] = [
|
||||||
|
{ no: 1, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '무허가', vesselType: '어구', gearType: '쌍끌이', gearIcon: '🚢' },
|
||||||
|
{ no: 2, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '범장망', gearIcon: '🚢' },
|
||||||
|
{ no: 3, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
||||||
|
{ no: 4, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
||||||
|
{ no: 5, mmsi: '440162980', name: '504 FAREKIMHO', eezPermit: '허가', vesselType: '어선', gearType: '-', gearIcon: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GEAR_FILTERS = ['외끌이', '쌍끌이', '트롤', '범장망', '형망', '채낚기', '통망'];
|
||||||
|
|
||||||
|
// ─── 지도 마커 ────────────────────────
|
||||||
|
const MAP_MARKERS = [
|
||||||
|
{ id: 'm1', x: 72, y: 38, label: '현재선박명', sensors: ['E', 'A', 'V'] },
|
||||||
|
{ id: 'm2', x: 65, y: 43, label: '현재선박명', sensors: ['V', 'B', 'A'] },
|
||||||
|
{ id: 'm3', x: 73, y: 49, label: '현재선박명', sensors: ['A', 'V', 'E'] },
|
||||||
|
];
|
||||||
|
const VTS_MARKERS = [{ id: 'vts1', x: 52, y: 63, label: '태안연안', sub: 'VTS 신호수신 선박명' }];
|
||||||
|
const PATROL_MARKERS = [
|
||||||
|
{ id: 'p1', x: 62, y: 63, label: 'E204', sub: '함정레이더 신호수신 선박명' },
|
||||||
|
{ id: 'p2', x: 80, y: 70, label: 'E204', sub: '함정레이더 신호수신 선박명' },
|
||||||
|
];
|
||||||
|
const CLUSTERS = [
|
||||||
|
{ x: 58, y: 22, n: 10 }, { x: 75, y: 30, n: 5 }, { x: 52, y: 55, n: 5 }, { x: 35, y: 68, n: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RIGHT_TOOLS = [
|
||||||
|
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
||||||
|
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
|
||||||
|
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 메인 컴포넌트 ────────────────────
|
||||||
|
|
||||||
|
export function VesselDetail() {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>('2');
|
||||||
|
const [startDate, setStartDate] = useState('2023-08-20 11:30:02');
|
||||||
|
const [endDate, setEndDate] = useState('2023-08-20 11:30:02');
|
||||||
|
const [shipId, setShipId] = useState('');
|
||||||
|
const [showAiPanel, setShowAiPanel] = useState(false);
|
||||||
|
const [gearChecks, setGearChecks] = useState<Record<string, boolean>>({ '쌍끌이': true, '범장망': true });
|
||||||
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
|
const buildLayers = useCallback(() => [
|
||||||
|
...STATIC_LAYERS,
|
||||||
|
|
||||||
|
// 관할해역 구역
|
||||||
|
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map(a => ({
|
||||||
|
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
||||||
|
})), 80000, 0.05),
|
||||||
|
|
||||||
|
// 등심선
|
||||||
|
...DEPTH_CONTOURS.map((contour, i) =>
|
||||||
|
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
||||||
|
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
// 선박 마커
|
||||||
|
createMarkerLayer('vessels', MAP_MARKERS.map((m): MarkerData => {
|
||||||
|
const lat = 34.2 + Math.random() * 2;
|
||||||
|
const lng = 125.5 + Math.random() * 3;
|
||||||
|
return { lat, lng, color: '#3b82f6', radius: 800, label: m.label };
|
||||||
|
})),
|
||||||
|
|
||||||
|
// VTS 마커
|
||||||
|
createMarkerLayer('vts', VTS_MARKERS.map((m): MarkerData => ({
|
||||||
|
lat: 34.0, lng: 126.2, color: '#eab308', radius: 800, label: m.label,
|
||||||
|
}))),
|
||||||
|
|
||||||
|
// 함정 마커
|
||||||
|
createMarkerLayer('patrols', PATROL_MARKERS.map((m): MarkerData => ({
|
||||||
|
lat: 33.5 + Math.random(), lng: 127.0 + Math.random(), color: '#a855f7', radius: 800, label: m.label,
|
||||||
|
}))),
|
||||||
|
|
||||||
|
// 클러스터
|
||||||
|
createMarkerLayer('clusters', CLUSTERS.map((c, i): MarkerData => ({
|
||||||
|
lat: 33.0 + i * 0.8, lng: 125.5 + i * 0.5, color: '#6b7280', radius: 2400, label: `${c.n}척`,
|
||||||
|
}))),
|
||||||
|
|
||||||
|
// 선박충돌 알림
|
||||||
|
createMarkerLayer('alerts', [{
|
||||||
|
lat: 33.8, lng: 127.5, color: '#ef4444', radius: 1400, label: '선박충돌',
|
||||||
|
}]),
|
||||||
|
], []);
|
||||||
|
|
||||||
|
useMapLayers(mapRef, buildLayers, []);
|
||||||
|
|
||||||
|
const toggleGear = (g: string) => setGearChecks((p) => ({ ...p, [g]: !p[g] }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
||||||
|
|
||||||
|
{/* ── 좌측: 항적조회 패널 ── */}
|
||||||
|
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
|
||||||
|
{/* 헤더: 검색 조건 */}
|
||||||
|
<div className="p-3 border-b border-border space-y-2">
|
||||||
|
<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)}
|
||||||
|
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" />
|
||||||
|
<span className="text-hint text-[10px]">~</span>
|
||||||
|
<input 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" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] text-hint w-14 shrink-0">조회간격</span>
|
||||||
|
<select className="bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none w-16">
|
||||||
|
<option>전체</option><option>1분</option><option>5분</option><option>10분</option><option>30분</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-[9px] text-hint ml-2 shrink-0">선박ID</span>
|
||||||
|
<input value={shipId} onChange={(e) => setShipId(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" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button className="flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300">
|
||||||
|
<Plus className="w-3 h-3" />선박추가
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선박 카드 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{VESSELS.map((v) => {
|
||||||
|
const isOpen = expandedId === v.id;
|
||||||
|
return (
|
||||||
|
<div key={v.id} className="border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-surface-overlay cursor-pointer" onClick={() => setExpandedId(isOpen ? null : v.id)}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[9px] text-hint">
|
||||||
|
ID | <span className="text-label">{v.mmsi}</span>
|
||||||
|
<span className="ml-2">호출부호 | <span className="text-label">{v.callSign}</span></span>
|
||||||
|
<span className="ml-2">출처 | <span className="text-label">{v.source}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[11px] font-bold text-heading">{v.name}</span>
|
||||||
|
<Badge className={`text-[7px] px-1 py-0 border-0 ${v.type === 'Fishing' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'}`}>{v.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] text-hint mt-0.5 flex items-center gap-1">🇰🇷 {v.country}</div>
|
||||||
|
</div>
|
||||||
|
{isOpen ? <ChevronUp className="w-4 h-4 text-blue-400" /> : <ChevronDown className="w-4 h-4 text-hint" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||||
|
{Object.entries(v.detail).map(([k, val], i) => (
|
||||||
|
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||||
|
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||||
|
<span className="flex-1 px-2.5 py-1.5 text-label">{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 중앙: 지도 ── */}
|
||||||
|
<div className="flex-1 relative bg-card/40 overflow-hidden">
|
||||||
|
|
||||||
|
{/* 상단 패널: 특이운항 + 비허가/재제선박 */}
|
||||||
|
<div className="absolute top-3 left-3 z-10 flex gap-2">
|
||||||
|
{(['특이운항', '비허가/재제선박'] as const).map((title) => (
|
||||||
|
<div key={title} className="bg-card/95 backdrop-blur-sm rounded-lg border border-border w-52">
|
||||||
|
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold text-heading">{title}</span>
|
||||||
|
<ChevronDown className="w-3 h-3 text-hint" />
|
||||||
|
</div>
|
||||||
|
{ALERT_VESSELS.map((v, i) => (
|
||||||
|
<button key={i} className={`w-full flex items-center justify-between px-3 py-1.5 text-[9px] transition-colors ${v.highlight ? 'bg-red-600/80 text-heading' : 'text-label hover:bg-surface-overlay'}`}>
|
||||||
|
{v.name}<ChevronRight className="w-3 h-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 조업 분석 패널 (토글) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAiPanel(!showAiPanel)}
|
||||||
|
className="absolute top-3 right-14 z-20 flex items-center gap-1.5 bg-blue-600/90 backdrop-blur-sm text-heading rounded-lg px-3 py-1.5 text-[10px] font-bold hover:bg-blue-500 transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
<Brain className="w-3.5 h-3.5" />AI 조업 분석
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAiPanel && (
|
||||||
|
<div className="absolute top-12 right-14 z-20 w-[480px] bg-input-background/98 backdrop-blur-md rounded-xl border border-blue-500/30 shadow-2xl shadow-blue-900/20 overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-overlay border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-[11px] font-bold text-heading">AI 조업 분석</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowAiPanel(false)} className="text-hint hover:text-heading"><X className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택선박 + 조업식별 필터 */}
|
||||||
|
<div className="px-4 py-2.5 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<Anchor className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">선택선박</span>
|
||||||
|
<span className="text-heading font-bold text-sm">50</span>
|
||||||
|
<span className="text-hint">척</span>
|
||||||
|
</div>
|
||||||
|
<button className="ml-auto bg-blue-600 text-heading rounded-lg px-4 py-1.5 text-[10px] font-bold flex items-center gap-1 hover:bg-blue-500 transition-colors">
|
||||||
|
<Search className="w-3 h-3" />검색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="text-[9px] text-hint mr-1 flex items-center gap-1"><Radar className="w-3 h-3" />조업식별</span>
|
||||||
|
{GEAR_FILTERS.map((g) => (
|
||||||
|
<label key={g} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox" checked={!!gearChecks[g]}
|
||||||
|
onChange={() => toggleGear(g)}
|
||||||
|
className="w-3 h-3 rounded border-slate-600 bg-secondary text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-label">{g}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<div className="grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-1.5 text-[9px] text-hint font-medium border-b border-border bg-surface-overlay">
|
||||||
|
<span>구분</span>
|
||||||
|
<span>선박ID/선박명</span>
|
||||||
|
<span>EEZ허가</span>
|
||||||
|
<span>어선/어구</span>
|
||||||
|
<span>조업식별</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 행 */}
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{FISHING_ANALYSIS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.no}
|
||||||
|
className={`grid grid-cols-[32px_1fr_70px_70px_90px] gap-1 px-4 py-2.5 items-center border-b border-border hover:bg-surface-overlay cursor-pointer transition-colors ${row.no === 1 ? 'bg-surface-overlay' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{row.no}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-[8px] text-hint">ID | {row.mmsi}</div>
|
||||||
|
<div className="text-[11px] font-bold text-heading">{row.name}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-bold ${row.eezPermit === '무허가' ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{row.eezPermit}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-label">{row.vesselType}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{row.gearType !== '-' && (
|
||||||
|
<Badge className={`text-[8px] px-1.5 py-0.5 border-0 ${
|
||||||
|
row.gearType === '쌍끌이' ? 'bg-orange-500/20 text-orange-400'
|
||||||
|
: row.gearType === '범장망' ? 'bg-purple-500/20 text-purple-400'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{row.gearIcon && <span className="mr-0.5">{row.gearIcon}</span>}
|
||||||
|
{row.gearType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{row.gearType === '-' && <span className="text-[10px] text-hint">-</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MapLibre GL + deck.gl 지도 */}
|
||||||
|
<BaseMap
|
||||||
|
ref={mapRef}
|
||||||
|
center={[34.5, 126.5]}
|
||||||
|
zoom={7}
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 하단 좌표 바 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-6 bg-background/90 backdrop-blur-sm border-t border-border flex items-center justify-center gap-4 px-4 z-[1000]">
|
||||||
|
<span className="flex items-center gap-1 text-[8px]">
|
||||||
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||||
|
<span className="text-hint">위도</span>
|
||||||
|
<span className="text-green-400 font-mono font-bold">34.5000</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[8px]">
|
||||||
|
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||||
|
<span className="text-hint">경도</span>
|
||||||
|
<span className="text-green-400 font-mono font-bold">126.5000</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px]">
|
||||||
|
<span className="text-blue-400 font-bold">UTC</span>
|
||||||
|
<span className="text-label font-mono ml-1">2023-07-10(월) 12:32:45</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[7px] text-hint">8,531 | 0 25 50NM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 우측 도구바 ── */}
|
||||||
|
<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}>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/features/vessel/index.ts
Normal file
2
src/features/vessel/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { VesselDetail } from './VesselDetail';
|
||||||
|
export { TransferDetection } from './TransferDetection';
|
||||||
83
src/lib/charts/BaseChart.tsx
Normal file
83
src/lib/charts/BaseChart.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* ECharts 코어 래퍼 컴포넌트
|
||||||
|
* - 자동 리사이즈 (ResizeObserver)
|
||||||
|
* - kcg-dark 테마 자동 적용
|
||||||
|
* - dispose 자동 정리
|
||||||
|
*/
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { BarChart, LineChart, PieChart, RadarChart, HeatmapChart } from 'echarts/charts';
|
||||||
|
import {
|
||||||
|
GridComponent, TooltipComponent, LegendComponent,
|
||||||
|
TitleComponent, DatasetComponent, PolarComponent,
|
||||||
|
RadarComponent,
|
||||||
|
} from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
import type { EChartsOption, ECharts } from 'echarts';
|
||||||
|
import './theme';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
BarChart, LineChart, PieChart, RadarChart, HeatmapChart,
|
||||||
|
GridComponent, TooltipComponent, LegendComponent,
|
||||||
|
TitleComponent, DatasetComponent, PolarComponent,
|
||||||
|
RadarComponent, CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface BaseChartProps {
|
||||||
|
option: EChartsOption;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
height?: number;
|
||||||
|
notMerge?: boolean;
|
||||||
|
onEvents?: Record<string, (params: unknown) => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseChart({
|
||||||
|
option,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
height = 200,
|
||||||
|
notMerge = false,
|
||||||
|
onEvents,
|
||||||
|
}: BaseChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartRef = useRef<ECharts | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const chart = echarts.init(containerRef.current, 'kcg-dark');
|
||||||
|
chartRef.current = chart;
|
||||||
|
chart.setOption(option, notMerge);
|
||||||
|
|
||||||
|
if (onEvents) {
|
||||||
|
Object.entries(onEvents).forEach(([event, handler]) => {
|
||||||
|
chart.on(event, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => chart.resize());
|
||||||
|
ro.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
chart.dispose();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.setOption(option, notMerge);
|
||||||
|
}
|
||||||
|
}, [option, notMerge]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={className}
|
||||||
|
style={{ width: '100%', height, ...style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/lib/charts/index.ts
Normal file
4
src/lib/charts/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { BaseChart } from './BaseChart';
|
||||||
|
export { AreaChart, BarChart, PieChart, LineChart } from './presets';
|
||||||
|
export { THEME_NAME } from './theme';
|
||||||
|
export { chartSeriesColors, riskColors } from './tokens';
|
||||||
40
src/lib/charts/presets/AreaChart.tsx
Normal file
40
src/lib/charts/presets/AreaChart.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { BaseChart } from '../BaseChart';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { chartSeriesColors } from '@lib/theme';
|
||||||
|
|
||||||
|
interface Series {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaChartProps {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
xKey: string;
|
||||||
|
series: Series[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
yAxisDomain?: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AreaChart({ data, xKey, series, height = 200, className, yAxisDomain }: AreaChartProps) {
|
||||||
|
const option = useMemo<EChartsOption>(() => ({
|
||||||
|
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: data.map(d => d[xKey] as string), boundaryGap: false },
|
||||||
|
yAxis: { type: 'value', ...(yAxisDomain ? { min: yAxisDomain[0], max: yAxisDomain[1] } : {}) },
|
||||||
|
series: series.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
data: data.map(d => d[s.key] as number),
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: { opacity: 0.12 },
|
||||||
|
itemStyle: { color: s.color ?? chartSeriesColors[i % chartSeriesColors.length] },
|
||||||
|
})),
|
||||||
|
}), [data, xKey, series, yAxisDomain]);
|
||||||
|
|
||||||
|
return <BaseChart option={option} height={height} className={className} />;
|
||||||
|
}
|
||||||
53
src/lib/charts/presets/BarChart.tsx
Normal file
53
src/lib/charts/presets/BarChart.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { BaseChart } from '../BaseChart';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { chartSeriesColors } from '@lib/theme';
|
||||||
|
|
||||||
|
interface Series {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
xKey: string;
|
||||||
|
series: Series[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
horizontal?: boolean;
|
||||||
|
/** 각 데이터 항목별 개별 색상 배열 (단일 시리즈에서 Cell 대체) */
|
||||||
|
itemColors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarChart({ data, xKey, series, height = 200, className, horizontal, itemColors }: BarChartProps) {
|
||||||
|
const option = useMemo<EChartsOption>(() => {
|
||||||
|
const categoryData = data.map(d => d[xKey] as string);
|
||||||
|
const xAxisConfig = horizontal
|
||||||
|
? { type: 'value' as const }
|
||||||
|
: { type: 'category' as const, data: categoryData };
|
||||||
|
const yAxisConfig = horizontal
|
||||||
|
? { type: 'category' as const, data: categoryData }
|
||||||
|
: { type: 'value' as const };
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid: { top: 10, right: 10, bottom: 24, left: horizontal ? 60 : 36, containLabel: false },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: xAxisConfig,
|
||||||
|
yAxis: yAxisConfig,
|
||||||
|
series: series.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'bar',
|
||||||
|
data: itemColors && series.length === 1
|
||||||
|
? data.map((d, j) => ({
|
||||||
|
value: d[s.key] as number,
|
||||||
|
itemStyle: { color: itemColors[j] ?? chartSeriesColors[i % chartSeriesColors.length] },
|
||||||
|
}))
|
||||||
|
: data.map(d => d[s.key] as number),
|
||||||
|
itemStyle: { color: s.color ?? chartSeriesColors[i % chartSeriesColors.length], borderRadius: 2 },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [data, xKey, series, horizontal, itemColors]);
|
||||||
|
|
||||||
|
return <BaseChart option={option} height={height} className={className} />;
|
||||||
|
}
|
||||||
39
src/lib/charts/presets/LineChart.tsx
Normal file
39
src/lib/charts/presets/LineChart.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { BaseChart } from '../BaseChart';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import { chartSeriesColors } from '@lib/theme';
|
||||||
|
|
||||||
|
interface Series {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
xKey: string;
|
||||||
|
series: Series[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineChart({ data, xKey, series, height = 200, className }: LineChartProps) {
|
||||||
|
const option = useMemo<EChartsOption>(() => ({
|
||||||
|
grid: { top: 10, right: 10, bottom: 24, left: 36, containLabel: false },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: data.map(d => d[xKey] as string), boundaryGap: false },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: series.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: data.map(d => d[s.key] as number),
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
itemStyle: { color: s.color ?? chartSeriesColors[i % chartSeriesColors.length] },
|
||||||
|
})),
|
||||||
|
}), [data, xKey, series]);
|
||||||
|
|
||||||
|
return <BaseChart option={option} height={height} className={className} />;
|
||||||
|
}
|
||||||
39
src/lib/charts/presets/PieChart.tsx
Normal file
39
src/lib/charts/presets/PieChart.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { BaseChart } from '../BaseChart';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
|
interface PieDataItem {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieChartProps {
|
||||||
|
data: PieDataItem[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
/** 도넛 차트 내부 반지름 (0이면 일반 파이) */
|
||||||
|
innerRadius?: number;
|
||||||
|
outerRadius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PieChart({ data, height = 200, className, innerRadius = 35, outerRadius = 65 }: PieChartProps) {
|
||||||
|
const option = useMemo<EChartsOption>(() => ({
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: [innerRadius, outerRadius],
|
||||||
|
center: ['50%', '50%'],
|
||||||
|
padAngle: 2,
|
||||||
|
itemStyle: { borderRadius: 4 },
|
||||||
|
label: { show: false },
|
||||||
|
data: data.map(d => ({
|
||||||
|
name: d.name,
|
||||||
|
value: d.value,
|
||||||
|
...(d.color ? { itemStyle: { color: d.color } } : {}),
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
}), [data, innerRadius, outerRadius]);
|
||||||
|
|
||||||
|
return <BaseChart option={option} height={height} className={className} />;
|
||||||
|
}
|
||||||
4
src/lib/charts/presets/index.ts
Normal file
4
src/lib/charts/presets/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { AreaChart } from './AreaChart';
|
||||||
|
export { BarChart } from './BarChart';
|
||||||
|
export { PieChart } from './PieChart';
|
||||||
|
export { LineChart } from './LineChart';
|
||||||
38
src/lib/charts/theme.ts
Normal file
38
src/lib/charts/theme.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* ECharts 'kcg-dark' 테마 등록
|
||||||
|
* 프로젝트 다크 UI에 맞춘 차트 기본 스타일
|
||||||
|
*/
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { chartSeriesColors } from '@lib/theme';
|
||||||
|
import { resolvedColors } from '@lib/theme/tokens';
|
||||||
|
|
||||||
|
const kcgDark: object = {
|
||||||
|
color: [...chartSeriesColors],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
textStyle: { color: resolvedColors.mutedForeground },
|
||||||
|
title: { textStyle: { color: resolvedColors.foreground }, subtextStyle: { color: resolvedColors.mutedForeground } },
|
||||||
|
categoryAxis: {
|
||||||
|
axisLine: { lineStyle: { color: resolvedColors.secondary } },
|
||||||
|
axisTick: { lineStyle: { color: resolvedColors.secondary } },
|
||||||
|
axisLabel: { color: '#64748b', fontSize: 9 },
|
||||||
|
splitLine: { lineStyle: { color: resolvedColors.secondary, type: 'dashed' } },
|
||||||
|
},
|
||||||
|
valueAxis: {
|
||||||
|
axisLine: { lineStyle: { color: resolvedColors.secondary } },
|
||||||
|
axisTick: { lineStyle: { color: resolvedColors.secondary } },
|
||||||
|
axisLabel: { color: '#64748b', fontSize: 9 },
|
||||||
|
splitLine: { lineStyle: { color: resolvedColors.secondary, type: 'dashed' } },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#0d1117',
|
||||||
|
borderColor: resolvedColors.secondary,
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: resolvedColors.foreground, fontSize: 11 },
|
||||||
|
extraCssText: 'border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.4);',
|
||||||
|
},
|
||||||
|
legend: { textStyle: { color: resolvedColors.mutedForeground, fontSize: 10 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
echarts.registerTheme('kcg-dark', kcgDark);
|
||||||
|
|
||||||
|
export const THEME_NAME = 'kcg-dark';
|
||||||
5
src/lib/charts/tokens.ts
Normal file
5
src/lib/charts/tokens.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 차트 전용 색상 토큰
|
||||||
|
* lib/theme/colors.ts 기반, ECharts option에서 직접 사용
|
||||||
|
*/
|
||||||
|
export { chartSeriesColors, riskColors } from '@lib/theme';
|
||||||
72
src/lib/i18n/config.ts
Normal file
72
src/lib/i18n/config.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import koCommon from './locales/ko/common.json';
|
||||||
|
import koDashboard from './locales/ko/dashboard.json';
|
||||||
|
import koDetection from './locales/ko/detection.json';
|
||||||
|
import koPatrol from './locales/ko/patrol.json';
|
||||||
|
import koEnforcement from './locales/ko/enforcement.json';
|
||||||
|
import koStatistics from './locales/ko/statistics.json';
|
||||||
|
import koAi from './locales/ko/ai.json';
|
||||||
|
import koFieldOps from './locales/ko/fieldOps.json';
|
||||||
|
import koAdmin from './locales/ko/admin.json';
|
||||||
|
import koAuth from './locales/ko/auth.json';
|
||||||
|
|
||||||
|
import enCommon from './locales/en/common.json';
|
||||||
|
import enDashboard from './locales/en/dashboard.json';
|
||||||
|
import enDetection from './locales/en/detection.json';
|
||||||
|
import enPatrol from './locales/en/patrol.json';
|
||||||
|
import enEnforcement from './locales/en/enforcement.json';
|
||||||
|
import enStatistics from './locales/en/statistics.json';
|
||||||
|
import enAi from './locales/en/ai.json';
|
||||||
|
import enFieldOps from './locales/en/fieldOps.json';
|
||||||
|
import enAdmin from './locales/en/admin.json';
|
||||||
|
import enAuth from './locales/en/auth.json';
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
ko: {
|
||||||
|
common: koCommon,
|
||||||
|
dashboard: koDashboard,
|
||||||
|
detection: koDetection,
|
||||||
|
patrol: koPatrol,
|
||||||
|
enforcement: koEnforcement,
|
||||||
|
statistics: koStatistics,
|
||||||
|
ai: koAi,
|
||||||
|
fieldOps: koFieldOps,
|
||||||
|
admin: koAdmin,
|
||||||
|
auth: koAuth,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: enCommon,
|
||||||
|
dashboard: enDashboard,
|
||||||
|
detection: enDetection,
|
||||||
|
patrol: enPatrol,
|
||||||
|
enforcement: enEnforcement,
|
||||||
|
statistics: enStatistics,
|
||||||
|
ai: enAi,
|
||||||
|
fieldOps: enFieldOps,
|
||||||
|
admin: enAdmin,
|
||||||
|
auth: enAuth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lng: 'ko',
|
||||||
|
fallbackLng: 'ko',
|
||||||
|
supportedLngs: ['ko', 'en'],
|
||||||
|
defaultNS: 'common',
|
||||||
|
ns: [
|
||||||
|
'common',
|
||||||
|
'dashboard',
|
||||||
|
'detection',
|
||||||
|
'patrol',
|
||||||
|
'enforcement',
|
||||||
|
'statistics',
|
||||||
|
'ai',
|
||||||
|
'fieldOps',
|
||||||
|
'admin',
|
||||||
|
'auth',
|
||||||
|
],
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
2
src/lib/i18n/index.ts
Normal file
2
src/lib/i18n/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as i18n } from './config';
|
||||||
|
export { useTranslation } from 'react-i18next';
|
||||||
22
src/lib/i18n/locales/en/admin.json
Normal file
22
src/lib/i18n/locales/en/admin.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"accessControl": {
|
||||||
|
"title": "Access Control",
|
||||||
|
"desc": "SFR-01 | User account, role, permission & audit log management"
|
||||||
|
},
|
||||||
|
"systemConfig": {
|
||||||
|
"title": "System Settings",
|
||||||
|
"desc": "SFR-02 | Common code reference data & system parameter management"
|
||||||
|
},
|
||||||
|
"notices": {
|
||||||
|
"title": "Notice Management",
|
||||||
|
"desc": "SFR-02 | System notice, banner, popup & notification management"
|
||||||
|
},
|
||||||
|
"adminPanel": {
|
||||||
|
"title": "System Admin",
|
||||||
|
"desc": "Server, DB, security & backup infrastructure monitoring"
|
||||||
|
},
|
||||||
|
"dataHub": {
|
||||||
|
"title": "Data Hub",
|
||||||
|
"desc": "SFR-03 | Integrated data collection, linkage & channel monitoring"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/i18n/locales/en/ai.json
Normal file
14
src/lib/i18n/locales/en/ai.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"modelManagement": {
|
||||||
|
"title": "AI Model Management",
|
||||||
|
"desc": "SFR-04 | Prediction model registry, detection rules, features, pipeline"
|
||||||
|
},
|
||||||
|
"mlops": {
|
||||||
|
"title": "MLOps / LLMOps",
|
||||||
|
"desc": "SFR-18/19 | ML & LLM experiment, deployment, monitoring"
|
||||||
|
},
|
||||||
|
"assistant": {
|
||||||
|
"title": "AI Decision Support (Q&A)",
|
||||||
|
"desc": "SFR-20 | Natural language query with RAG-based law, case & prediction answers"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/lib/i18n/locales/en/auth.json
Normal file
67
src/lib/i18n/locales/en/auth.json
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"title": "AI-based IUU Fishing Detection & Response Platform",
|
||||||
|
"subtitle": "Korea Coast Guard",
|
||||||
|
"authMethod": {
|
||||||
|
"password": "ID/PW Login",
|
||||||
|
"passwordDesc": "Enter ID & Password",
|
||||||
|
"gpki": "GPKI Auth",
|
||||||
|
"gpkiDesc": "Gov. Digital Signature",
|
||||||
|
"sso": "SSO Login",
|
||||||
|
"ssoDesc": "KCG Unified Auth"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"userId": "User ID",
|
||||||
|
"userIdPlaceholder": "KCG Unified Account ID",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Password (min. 9 chars, alphanumeric + special)"
|
||||||
|
},
|
||||||
|
"passwordPolicy": {
|
||||||
|
"title": "Password Policy",
|
||||||
|
"minLength": "- Min. 9 characters (letters, numbers, special characters)",
|
||||||
|
"changeInterval": "- Must be changed every 90 days",
|
||||||
|
"lockout": "- Account locked after 5 failed attempts (30 min)",
|
||||||
|
"reuse": "- Cannot reuse last 3 passwords"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"locked": "Account is locked. Please retry after {{minutes}} min.",
|
||||||
|
"emptyId": "Please enter your ID.",
|
||||||
|
"emptyPassword": "Please enter your password.",
|
||||||
|
"invalidPassword": "Password must be at least 9 characters. (letters + numbers + special chars)",
|
||||||
|
"maxFailed": "Login failed {{max}} times — account locked for 30 minutes.",
|
||||||
|
"wrongCredentials": "Invalid ID or password. ({{count}}/{{max}} failed attempts)"
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"login": "Login",
|
||||||
|
"authenticating": "Authenticating..."
|
||||||
|
},
|
||||||
|
"demo": {
|
||||||
|
"title": "Quick Demo Login",
|
||||||
|
"admin": "Admin",
|
||||||
|
"operator": "Operator",
|
||||||
|
"analyst": "Analyst",
|
||||||
|
"field": "Field",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"gpki": {
|
||||||
|
"title": "GPKI Certificate Authentication",
|
||||||
|
"desc": "Please insert your government ID or GPKI certificate",
|
||||||
|
"certStatus": "Certificate Status",
|
||||||
|
"certWaiting": "Waiting for certificate...",
|
||||||
|
"authenticating": "GPKI authenticating...",
|
||||||
|
"start": "Start GPKI Auth",
|
||||||
|
"internalOnly": "* GPKI authentication is only available on the KCG internal network"
|
||||||
|
},
|
||||||
|
"sso": {
|
||||||
|
"title": "KCG Single Sign-On (SSO)",
|
||||||
|
"desc": "Auto-login via KCG SSO system integration",
|
||||||
|
"tokenDetected": "SSO token detected — auto-authentication available",
|
||||||
|
"authenticating": "SSO authenticating...",
|
||||||
|
"autoLogin": "SSO Auto Login",
|
||||||
|
"sessionNote": "* Auto-authentication is available when your KCG session is active"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"version": "v1.0.0 | AI IUU Fishing Detection Platform",
|
||||||
|
"org": "KCG Info & Communications Div."
|
||||||
|
},
|
||||||
|
"accessNotice": "This system is restricted to authorized KCG personnel only. Unauthorized access will be prosecuted under applicable laws."
|
||||||
|
}
|
||||||
94
src/lib/i18n/locales/en/common.json
Normal file
94
src/lib/i18n/locales/en/common.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"monitoring": "Alert Monitor",
|
||||||
|
"riskMap": "Risk Map",
|
||||||
|
"enforcementPlan": "Enforcement Plan",
|
||||||
|
"darkVessel": "Dark Vessel",
|
||||||
|
"gearDetection": "Gear Detection",
|
||||||
|
"chinaFishing": "Chinese Vessel",
|
||||||
|
"patrolRoute": "Patrol Route",
|
||||||
|
"fleetOptimization": "Fleet Optimize",
|
||||||
|
"enforcementHistory": "History",
|
||||||
|
"eventList": "Event List",
|
||||||
|
"mobileService": "Mobile",
|
||||||
|
"shipAgent": "Ship Agent",
|
||||||
|
"aiAlert": "AI Alert",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"externalService": "External API",
|
||||||
|
"reports": "Reports",
|
||||||
|
"aiModel": "AI Model",
|
||||||
|
"mlops": "MLOps",
|
||||||
|
"aiAssistant": "AI Q&A",
|
||||||
|
"dataHub": "Data Hub",
|
||||||
|
"systemConfig": "Settings",
|
||||||
|
"notices": "Notices",
|
||||||
|
"accessControl": "Access",
|
||||||
|
"admin": "Admin"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"tracking": "Tracking",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"pending": "Pending"
|
||||||
|
},
|
||||||
|
"alert": {
|
||||||
|
"critical": "Critical",
|
||||||
|
"high": "High",
|
||||||
|
"medium": "Medium",
|
||||||
|
"low": "Low"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"search": "Search",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"export": "Export",
|
||||||
|
"print": "Print",
|
||||||
|
"download": "Download",
|
||||||
|
"upload": "Upload",
|
||||||
|
"filter": "Filter",
|
||||||
|
"reset": "Reset",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"brandTitle": "Fishing Patrol",
|
||||||
|
"brandSub": "AI Detection Platform",
|
||||||
|
"alertCount_one": "{{count}} Alert",
|
||||||
|
"alertCount_other": "{{count}} Alerts",
|
||||||
|
"searchPlaceholder": "Search vessel, MMSI, area...",
|
||||||
|
"pageSearch": "Search in page...",
|
||||||
|
"sessionExpiring": "Session expiring",
|
||||||
|
"extendNeeded": "Extend needed",
|
||||||
|
"auth": "Auth:",
|
||||||
|
"logout": "Logout",
|
||||||
|
"download": "Download",
|
||||||
|
"excel": "Excel",
|
||||||
|
"print": "Print",
|
||||||
|
"noExportTable": "No table to export.",
|
||||||
|
"fileDownload": "File Download",
|
||||||
|
"excelExport": "Excel (CSV) Export"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"ADMIN": "Admin",
|
||||||
|
"OPERATOR": "Operator",
|
||||||
|
"ANALYST": "Analyst",
|
||||||
|
"FIELD": "Field Officer",
|
||||||
|
"VIEWER": "Viewer"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"detection": "Detection",
|
||||||
|
"patrol": "Patrol",
|
||||||
|
"enforcement": "Enforcement",
|
||||||
|
"fieldOps": "Field Ops",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"aiOps": "AI Ops",
|
||||||
|
"admin": "Admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/lib/i18n/locales/en/dashboard.json
Normal file
10
src/lib/i18n/locales/en/dashboard.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Situation Dashboard",
|
||||||
|
"desc": "SFR-12 | Real-time detection, risk heatmap, alert timeline, patrol status"
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"title": "Alert Dashboard",
|
||||||
|
"desc": "SFR-12 | Real-time alert & detection monitoring dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/lib/i18n/locales/en/detection.json
Normal file
18
src/lib/i18n/locales/en/detection.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"darkVessel": {
|
||||||
|
"title": "Dark Vessel Detection",
|
||||||
|
"desc": "SFR-09 | AIS manipulation, disguise, and dark vessel pattern detection"
|
||||||
|
},
|
||||||
|
"gearDetection": {
|
||||||
|
"title": "Gear Detection",
|
||||||
|
"desc": "SFR-10 | Illegal gear location, permit status, and risk monitoring"
|
||||||
|
},
|
||||||
|
"chinaFishing": {
|
||||||
|
"title": "Chinese Vessel Analysis",
|
||||||
|
"desc": "SFR-09/10 | Multi-sensor Chinese fishing pattern, transfer & gear analysis"
|
||||||
|
},
|
||||||
|
"gearId": {
|
||||||
|
"title": "Gear Identification",
|
||||||
|
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/i18n/locales/en/enforcement.json
Normal file
14
src/lib/i18n/locales/en/enforcement.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"history": {
|
||||||
|
"title": "Enforcement History",
|
||||||
|
"desc": "SFR-11 | Enforcement records, AI match verification, result tracking"
|
||||||
|
},
|
||||||
|
"eventList": {
|
||||||
|
"title": "Event List",
|
||||||
|
"desc": "SFR-02 | Integrated event listing, filtering, and assignment"
|
||||||
|
},
|
||||||
|
"enforcementPlan": {
|
||||||
|
"title": "Enforcement Plan & Alert",
|
||||||
|
"desc": "SFR-06 | Risk + past enforcement → priority area/time auto-recommendation & alerts"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/i18n/locales/en/fieldOps.json
Normal file
14
src/lib/i18n/locales/en/fieldOps.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mobileService": {
|
||||||
|
"title": "Mobile Service",
|
||||||
|
"desc": "SFR-15 | Field officer risk, suspect vessel, route & alert mobile view"
|
||||||
|
},
|
||||||
|
"shipAgent": {
|
||||||
|
"title": "Ship Agent",
|
||||||
|
"desc": "SFR-16 | Vessel terminal AI agent deployment, sync & task status"
|
||||||
|
},
|
||||||
|
"aiAlert": {
|
||||||
|
"title": "AI Alert Dispatch",
|
||||||
|
"desc": "SFR-17 | AI detection result instant alert message to field units"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user